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/app.py ADDED
@@ -0,0 +1,876 @@
1
+ """Textual dashboard: changes across sources, merge queues, and per-source tables.
2
+
3
+ Layout (design/README.md): a status line with one pill per source, a strip of
4
+ bordered chips per merge queue, then a Changes tab (records correlated by ref or
5
+ sha, grouped by what needs attention) beside an inspector. Every view a source
6
+ declares stays reachable as its own tab.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import json
13
+ import time
14
+ import webbrowser
15
+ from typing import Any, ClassVar
16
+ from urllib.parse import urlparse
17
+
18
+ from rich.text import Text
19
+ from textual import on, work
20
+ from textual.app import App, ComposeResult
21
+ from textual.binding import Binding
22
+ from textual.containers import Horizontal, Vertical
23
+ from textual.content import Content
24
+ from textual.events import Resize
25
+ from textual.widgets import Button, DataTable, Footer, Input, Static, TabbedContent, TabPane
26
+
27
+ from . import widgets
28
+ from .adapters import Adapter
29
+ from .changes import Change, Hit, correlate, grouped
30
+ from .config import Source
31
+ from .model import lookup, views_for
32
+ from .process import clean
33
+ from .widgets import (
34
+ MUTED,
35
+ STATE_STYLE,
36
+ ChangeSelected,
37
+ ChangeTable,
38
+ ChipClicked,
39
+ Health,
40
+ Inspector,
41
+ QueueStrip,
42
+ StatusBar,
43
+ ago,
44
+ ci_status,
45
+ clock,
46
+ dim,
47
+ duration,
48
+ elapsed,
49
+ eta_of,
50
+ eta_text,
51
+ fit,
52
+ glyph,
53
+ human_seconds,
54
+ lines,
55
+ muted,
56
+ queue_status,
57
+ raw,
58
+ rich_style,
59
+ short_age,
60
+ state_text,
61
+ )
62
+
63
+ FETCH_ERRORS = (OSError, RuntimeError, ValueError, KeyError, TypeError)
64
+ CHANGES = "changes-tab"
65
+ CHILD_TTL = 15
66
+ BAR = 8 # job duration bar cells; a job line fits the narrowest inspector
67
+
68
+ CSS = """
69
+ Screen { background: $surface; }
70
+ #status { height: 1; padding: 0 1; background: $panel; color: $foreground; }
71
+ #status #brand { width: auto; margin-right: 2; color: $primary; }
72
+ #status .pill { width: auto; padding: 0 1; margin-right: 1; }
73
+ #status .pill.live { background: $success 25%; color: $text-success; }
74
+ #status .pill.paused { background: $primary 20%; color: $text-primary; }
75
+ #status .pill.waiting { background: $panel; color: $text-muted; }
76
+ #status .pill.stale { background: $warning 30%; color: $text-warning; }
77
+ #status .pill.unavailable { background: $error 30%; color: $text-error; }
78
+ #status .counts { width: 1fr; text-align: right; }
79
+ #status #counts-short { display: none; }
80
+ .-narrow #status #counts-long { display: none; }
81
+ .-narrow #status #counts-short { display: block; }
82
+
83
+ #queues { height: auto; }
84
+ .queue { height: auto; padding: 0 1; border-bottom: solid $foreground 20%; }
85
+ .queue-head { height: 1; }
86
+ .queue-head .queue-title { width: auto; }
87
+ .queue-head .queue-eta { width: 1fr; min-width: 18; padding-left: 2; }
88
+ .queue-head .spark-label { width: auto; margin-left: 2; }
89
+ .queue-head .spark { margin: 0 1; }
90
+ .queue-head .rate { width: auto; margin-left: 2; }
91
+ .queue-head .took-stats { width: auto; }
92
+ .queue-head .depth, .queue-head .took { display: none; }
93
+ .queue.-depth .queue-head .depth, .queue.-took .queue-head .took { display: block; }
94
+ .-narrow .queue .queue-head .spark, .-narrow .queue .queue-head .depth { display: none; }
95
+ Sparkline > .sparkline--max-color { color: $error; }
96
+ Sparkline > .sparkline--min-color { color: $success; }
97
+ .chain { height: auto; }
98
+ .chain .link { width: 3; height: 4; content-align: center middle; color: $text-muted; }
99
+ .chain .more { width: auto; height: 4; content-align: center middle; color: $text-muted; padding: 0 1; }
100
+ .chip {
101
+ width: 1fr; height: 4; padding: 0 1; background: $surface;
102
+ border: round $foreground 30%; border-title-color: $text-muted;
103
+ text-wrap: nowrap; text-overflow: ellipsis;
104
+ }
105
+ .chip:hover { border: round $primary 60%; }
106
+ .chip.holder { border: heavy $primary; background: $primary 14%; border-title-color: $primary; }
107
+ .-ascii .chip { border: ascii $foreground 40%; }
108
+ .-ascii .chip.holder { border: ascii $primary; }
109
+ .-short .chip, .-short .chip:hover, .-short .chip.holder { height: 1; border: none; }
110
+ .-short .chip:hover { background: $primary 30%; }
111
+ .-short .chain .link, .-short .chain .more { height: 1; }
112
+
113
+ #search { display: none; height: 1; border: none; padding: 0 1; margin: 0 1; background: $panel; }
114
+ #body { height: 1fr; }
115
+ TabbedContent { width: 1fr; height: 1fr; }
116
+ TabbedContent > ContentSwitcher { height: 1fr; }
117
+ TabPane { padding: 0; height: 1fr; }
118
+ Tabs { height: 2; }
119
+ #changes { height: 1fr; }
120
+ #colhead { height: 1; padding: 0 1; background: $panel; }
121
+ OptionList {
122
+ height: 1fr; background: $surface; border: none; padding: 0; scrollbar-size-vertical: 1;
123
+ }
124
+ OptionList:focus { border: none; }
125
+ OptionList > .option-list--option { padding: 0 1; }
126
+ OptionList > .option-list--option-highlighted { background: $primary 28%; color: $foreground; text-style: none; }
127
+ OptionList:focus > .option-list--option-highlighted { background: $primary 40%; color: $foreground; text-style: none; }
128
+ OptionList > .option-list--option-hover { background: $primary 12%; }
129
+ OptionList > .option-list--option-disabled { color: $text-muted; }
130
+ DataTable { height: 1fr; background: $surface; }
131
+ DataTable > .datatable--header { background: $panel; color: $text-muted; text-style: bold; }
132
+ DataTable > .datatable--cursor { background: $primary 30%; color: $foreground; }
133
+
134
+ #inspector {
135
+ width: 32%; min-width: 40; max-width: 64; height: 1fr; padding: 0 1;
136
+ border-left: solid $foreground 20%; background: $surface; scrollbar-size-vertical: 1;
137
+ }
138
+ #insp-head { height: auto; padding-bottom: 1; }
139
+ #insp-actions { height: auto; padding-bottom: 1; }
140
+ #insp-actions Button { margin-right: 1; background: $primary 30%; color: $foreground; }
141
+ #sections { height: auto; }
142
+ .section-title { background: $panel; padding: 0 1; margin-top: 1; height: 1; }
143
+ .section-body { height: auto; padding: 0 1; }
144
+
145
+ #notices { display: none; height: auto; max-height: 4; padding: 0 1; color: $text-warning; background: $panel; }
146
+ """
147
+
148
+
149
+ def safe_url(url: Any) -> bool:
150
+ if not isinstance(url, str):
151
+ return False
152
+ parsed = urlparse(url)
153
+ return parsed.scheme == "https" and bool(parsed.netloc)
154
+
155
+
156
+ def cell(app: App, record: dict, column: dict) -> Text:
157
+ value = lookup(record, column["key"])
158
+ match column.get("format", "text"):
159
+ case "state":
160
+ return state_text(app, record)
161
+ case "clock":
162
+ return Text(clock(value))
163
+ case "age":
164
+ return Text(short_age(ago(value)), style=rich_style(app, MUTED))
165
+ case "seconds":
166
+ return Text(f"{value:.1f}s" if isinstance(value, (int, float)) else "-")
167
+ case "duration":
168
+ return Text(duration(value))
169
+ case "sha":
170
+ return Text(clean(value or "")[:8], style=rich_style(app, MUTED))
171
+ return Text(clean(value) if value is not None else "-")
172
+
173
+
174
+ def describe(record: dict) -> list[Content]:
175
+ rows = [Content.assemble(muted("State "), glyph(record["state"]), f" {raw(record)}")]
176
+ for key, label in (("ref", "Branch"), ("sha", "SHA"), ("owner", "Owner"), ("position", "#")):
177
+ if record.get(key) is not None:
178
+ rows.append(Content.assemble(muted(f"{label} "), clean(record[key])))
179
+ for key in ("created_at", "updated_at", "started_at", "finished_at"):
180
+ if record.get(key):
181
+ label = key.removesuffix("_at").capitalize()
182
+ rows.append(Content.assemble(muted(f"{label} "), clock(record[key])))
183
+ for name, value in (record.get("fields") or {}).items():
184
+ if isinstance(value, (dict, list)):
185
+ value = json.dumps(value)
186
+ shown = clean(value) if value is not None else "-"
187
+ rows.append(Content.assemble(muted(f"{clean(name).replace('_', ' ')} "), shown))
188
+ return rows
189
+
190
+
191
+ def job_tree(jobs: list[dict]) -> list[Content]:
192
+ """Jobs with duration bars; steps only for jobs that are running or failed."""
193
+ took = [elapsed(job) for job in jobs]
194
+ scale = max((t for t in took if t), default=1)
195
+ rows: list[Content] = []
196
+ for index, (job, seconds) in enumerate(zip(jobs, took, strict=True)):
197
+ last = index == len(jobs) - 1
198
+ style = STATE_STYLE.get(job["state"], MUTED)
199
+ if seconds:
200
+ filled = max(1, round(BAR * seconds / scale))
201
+ bar = Content.assemble(
202
+ (widgets.SYMBOLS.bar_full * filled, style),
203
+ muted(widgets.SYMBOLS.bar_empty * (BAR - filled)),
204
+ )
205
+ length = human_seconds(seconds)
206
+ else:
207
+ bar, length = dim(widgets.SYMBOLS.bar_empty * BAR), widgets.SYMBOLS.dash
208
+ rows.append(
209
+ Content.assemble(
210
+ muted(f"{widgets.SYMBOLS.elbow if last else widgets.SYMBOLS.tee} "),
211
+ glyph(job["state"]),
212
+ " ",
213
+ fit(Content(clean(job["title"])), 14),
214
+ " ",
215
+ bar,
216
+ " ",
217
+ fit(Content(length), 6, "right"),
218
+ )
219
+ )
220
+ if job["state"] in ("running", "failure"):
221
+ trunk = " " if last else f"{widgets.SYMBOLS.pipe} "
222
+ for step in job.get("children") or []:
223
+ if step["state"] in ("running", "failure"):
224
+ rows.append(
225
+ Content.assemble(
226
+ muted(f"{trunk} "), glyph(step["state"]), f" {clean(step['title'])}"
227
+ )
228
+ )
229
+ return rows
230
+
231
+
232
+ class QueueWatch(App):
233
+ TITLE = "queue-watch"
234
+ CSS = CSS
235
+ BINDINGS: ClassVar = [
236
+ Binding("q", "quit", "Quit"),
237
+ Binding("r", "refresh", "Refresh"),
238
+ Binding("p", "pause", "Pause"),
239
+ Binding("slash", "search", "Search"),
240
+ Binding("escape", "clear", "Clear", show=False),
241
+ Binding("o", "open", "Open"),
242
+ Binding("c", "copy_sha", "Copy SHA"),
243
+ Binding("d", "density", "Density"),
244
+ Binding("1", "tab(0)", "Changes"),
245
+ *(Binding(str(n), f"tab({n - 1})", show=False) for n in range(2, 10)),
246
+ ]
247
+
248
+ def __init__(
249
+ self,
250
+ sources: list[Source],
251
+ *,
252
+ theme: str = "tokyo-night",
253
+ ascii: bool = False,
254
+ density: str = "compact",
255
+ ):
256
+ super().__init__()
257
+ self.sources = {source.id: source for source in sources}
258
+ self.order = list(self.sources)
259
+ self.initial_theme = theme
260
+ self.initial_density = density
261
+ if ascii:
262
+ widgets.use_ascii()
263
+ self.set_class(ascii, "-ascii")
264
+ self.snapshots: dict[str, dict] = {}
265
+ self.errors: dict[str, str] = {}
266
+ self.updated: dict[str, float] = {}
267
+ self.busy: set[str] = set()
268
+ self.paused = False
269
+ # Pane name -> (source id, view); names are generated, never user text.
270
+ self.panes: dict[str, tuple[str, dict]] = {}
271
+ self.pane_ids = itertools.count()
272
+ self.records: dict[str, dict[str, dict]] = {}
273
+ self.strips: dict[str, QueueStrip] = {}
274
+ self.changes: dict[str, Change] = {}
275
+ self.by_record: dict[tuple[str, str], Change] = {}
276
+ self.selected: str | None = None
277
+ self.shown: tuple | None = None
278
+ self.child_cache: dict[tuple[str, str], tuple[float, list]] = {}
279
+ self.generation = 0
280
+ self.rendering = False
281
+ self.settled = False
282
+
283
+ def compose(self) -> ComposeResult:
284
+ yield StatusBar(len(self.sources))
285
+ yield Vertical(id="queues")
286
+ yield Input(placeholder="Search changes and records (esc to clear)", id="search")
287
+ with Horizontal(id="body"):
288
+ with TabbedContent(id="tabs", initial=CHANGES), TabPane("Changes", id=CHANGES):
289
+ yield ChangeTable(self.initial_density)
290
+ yield Inspector()
291
+ yield Static(id="notices")
292
+ yield Footer()
293
+
294
+ def on_mount(self) -> None:
295
+ if self.initial_theme in self.available_themes:
296
+ self.theme = self.initial_theme
297
+ self.theme_changed_signal.subscribe(self, lambda _theme: self.rebuild_tables())
298
+ for source in self.sources.values():
299
+ self.set_interval(source.interval, lambda s=source.id: self.poll(s))
300
+ self.set_interval(1, self.render_status)
301
+ self.render_status()
302
+ self.query_one("#rows").focus()
303
+ self.action_refresh()
304
+
305
+ def on_resize(self, event: Resize) -> None:
306
+ self.set_class(event.size.width < 120, "-narrow")
307
+ short = event.size.height < 30
308
+ if short != self.has_class("-short"):
309
+ self.set_class(short, "-short")
310
+ for chip in self.query(widgets.QueueChip):
311
+ chip.refresh(layout=True)
312
+
313
+ # --- polling ----------------------------------------------------------- #
314
+
315
+ def poll(self, source_id: str) -> None:
316
+ if not self.paused:
317
+ self.fetch(source_id)
318
+
319
+ @work
320
+ async def fetch(self, source_id: str) -> None:
321
+ if source_id in self.busy:
322
+ return
323
+ self.busy.add(source_id)
324
+ try:
325
+ snapshot = await self.sources[source_id].snapshot()
326
+ self.snapshots[source_id] = snapshot
327
+ self.updated[source_id] = time.monotonic()
328
+ self.errors.pop(source_id, None)
329
+ await self.ensure_panes(source_id)
330
+ await self.ensure_strip(source_id)
331
+ if source_id in self.strips:
332
+ self.strips[source_id].sample(snapshot["records"])
333
+ except FETCH_ERRORS as error:
334
+ self.errors[source_id] = clean(str(error) or type(error).__name__)
335
+ finally:
336
+ self.busy.discard(source_id)
337
+ await self.rebuild()
338
+ self.settle()
339
+ self.render_status()
340
+
341
+ def settle(self) -> None:
342
+ """Once every source has answered, leave an empty Changes tab for real data."""
343
+ if self.settled or any(s not in self.updated and s not in self.errors for s in self.order):
344
+ return
345
+ self.settled = True
346
+ tabs = self.query_one("#tabs", TabbedContent)
347
+ if not self.changes and self.panes and tabs.active == CHANGES:
348
+ first = min(self.panes, key=lambda pane: self.order.index(self.panes[pane][0]))
349
+ tabs.active = f"{first}-tab"
350
+ self.query_one(f"#{first}", DataTable).focus()
351
+
352
+ async def ensure_panes(self, source_id: str) -> None:
353
+ """Add one tab per (source, view) the first time that view appears."""
354
+ tabs = self.query_one("#tabs", TabbedContent)
355
+ known = {(sid, view["kind"]) for sid, view in self.panes.values()}
356
+ for view in views_for(self.snapshots[source_id]):
357
+ if (source_id, view["kind"]) in known:
358
+ continue
359
+ # Sources answer in any order; keep tabs in configuration order.
360
+ later = [
361
+ f"{pane}-tab"
362
+ for pane, (sid, _) in self.panes.items()
363
+ if self.order.index(sid) > self.order.index(source_id)
364
+ ]
365
+ name = f"pane{next(self.pane_ids)}"
366
+ table = DataTable(id=name, cursor_type="row")
367
+ for column in view["columns"]:
368
+ table.add_column(column.get("label", column["key"]), width=column.get("width"))
369
+ first = all(sid != source_id for sid, _ in self.panes.values())
370
+ pane = TabPane(self.pane_label(source_id, view, 0, first), table, id=f"{name}-tab")
371
+ await tabs.add_pane(pane, before=later[0] if later else None)
372
+ # Register only once mounted: other fetches rebuild tables concurrently.
373
+ self.records[name] = {}
374
+ self.panes[name] = (source_id, view)
375
+
376
+ async def ensure_strip(self, source_id: str) -> None:
377
+ if source_id in self.strips:
378
+ return
379
+ snapshot = self.snapshots[source_id]
380
+ kinds = {r["kind"] for r in snapshot["records"]} | {v["kind"] for v in views_for(snapshot)}
381
+ if "queue_entry" not in kinds:
382
+ return
383
+ strip = QueueStrip(source_id, self.sources[source_id].label)
384
+ later = [
385
+ self.strips[sid]
386
+ for sid in self.order[self.order.index(source_id) + 1 :]
387
+ if sid in self.strips
388
+ ]
389
+ await self.query_one("#queues", Vertical).mount(strip, before=later[0] if later else None)
390
+ self.strips[source_id] = strip
391
+
392
+ def pane_label(self, source_id: str, view: dict, count: int, first: bool) -> Content:
393
+ """``Source · View 4`` on a source's first tab, ``View 4`` on the rest."""
394
+ label = view.get("label") or view["kind"]
395
+ if len(self.sources) > 1 and first:
396
+ label = f"{self.sources[source_id].label} {widgets.SYMBOLS.dot} {label}"
397
+ return Content(f"{clean(label)} {count}")
398
+
399
+ # --- rendering ------------------------------------------------------- #
400
+
401
+ def query_text(self) -> str:
402
+ return self.query_one("#search", Input).value.casefold()
403
+
404
+ def matches(self, record: dict) -> bool:
405
+ query = self.query_text()
406
+ return not query or query in json.dumps(record, ensure_ascii=False).casefold()
407
+
408
+ def change_matches(self, change: Change) -> bool:
409
+ query = self.query_text()
410
+ if not query:
411
+ return True
412
+ hits = (*change.runs, *change.entries, *change.attempts)
413
+ return query in change.key.casefold() or any(self.matches(hit.record) for hit in hits)
414
+
415
+ async def rebuild(self) -> None:
416
+ snapshots = {s: self.snapshots[s] for s in self.order if s in self.snapshots}
417
+ changes = correlate(snapshots)
418
+ self.changes = {change.key: change for change in changes}
419
+ self.by_record = {
420
+ (hit.source, hit.record["id"]): change
421
+ for change in changes
422
+ for hit in (*change.runs, *change.entries, *change.attempts)
423
+ }
424
+ visible = [change for change in changes if self.change_matches(change)]
425
+ ordered = [change for *_, members in grouped(visible) for change in members]
426
+ if self.selected not in {change.key for change in ordered}:
427
+ self.selected = ordered[0].key if ordered else None
428
+ empty = self.empty_changes(snapshots)
429
+ queue = any(change.entries or change.attempts for change in changes)
430
+ self.query_one(ChangeTable).show(visible, queue, self.selected, empty)
431
+ tabs = self.query_one("#tabs", TabbedContent)
432
+ tabs.get_tab(CHANGES).label = f"Changes {len(visible)}"
433
+ for source_id, strip in self.strips.items():
434
+ records = snapshots.get(source_id, {}).get("records", [])
435
+ mine = {
436
+ rid: change for (sid, rid), change in self.by_record.items() if sid == source_id
437
+ }
438
+ await strip.show(records, mine)
439
+ self.rebuild_tables()
440
+ await self.show_selection()
441
+
442
+ def empty_changes(self, snapshots: dict[str, dict]) -> str:
443
+ """Why the Changes tab has no rows, in the user's terms."""
444
+ if self.query_text():
445
+ return (
446
+ f'No changes match "{clean(self.query_one("#search", Input).value)}". esc to clear.'
447
+ )
448
+ pending = [
449
+ clean(self.sources[s].label)
450
+ for s in self.order
451
+ if s not in self.updated and s not in self.errors
452
+ ]
453
+ if not snapshots:
454
+ if pending:
455
+ return f"Waiting for {', '.join(pending)}..."
456
+ return "No source answered. Their errors are listed at the bottom."
457
+ if pending:
458
+ return f"No changes yet. Still waiting for {', '.join(pending)}..."
459
+ records = sum(len(snapshot["records"]) for snapshot in snapshots.values())
460
+ if not records:
461
+ return "Every source answered with no records."
462
+ noun = "record names" if records == 1 else "records name"
463
+ return (
464
+ f"None of the {records} {noun} a branch or commit on a run, queue entry, or attempt. "
465
+ "The source tabs list them."
466
+ )
467
+
468
+ def rebuild_tables(self) -> None:
469
+ self.rendering = True
470
+ tabs = self.query_one("#tabs", TabbedContent)
471
+ for name, (source_id, view) in self.panes.items():
472
+ rows = [
473
+ r
474
+ for r in self.snapshots.get(source_id, {}).get("records", [])
475
+ if r["kind"] == view["kind"] and self.matches(r)
476
+ ]
477
+ self.records[name] = {r["id"]: r for r in rows}
478
+ first = next(pane for pane, (sid, _) in self.panes.items() if sid == source_id)
479
+ label = self.pane_label(source_id, view, len(rows), first == name)
480
+ tabs.get_tab(f"{name}-tab").label = label
481
+ table = self.query_one(f"#{name}", DataTable)
482
+ old_key = None
483
+ if table.row_count:
484
+ old_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
485
+ scroll_x, scroll_y = table.scroll_x, table.scroll_y
486
+ table.clear()
487
+ for record in rows:
488
+ table.add_row(*(cell(self, record, c) for c in view["columns"]), key=record["id"])
489
+ keys = [r["id"] for r in rows]
490
+ if keys:
491
+ table.move_cursor(row=keys.index(old_key) if old_key in keys else 0, animate=False)
492
+ table.scroll_to(x=scroll_x, y=scroll_y, animate=False)
493
+ self.rendering = False
494
+
495
+ def health(self) -> list[Health]:
496
+ items = []
497
+ now = time.monotonic()
498
+ for source_id, source in self.sources.items():
499
+ age = now - self.updated[source_id] if source_id in self.updated else None
500
+ if age is None:
501
+ word = "unavailable" if source_id in self.errors else "waiting"
502
+ elif source_id in self.errors or age > max(3 * source.interval, 20):
503
+ word = "stale"
504
+ else:
505
+ word = "paused" if self.paused else "live"
506
+ items.append(Health(source.label, word, age))
507
+ return items
508
+
509
+ def render_status(self) -> None:
510
+ if not self.is_running:
511
+ return
512
+ counts = None
513
+ if self.snapshots:
514
+ counts = {
515
+ key: len(members) for key, _, _, members in grouped(list(self.changes.values()))
516
+ }
517
+ self.query_one(StatusBar).show(self.health(), counts)
518
+ notes = []
519
+ for source_id, source in self.sources.items():
520
+ problems = []
521
+ if source_id in self.errors:
522
+ problems.append(self.errors[source_id])
523
+ problems += self.snapshots.get(source_id, {}).get("warnings", [])
524
+ if problems:
525
+ notes.append(f"{clean(source.label)}: {clean('; '.join(problems))}")
526
+ notices = self.query_one("#notices", Static)
527
+ notices.update(Content("\n".join(notes)))
528
+ notices.display = bool(notes)
529
+
530
+ # --- selection and inspector ------------------------------------------- #
531
+
532
+ def active_pane(self) -> str | None:
533
+ """Pane name of the active source tab, or None on the Changes tab."""
534
+ active = self.query_one("#tabs", TabbedContent).active
535
+ name = (active or "").removesuffix("-tab")
536
+ return name if name in self.panes else None
537
+
538
+ def current_record(self, name: str) -> dict | None:
539
+ table = self.query_one(f"#{name}", DataTable)
540
+ if not table.row_count:
541
+ return None
542
+ key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
543
+ return self.records[name].get(key)
544
+
545
+ def selection(self) -> tuple[str | None, str | None]:
546
+ """(url, sha) of whatever the inspector shows."""
547
+ name = self.active_pane()
548
+ if name is None:
549
+ change = self.changes.get(self.selected or "")
550
+ return (change.url, change.sha) if change else (None, None)
551
+ record = self.current_record(name) or {}
552
+ return record.get("url"), record.get("sha")
553
+
554
+ async def show_selection(self, reset: bool = False) -> None:
555
+ if not self.is_running:
556
+ return
557
+ inspector = self.query_one(Inspector)
558
+ name = self.active_pane()
559
+ if name is None:
560
+ change = self.changes.get(self.selected or "")
561
+ target = ("change", self.selected)
562
+ if change is None:
563
+ # Nothing is selected only when the table has no rows: say why, as it does.
564
+ reason = self.query_one(ChangeTable).empty
565
+ await inspector.show(dim(reason), [], url=False, sha=False, reset=True)
566
+ self.shown = target
567
+ return
568
+ head, sections, lazy = self.change_view(change)
569
+ else:
570
+ source_id = self.panes[name][0]
571
+ record = self.current_record(name)
572
+ target = ("record", name, record["id"] if record else None)
573
+ if record is None:
574
+ if self.query_text():
575
+ text = "No rows match the search. esc to clear."
576
+ else:
577
+ label = self.sources[source_id].label
578
+ text = f"{label} has sent no rows for this view yet."
579
+ await inspector.show(dim(clean(text)), [], url=False, sha=False, reset=True)
580
+ self.shown = target
581
+ return
582
+ head, sections, lazy = self.record_view(source_id, record)
583
+ url, sha = self.selection()
584
+ reset = reset or target != self.shown
585
+ self.shown = target
586
+ self.generation += 1
587
+ await inspector.show(head, sections, url=safe_url(url), sha=bool(sha), reset=reset)
588
+ if lazy:
589
+ self.load_children(*lazy, self.generation)
590
+
591
+ def can_expand(self, source_id: str, record: dict) -> bool:
592
+ adapter = self.sources[source_id].adapter
593
+ return bool(record.get("children")) or type(adapter).children is not Adapter.children
594
+
595
+ def change_view(self, change: Change):
596
+ meta = [clean(change.ref or change.key)]
597
+ if change.sha:
598
+ meta.append(clean(change.sha)[:7])
599
+ if change.pr:
600
+ meta.append(f"#{change.pr}")
601
+ if change.owner:
602
+ meta.append(f"@{clean(change.owner)}")
603
+ head = Content.assemble(
604
+ glyph(change.state),
605
+ " ",
606
+ (clean(change.title), "b"),
607
+ "\n",
608
+ muted(f" {widgets.SYMBOLS.dot} ".join(meta)),
609
+ "\n",
610
+ ci_status(change),
611
+ muted(f" {widgets.SYMBOLS.arrow} "),
612
+ queue_status(change, with_eta=False),
613
+ )
614
+ checks = change.checks
615
+ ci_rows = [
616
+ Content.assemble(
617
+ glyph(hit.record["state"]),
618
+ " ",
619
+ clean(hit.record["title"]),
620
+ muted(f" {widgets.SYMBOLS.dot} {raw(hit.record)} {widgets.SYMBOLS.dot} "),
621
+ muted(f"{short_age(ago(hit.record.get('created_at')))} ago"),
622
+ )
623
+ for hit in checks
624
+ ] or [dim("no CI runs for this change")]
625
+ lazy = None
626
+ state = change.ci_state
627
+ notable = next((hit for hit in checks if hit.record["state"] == state), None)
628
+ if notable and self.can_expand(notable.source, notable.record):
629
+ lazy = (notable, 0, lines(ci_rows))
630
+ ci_rows = [*ci_rows, dim(f"Loading jobs for {clean(notable.record['title'])}...")]
631
+ noun = "workflow" if len(checks) == 1 else "workflows"
632
+ sections = [
633
+ (Content.assemble(("CI", "b"), muted(f" {len(checks)} {noun}")), lines(ci_rows)),
634
+ (Content.assemble(("Queue", "b")), self.queue_section(change)),
635
+ (Content.assemble(("History", "b")), self.history_section(change)),
636
+ ]
637
+ links = [
638
+ Content.assemble(muted(f"{label:<6}"), clean(hit.record["url"]))
639
+ for label, hit in (
640
+ ("run", change.runs[0] if change.runs else None),
641
+ ("queue", change.entry),
642
+ )
643
+ if hit and safe_url(hit.record.get("url"))
644
+ ]
645
+ if links:
646
+ sections.append((Content.assemble(("Links", "b")), lines(links)))
647
+ return head, sections, lazy
648
+
649
+ def queue_section(self, change: Change) -> Content:
650
+ if change.entry:
651
+ entry = change.entry.record
652
+ waiting = sum(
653
+ r["kind"] == "queue_entry"
654
+ for r in self.snapshots.get(change.entry.source, {}).get("records", [])
655
+ )
656
+ where = (
657
+ f"#{entry['position']} of {waiting}"
658
+ if isinstance(entry.get("position"), int)
659
+ else ""
660
+ )
661
+ rows = [
662
+ Content.assemble(glyph(entry["state"]), f" {raw(entry)}", muted(f" {where}")),
663
+ dim(f"queued {short_age(ago(entry.get('created_at')))} ago"),
664
+ Content.assemble(
665
+ muted("ETA "),
666
+ (eta_text(eta), "b") if (eta := eta_of(entry)) else muted("unknown"),
667
+ ),
668
+ ]
669
+ note = self.snapshots.get(change.entry.source, {}).get("note")
670
+ if note:
671
+ rows.append(dim(clean(note)))
672
+ return lines(rows)
673
+ if change.attempts:
674
+ attempt = change.attempts[0].record
675
+ took = elapsed(attempt)
676
+ return Content.assemble(
677
+ glyph(attempt["state"]),
678
+ f" {raw(attempt)}",
679
+ muted(f" took {human_seconds(took)}") if took else "",
680
+ )
681
+ return dim("not in a merge queue")
682
+
683
+ def history_section(self, change: Change) -> Content:
684
+ events: list[tuple[float, Content]] = []
685
+ for hit in change.runs:
686
+ record = hit.record
687
+ when = ago(
688
+ record.get("finished_at") or record.get("updated_at") or record.get("created_at")
689
+ )
690
+ events.append(
691
+ (
692
+ when,
693
+ Content.assemble(
694
+ glyph(record["state"]),
695
+ f" {clean(record['title'])}",
696
+ muted(f" {raw(record)}"),
697
+ ),
698
+ )
699
+ )
700
+ for hit in change.entries:
701
+ when = ago(hit.record.get("created_at"))
702
+ events.append((when, Content.assemble(glyph("queued"), " entered the queue")))
703
+ for hit in change.attempts:
704
+ when = ago(hit.record.get("finished_at") or hit.record.get("started_at"))
705
+ events.append(
706
+ (
707
+ when,
708
+ Content.assemble(
709
+ glyph(hit.record["state"]), f" merge attempt {raw(hit.record)}"
710
+ ),
711
+ )
712
+ )
713
+ events = [e for e in events if e[0] is not None]
714
+ events.sort(key=lambda event: event[0])
715
+ rows = [
716
+ Content.assemble(fit(dim(f"{short_age(when)} ago"), 9), text)
717
+ for when, text in events[:6]
718
+ ]
719
+ return lines(rows) if rows else dim("no events")
720
+
721
+ def record_view(self, source_id: str, record: dict):
722
+ head = Content.assemble(glyph(record["state"]), " ", (clean(record["title"]), "b"))
723
+ if record.get("subtitle") and record["subtitle"] != record["title"]:
724
+ head = Content.assemble(head, "\n", muted(clean(record["subtitle"])))
725
+ sections = [(Content.assemble(("Record", "b")), lines(describe(record)))]
726
+ lazy = None
727
+ if self.can_expand(source_id, record):
728
+ sections.append((Content.assemble(("Details", "b")), dim("Loading...")))
729
+ lazy = (Hit(source_id, record), 1, Content(""))
730
+ change = self.by_record.get((source_id, record["id"]))
731
+ if change:
732
+ sections.append(
733
+ (
734
+ Content.assemble(("Change", "b")),
735
+ Content.assemble(
736
+ glyph(change.state),
737
+ " ",
738
+ clean(change.ref or change.key),
739
+ muted(" (Changes tab)"),
740
+ ),
741
+ )
742
+ )
743
+ note = self.snapshots.get(source_id, {}).get("note")
744
+ if note:
745
+ sections.append((Content.assemble(("Note", "b")), dim(clean(note))))
746
+ return head, sections, lazy
747
+
748
+ @work(group="children", exclusive=True)
749
+ async def load_children(self, hit: Hit, index: int, prefix: Content, generation: int) -> None:
750
+ cache_key = (hit.source, hit.record["id"])
751
+ try:
752
+ cached = self.child_cache.get(cache_key)
753
+ if cached and time.monotonic() - cached[0] < CHILD_TTL:
754
+ children = cached[1]
755
+ else:
756
+ children = await self.sources[hit.source].adapter.children(hit.record)
757
+ self.child_cache[cache_key] = (time.monotonic(), children)
758
+ if len(self.child_cache) > 100:
759
+ self.child_cache.pop(next(iter(self.child_cache)))
760
+ tree = job_tree(children) or [dim("none reported yet")]
761
+ except FETCH_ERRORS as error:
762
+ tree = [dim(f"details unavailable: {clean(str(error) or type(error).__name__)}")]
763
+ if generation != self.generation:
764
+ return
765
+ body = lines([prefix, *tree]) if prefix.plain else lines(tree)
766
+ self.query_one(Inspector).section(index, body)
767
+
768
+ # --- events ------------------------------------------------------------ #
769
+
770
+ async def on_change_selected(self, message: ChangeSelected) -> None:
771
+ if message.key != self.selected:
772
+ self.selected = message.key
773
+ if self.active_pane() is None:
774
+ await self.show_selection()
775
+
776
+ async def on_chip_clicked(self, message: ChipClicked) -> None:
777
+ tabs = self.query_one("#tabs", TabbedContent)
778
+ change = self.by_record.get((message.source, message.record_id))
779
+ if change and change.key in {
780
+ c.key for c in self.changes.values() if self.change_matches(c)
781
+ }:
782
+ self.selected = change.key
783
+ tabs.active = CHANGES
784
+ self.query_one(ChangeTable).highlight(change.key, reveal=True)
785
+ self.query_one("#rows").focus()
786
+ await self.show_selection()
787
+ return
788
+ for name, (source_id, view) in self.panes.items():
789
+ if source_id == message.source and view["kind"] == "queue_entry":
790
+ tabs.active = f"{name}-tab"
791
+ table = self.query_one(f"#{name}", DataTable)
792
+ keys = list(self.records[name])
793
+ if message.record_id in keys:
794
+ table.move_cursor(row=keys.index(message.record_id), animate=False)
795
+ table.focus()
796
+ await self.show_selection()
797
+ return
798
+
799
+ @on(DataTable.RowHighlighted)
800
+ async def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
801
+ if not self.rendering and event.data_table.id == self.active_pane():
802
+ await self.show_selection()
803
+
804
+ @on(TabbedContent.TabActivated)
805
+ async def tab_changed(self) -> None:
806
+ if self.is_mounted:
807
+ await self.show_selection()
808
+
809
+ @on(Input.Changed, "#search")
810
+ async def search_changed(self) -> None:
811
+ await self.rebuild()
812
+
813
+ @on(Input.Submitted, "#search")
814
+ def search_submitted(self) -> None:
815
+ self.focus_view()
816
+
817
+ @on(Button.Pressed, "#open")
818
+ def open_pressed(self) -> None:
819
+ self.action_open()
820
+
821
+ @on(Button.Pressed, "#copy")
822
+ def copy_pressed(self) -> None:
823
+ self.action_copy_sha()
824
+
825
+ # --- actions ------------------------------------------------------------ #
826
+
827
+ def focus_view(self) -> None:
828
+ name = self.active_pane()
829
+ self.query_one(f"#{name}" if name else "#rows").focus()
830
+
831
+ def action_density(self) -> None:
832
+ table = self.query_one(ChangeTable)
833
+ table.density = "compact" if table.density == "comfortable" else "comfortable"
834
+ table.rebuild()
835
+
836
+ def action_refresh(self) -> None:
837
+ for source_id in self.sources:
838
+ self.fetch(source_id)
839
+
840
+ def action_pause(self) -> None:
841
+ self.paused = not self.paused
842
+ self.notify("Paused: no polling" if self.paused else "Resumed", timeout=2)
843
+ if not self.paused:
844
+ self.action_refresh()
845
+ self.render_status()
846
+
847
+ def action_search(self) -> None:
848
+ search = self.query_one("#search", Input)
849
+ search.display = True
850
+ search.focus()
851
+
852
+ def action_clear(self) -> None:
853
+ search = self.query_one("#search", Input)
854
+ search.value = ""
855
+ search.display = False
856
+ self.focus_view()
857
+
858
+ def action_open(self) -> None:
859
+ url, _ = self.selection()
860
+ if safe_url(url):
861
+ webbrowser.open(url)
862
+
863
+ def action_copy_sha(self) -> None:
864
+ _, sha = self.selection()
865
+ if sha:
866
+ self.copy_to_clipboard(sha)
867
+ self.notify(clean(sha), title="Copied SHA", timeout=3, markup=False)
868
+
869
+ def action_tab(self, index: int) -> None:
870
+ panes = [pane.id for pane in self.query(TabPane)]
871
+ if index < len(panes):
872
+ self.query_one("#tabs", TabbedContent).active = panes[index]
873
+ self.focus_view()
874
+
875
+
876
+ __all__ = ["QueueWatch"]