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/__init__.py +3 -0
- queue_watch/adapters/__init__.py +34 -0
- queue_watch/adapters/command.py +54 -0
- queue_watch/adapters/flock_queue.py +322 -0
- queue_watch/adapters/github_actions.py +164 -0
- queue_watch/adapters/github_merge_queue.py +141 -0
- queue_watch/app.py +876 -0
- queue_watch/changes.py +181 -0
- queue_watch/cli.py +129 -0
- queue_watch/config.py +105 -0
- queue_watch/discovery.py +149 -0
- queue_watch/model.py +188 -0
- queue_watch/process.py +67 -0
- queue_watch/widgets.py +774 -0
- queue_watch-0.1.0.dist-info/METADATA +141 -0
- queue_watch-0.1.0.dist-info/RECORD +19 -0
- queue_watch-0.1.0.dist-info/WHEEL +4 -0
- queue_watch-0.1.0.dist-info/entry_points.txt +2 -0
- queue_watch-0.1.0.dist-info/licenses/LICENSE +21 -0
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"]
|