dev-null-tty 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.
@@ -0,0 +1,758 @@
1
+ """A Textual client for /dev/null."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ from datetime import datetime, timezone
6
+ import sys
7
+ import webbrowser
8
+ from typing import Literal
9
+
10
+ from rich.markup import escape
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Container, Horizontal, Vertical
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import (
16
+ Button,
17
+ ContentSwitcher,
18
+ Footer,
19
+ Header,
20
+ Input,
21
+ Label,
22
+ ListItem,
23
+ ListView,
24
+ Static,
25
+ TextArea,
26
+ )
27
+
28
+ from .api import (
29
+ ApiError,
30
+ ApiCommit,
31
+ ApiUser,
32
+ DeviceAuthorization,
33
+ LogName,
34
+ SignedApiClient,
35
+ authorization_code_from_callback,
36
+ create_device_authorization,
37
+ existing_device_identity,
38
+ remove_device_identity,
39
+ )
40
+ from .desktop import callback_handoff_path, register_uri_handler
41
+
42
+ ComposerMode = Literal["new", "reply", "quote"]
43
+
44
+
45
+ class CommitCard(ListItem):
46
+ """A single commit in a log."""
47
+
48
+ def __init__(self, commit: ApiCommit) -> None:
49
+ self.commit = commit
50
+ body = commit.body.strip()
51
+ if len(body) > 600:
52
+ body = f"{body[:597].rstrip()}..."
53
+ author = commit.author_name or commit.username
54
+ super().__init__(
55
+ Static(
56
+ f"[bold]{escape(author)}[/bold] [dim]@{escape(commit.username)} · "
57
+ f"{_relative_age(commit.created_at)}[/dim]\n"
58
+ f"{escape(body)}\n"
59
+ f"[dim]★ {commit.stars_count} ↩ {commit.replies_count}[/dim]",
60
+ classes="commit-card",
61
+ )
62
+ )
63
+
64
+
65
+ class JournalListView(ListView):
66
+ def __init__(self, log_name: LogName) -> None:
67
+ self.log_name = log_name
68
+ super().__init__(id=f"{log_name}-list")
69
+
70
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
71
+ super().watch_scroll_y(old_value, new_value)
72
+ if self.is_mounted and self.is_vertical_scroll_end:
73
+ self.app.call_after_refresh(
74
+ self.app._on_journal_scroll_end,
75
+ self.log_name,
76
+ )
77
+
78
+
79
+ class CommitComposerScreen(ModalScreen[None]):
80
+ BINDINGS = [
81
+ Binding("ctrl+q", "cancel", "Close dialog", show=False, priority=True),
82
+ Binding("escape", "cancel", "Close dialog", show=False, priority=True),
83
+ ]
84
+
85
+ def __init__(
86
+ self,
87
+ mode: ComposerMode,
88
+ commit: ApiCommit | None = None,
89
+ ) -> None:
90
+ if mode != "new" and commit is None:
91
+ raise ValueError(f"A {mode} composer needs a selected commit.")
92
+ super().__init__()
93
+ self.mode = mode
94
+ self.commit = commit
95
+
96
+ def compose(self) -> ComposeResult:
97
+ if self.mode == "reply":
98
+ title = f"Reply to @{self.commit.username}"
99
+ elif self.mode == "quote":
100
+ title = f"Quote @{self.commit.username}"
101
+ else:
102
+ title = "New commit"
103
+
104
+ with Container(id="composer-dialog"):
105
+ yield Label(title, id="composer-title")
106
+ if self.commit is not None:
107
+ author = self.commit.author_name or self.commit.username
108
+ body = self.commit.body.strip()
109
+ if len(body) > 1200:
110
+ body = f"{body[:1197].rstrip()}..."
111
+ yield Static(
112
+ f"[bold]{escape(author)}[/bold] [dim]@{escape(self.commit.username)} · "
113
+ f"{_relative_age(self.commit.created_at)}[/dim]\n{escape(body)}",
114
+ id="composer-context",
115
+ )
116
+ yield TextArea(
117
+ placeholder="What would you like to share?",
118
+ id="commit-body",
119
+ )
120
+ with Horizontal(id="composer-actions"):
121
+ yield Button("Post", variant="primary", id="submit-commit")
122
+ yield Button("Cancel (Ctrl+Q)", id="cancel-commit")
123
+ yield Static("", id="composer-status")
124
+
125
+ def on_mount(self) -> None:
126
+ self.query_one("#commit-body", TextArea).focus()
127
+
128
+ async def action_cancel(self) -> None:
129
+ await self.app.pop_screen()
130
+
131
+
132
+ def _relative_age(created_at: str) -> str:
133
+ try:
134
+ committed = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
135
+ except ValueError:
136
+ return created_at
137
+ if committed.tzinfo is None:
138
+ committed = committed.replace(tzinfo=timezone.utc)
139
+ seconds = max(0, int((datetime.now(timezone.utc) - committed).total_seconds()))
140
+ if seconds < 60:
141
+ return f"{seconds}s"
142
+ if seconds < 3600:
143
+ return f"{seconds // 60}m"
144
+ if seconds < 86400:
145
+ return f"{seconds // 3600}h"
146
+ return f"{seconds // 86400}d"
147
+
148
+
149
+ class DevNullApp(App[None]):
150
+ """Browse the public /dev/null logs from the terminal."""
151
+
152
+ TITLE = "/dev/null"
153
+ SUB_TITLE = "A social network for developers"
154
+ CSS_PATH = "styles.tcss"
155
+ BINDINGS = [
156
+ ("h", "previous_journal", "Previous journal"),
157
+ ("j", "next_commit", "Next commit"),
158
+ ("k", "previous_commit", "Previous commit"),
159
+ ("l", "next_journal", "Next journal"),
160
+ Binding("C", "new_commit", "New commit", key_display="Shift+C"),
161
+ Binding("shift+c", "new_commit", show=False),
162
+ Binding("Q", "quote_selected", "Quote", key_display="Shift+Q"),
163
+ Binding("shift+q", "quote_selected", show=False),
164
+ Binding("R", "reply_selected", "Reply", key_display="Shift+R"),
165
+ Binding("shift+r", "reply_selected", show=False),
166
+ Binding("ctrl+q", "close_dialog_or_quit", show=False, priority=True),
167
+ ("q", "quit", "Quit"),
168
+ ]
169
+
170
+ def __init__(self) -> None:
171
+ super().__init__()
172
+ self.authenticated_user: ApiUser | None = None
173
+ self.api_client: SignedApiClient | None = None
174
+ self.pending_authorization: DeviceAuthorization | None = None
175
+ self.active_log: LogName = "latest"
176
+ self.log_cursors: dict[LogName, str | None] = {
177
+ "following": None,
178
+ "latest": None,
179
+ "trending": None,
180
+ }
181
+ self.log_versions: dict[LogName, int] = {
182
+ "following": 0,
183
+ "latest": 0,
184
+ "trending": 0,
185
+ }
186
+ self.log_loading: dict[LogName, bool] = {
187
+ "following": False,
188
+ "latest": False,
189
+ "trending": False,
190
+ }
191
+
192
+ def on_mount(self) -> None:
193
+ self.set_interval(0.25, self._poll_callback_handoff)
194
+ self.run_worker(self._restore_saved_session(), group="session-restore", exclusive=True)
195
+
196
+ def compose(self) -> ComposeResult:
197
+ yield Header()
198
+ with Horizontal(id="app-body"):
199
+ yield self._sidebar()
200
+ with Vertical(id="main-column"):
201
+ with Container(id="toolbar"):
202
+ yield Label("LOG / LATEST", id="view-title")
203
+ yield Input(placeholder="Search commits...", id="search")
204
+ with ContentSwitcher(initial="latest-view", id="views"):
205
+ yield self._log_view("latest")
206
+ yield self._log_view("following")
207
+ yield self._log_view("trending")
208
+ yield self._profile_view()
209
+ yield Footer()
210
+
211
+ def _sidebar(self) -> Container:
212
+ return Container(
213
+ Static("[bold cyan]/dev/null[/bold cyan]\n[dim]developer logs[/dim]", id="brand"),
214
+ Button("Latest log", id="latest-log", variant="primary"),
215
+ Button("Following", id="following-log"),
216
+ Button("Trending", id="trending-log"),
217
+ Button("My profile", id="profile"),
218
+ Static(
219
+ "\n[dim]Sign in with GitHub to browse journals,\npost commits, and reply.[/dim]",
220
+ id="sidebar-note",
221
+ ),
222
+ id="sidebar",
223
+ )
224
+
225
+ def _log_view(self, log_name: LogName) -> Container:
226
+ commits = JournalListView(log_name)
227
+ status = Static(
228
+ "Sign in to load your /dev/null journals.",
229
+ id=f"{log_name}-status",
230
+ classes="log-status",
231
+ )
232
+ status.display = False
233
+ return Container(
234
+ status,
235
+ commits,
236
+ id=f"{log_name}-view",
237
+ )
238
+
239
+ def _profile_view(self) -> Container:
240
+ callback_input = Input(placeholder="Paste the full devnull://auth/callback URL", id="auth-callback")
241
+ callback_input.display = False
242
+ complete_button = Button("Complete sign-in", id="complete-sign-in")
243
+ complete_button.display = False
244
+ logout_button = Button("Log out", id="logout")
245
+ logout_button.display = False
246
+ return Container(
247
+ Static(
248
+ "[bold]@guest[/bold]\n[dim]Public visitor[/dim]\n\n"
249
+ "You are reading /dev/null as a guest.\n"
250
+ "GitHub identity keeps developer profiles real.",
251
+ classes="profile-card",
252
+ id="profile-card",
253
+ ),
254
+ Button("Sign in with GitHub", variant="primary", id="sign-in"),
255
+ logout_button,
256
+ callback_input,
257
+ complete_button,
258
+ Static("", id="sign-in-status"),
259
+ id="profile",
260
+ )
261
+
262
+ def action_show_feed(self) -> None:
263
+ self._show_log("latest")
264
+
265
+ def action_show_following(self) -> None:
266
+ self._show_log("following")
267
+
268
+ def action_show_trending(self) -> None:
269
+ self._show_log("trending")
270
+
271
+ def action_previous_journal(self) -> None:
272
+ self._move_journal(-1)
273
+
274
+ def action_next_journal(self) -> None:
275
+ self._move_journal(1)
276
+
277
+ def action_next_commit(self) -> None:
278
+ self._move_commit_selection(1)
279
+
280
+ def action_previous_commit(self) -> None:
281
+ self._move_commit_selection(-1)
282
+
283
+ def action_new_commit(self) -> None:
284
+ self._begin_composition()
285
+
286
+ def action_reply_selected(self) -> None:
287
+ self._begin_reply()
288
+
289
+ def action_quote_selected(self) -> None:
290
+ self._begin_quote()
291
+
292
+ async def action_close_dialog_or_quit(self) -> None:
293
+ if isinstance(self.screen, CommitComposerScreen):
294
+ await self.pop_screen()
295
+ else:
296
+ self.exit()
297
+
298
+ def action_show_profile(self) -> None:
299
+ title = (
300
+ f"PROFILE / @{self.authenticated_user.username}"
301
+ if self.authenticated_user is not None
302
+ else "PROFILE / GUEST"
303
+ )
304
+ self._show_view("profile", title, "profile")
305
+
306
+ def _show_view(self, view: str, title: str, active_button: str) -> None:
307
+ self.query_one("#views", ContentSwitcher).current = view
308
+ self.query_one("#view-title", Label).update(title)
309
+ for button_id in ("latest-log", "following-log", "trending-log", "profile"):
310
+ is_active = button_id == active_button
311
+ self.query_one(f"#{button_id}", Button).variant = (
312
+ "primary" if is_active else "default"
313
+ )
314
+ self.query_one(f"#{button_id}", Button).set_class(is_active, "active")
315
+
316
+ def _show_log(self, log_name: LogName) -> None:
317
+ self.active_log = log_name
318
+ titles = {
319
+ "following": "LOG / FOLLOWING",
320
+ "latest": "LOG / LATEST",
321
+ "trending": "LOG / TRENDING",
322
+ }
323
+ self._show_view(f"{log_name}-view", titles[log_name], f"{log_name}-log")
324
+ self._start_log_load(log_name, reset=True)
325
+
326
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
327
+ actions = {
328
+ "latest-log": self.action_show_feed,
329
+ "following-log": self.action_show_following,
330
+ "trending-log": self.action_show_trending,
331
+ "profile": self.action_show_profile,
332
+ }
333
+ action = actions.get(event.button.id)
334
+ if action is not None:
335
+ action()
336
+ elif event.button.id == "sign-in":
337
+ await self._sign_in()
338
+ elif event.button.id == "complete-sign-in":
339
+ await self._complete_sign_in(self.query_one("#auth-callback", Input).value)
340
+ elif event.button.id == "logout":
341
+ await self._logout()
342
+ elif event.button.id == "submit-commit":
343
+ await self._submit_commit()
344
+ elif event.button.id == "cancel-commit":
345
+ await self._cancel_composition()
346
+
347
+ async def _restore_saved_session(self) -> None:
348
+ button = self.query_one("#sign-in", Button)
349
+ status = self.query_one("#sign-in-status", Static)
350
+ button.disabled = True
351
+ try:
352
+ key_path = await asyncio.to_thread(existing_device_identity)
353
+ if key_path is None:
354
+ return
355
+ client = SignedApiClient(key_path)
356
+ user = await asyncio.to_thread(client.get_current_user)
357
+ except ApiError as error:
358
+ if error.code == "device_not_approved":
359
+ status.update("[yellow]Saved device is not approved. Sign in again to authorize it.[/yellow]")
360
+ else:
361
+ status.update(f"[bold red]Could not restore sign-in: {escape(str(error))}[/bold red]")
362
+ except OSError as error:
363
+ status.update(f"[bold red]Could not restore sign-in: {escape(str(error))}[/bold red]")
364
+ else:
365
+ self._set_authenticated_user(client, user)
366
+ status.update("[bold green]Signed in with the saved device.[/bold green]")
367
+ self._start_log_load(self.active_log, reset=True)
368
+ finally:
369
+ if self.authenticated_user is None:
370
+ button.disabled = False
371
+
372
+ async def _sign_in(self) -> None:
373
+ button = self.query_one("#sign-in", Button)
374
+ callback_input = self.query_one("#auth-callback", Input)
375
+ complete_button = self.query_one("#complete-sign-in", Button)
376
+ status = self.query_one("#sign-in-status", Static)
377
+ button.disabled = True
378
+ button.label = "Preparing device..."
379
+ status.update("[dim]Registering the callback handler and preparing your device...[/dim]")
380
+ try:
381
+ await asyncio.to_thread(register_uri_handler)
382
+ authorization = await asyncio.to_thread(create_device_authorization)
383
+ opened = await asyncio.to_thread(webbrowser.open, authorization.authorization_url)
384
+ except ApiError as error:
385
+ status.update(f"[bold red]{escape(str(error))}[/bold red]")
386
+ except OSError as error:
387
+ status.update(f"[bold red]Could not start sign-in: {escape(str(error))}[/bold red]")
388
+ except webbrowser.Error as error:
389
+ status.update(f"[bold red]Could not open the browser: {escape(str(error))}[/bold red]")
390
+ else:
391
+ self.pending_authorization = authorization
392
+ callback_input.value = ""
393
+ callback_input.display = True
394
+ complete_button.display = True
395
+ browser_status = "Browser opened." if opened else "Open this URL in your browser:"
396
+ status.update(
397
+ f"{browser_status} Approve the device. The callback handler is ready to return automatically; "
398
+ "otherwise paste the full callback URL here.\n"
399
+ f"[dim]{escape(authorization.authorization_url)}[/dim]"
400
+ )
401
+ finally:
402
+ button.label = "Sign in with GitHub"
403
+ button.disabled = False
404
+
405
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
406
+ if event.input.id == "auth-callback":
407
+ await self._complete_sign_in(event.value)
408
+
409
+ async def _complete_sign_in(self, callback_url: str) -> None:
410
+ authorization = self.pending_authorization
411
+ status = self.query_one("#sign-in-status", Static)
412
+ if authorization is None:
413
+ status.update("[bold red]Start sign-in before submitting a callback.[/bold red]")
414
+ return
415
+
416
+ try:
417
+ code = authorization_code_from_callback(callback_url, authorization.state)
418
+ except ApiError as error:
419
+ status.update(f"[bold red]{escape(str(error))}[/bold red]")
420
+ return
421
+
422
+ self.pending_authorization = None
423
+ complete_button = self.query_one("#complete-sign-in", Button)
424
+ complete_button.disabled = True
425
+ complete_button.label = "Redeeming code..."
426
+ status.update("[dim]Redeeming the one-time authorization code...[/dim]")
427
+ client = SignedApiClient(authorization.key_path)
428
+ try:
429
+ user = await asyncio.to_thread(client.redeem_device, code, authorization.code_verifier)
430
+ except ApiError as error:
431
+ status.update(f"[bold red]{escape(str(error))}[/bold red] Start sign-in again to retry.")
432
+ except OSError as error:
433
+ status.update(f"[bold red]Could not complete sign-in: {escape(str(error))}[/bold red]")
434
+ else:
435
+ self._set_authenticated_user(client, user)
436
+ self.query_one("#auth-callback", Input).display = False
437
+ complete_button.display = False
438
+ status.update("[bold green]Signed in.[/bold green]")
439
+ self._start_log_load(self.active_log, reset=True)
440
+ finally:
441
+ complete_button.label = "Complete sign-in"
442
+ complete_button.disabled = False
443
+
444
+ def _set_authenticated_user(self, client: SignedApiClient, user: ApiUser) -> None:
445
+ self.api_client = client
446
+ self.authenticated_user = user
447
+ self.query_one("#profile-card", Static).update(
448
+ f"[bold]@{escape(user.username)}[/bold]\n[dim]{escape(user.name or user.username)}[/dim]\n\n"
449
+ f"{escape(user.bio or 'No bio set on GitHub.')}\n\n"
450
+ "[dim]Signed in with your approved SSH device key.[/dim]"
451
+ )
452
+ self.query_one("#sign-in", Button).display = False
453
+ self.query_one("#logout", Button).display = True
454
+ if self.query_one("#views", ContentSwitcher).current == "profile":
455
+ self.query_one("#view-title", Label).update(
456
+ f"PROFILE / @{escape(user.username)}"
457
+ )
458
+
459
+ async def _logout(self) -> None:
460
+ status = self.query_one("#sign-in-status", Static)
461
+ button = self.query_one("#logout", Button)
462
+ button.disabled = True
463
+ status.update("[dim]Removing the saved local device key...[/dim]")
464
+ try:
465
+ await asyncio.to_thread(remove_device_identity)
466
+ except ApiError as error:
467
+ status.update(f"[bold red]Could not log out: {escape(str(error))}[/bold red]")
468
+ except OSError as error:
469
+ status.update(f"[bold red]Could not log out: {escape(str(error))}[/bold red]")
470
+ else:
471
+ self.authenticated_user = None
472
+ self.api_client = None
473
+ self.pending_authorization = None
474
+ self.query_one("#profile-card", Static).update(
475
+ "[bold]@guest[/bold]\n[dim]Public visitor[/dim]\n\n"
476
+ "You are reading /dev/null as a guest.\n"
477
+ "GitHub identity keeps developer profiles real."
478
+ )
479
+ self.query_one("#sign-in", Button).display = True
480
+ button.display = False
481
+ self.query_one("#auth-callback", Input).display = False
482
+ self.query_one("#complete-sign-in", Button).display = False
483
+ for log_name in ("following", "latest", "trending"):
484
+ self.query_one(f"#{log_name}-list", ListView).clear()
485
+ self.query_one(f"#{log_name}-status", Static).update(
486
+ "Sign in to load your /dev/null journals."
487
+ )
488
+ self.query_one(f"#{log_name}-status", Static).display = False
489
+ self.log_cursors[log_name] = None
490
+ self.log_versions[log_name] += 1
491
+ self.log_loading[log_name] = False
492
+ self._show_view("profile", "PROFILE / GUEST", "profile")
493
+ status.update(
494
+ "[yellow]Signed out locally. Revoke this device in /settings/devices "
495
+ "to remove its server-side approval.[/yellow]"
496
+ )
497
+ finally:
498
+ button.disabled = False
499
+
500
+ def _move_journal(self, direction: int) -> None:
501
+ if self.query_one("#views", ContentSwitcher).current == "profile":
502
+ return
503
+ journals: tuple[LogName, ...] = ("latest", "following", "trending")
504
+ current = journals.index(self.active_log)
505
+ target = current + direction
506
+ if 0 <= target < len(journals):
507
+ self._show_log(journals[target])
508
+
509
+ def _move_commit_selection(self, direction: int) -> None:
510
+ if self.query_one("#views", ContentSwitcher).current == "profile":
511
+ return
512
+ list_view = self.query_one(f"#{self.active_log}-list", ListView)
513
+ count = len(list_view.children)
514
+ if count == 0:
515
+ return
516
+ index = list_view.index
517
+ if index is None:
518
+ index = 0 if direction > 0 else count - 1
519
+ else:
520
+ index = min(max(index + direction, 0), count - 1)
521
+ list_view.index = index
522
+ list_view.focus()
523
+
524
+ def _begin_composition(
525
+ self,
526
+ parent: ApiCommit | None = None,
527
+ quoted_commit: ApiCommit | None = None,
528
+ ) -> None:
529
+ if self.api_client is None:
530
+ self.notify("Sign in before posting a commit or reply.", severity="warning")
531
+ return
532
+ if self.query_one("#views", ContentSwitcher).current == "profile":
533
+ return
534
+ if parent is not None:
535
+ mode = "reply"
536
+ context = parent
537
+ elif quoted_commit is not None:
538
+ mode = "quote"
539
+ context = quoted_commit
540
+ else:
541
+ mode = "new"
542
+ context = None
543
+ self.push_screen(CommitComposerScreen(mode, context))
544
+
545
+ def _begin_reply(self) -> None:
546
+ selected = self._selected_commit()
547
+ if selected is not None:
548
+ self._begin_composition(selected)
549
+
550
+ def _begin_quote(self) -> None:
551
+ selected = self._selected_commit()
552
+ if selected is not None:
553
+ self._begin_composition(quoted_commit=selected)
554
+
555
+ def _selected_commit(self) -> ApiCommit | None:
556
+ if self.api_client is None:
557
+ return None
558
+ list_view = self.query_one(f"#{self.active_log}-list", ListView)
559
+ index = list_view.index
560
+ if index is None or index < 0 or index >= len(list_view.children):
561
+ self.notify("Select a commit before replying or quoting it.", severity="warning")
562
+ return None
563
+ selected = list_view.children[index]
564
+ if not isinstance(selected, CommitCard):
565
+ self.notify("Select a commit before replying or quoting it.", severity="warning")
566
+ return None
567
+ return selected.commit
568
+
569
+ async def _cancel_composition(self) -> None:
570
+ if isinstance(self.screen, CommitComposerScreen):
571
+ await self.pop_screen()
572
+
573
+ async def _submit_commit(self) -> None:
574
+ screen = self.screen
575
+ if not isinstance(screen, CommitComposerScreen):
576
+ return
577
+ client = self.api_client
578
+ status = screen.query_one("#composer-status", Static)
579
+ if client is None:
580
+ status.update("[bold red]Sign in before posting.[/bold red]")
581
+ return
582
+ body = screen.query_one("#commit-body", TextArea).text
583
+ if not body.strip():
584
+ status.update("[bold red]A commit cannot be empty.[/bold red]")
585
+ return
586
+
587
+ parent_id = screen.commit.id if screen.mode == "reply" and screen.commit else None
588
+ quoted_commit_id = screen.commit.id if screen.mode == "quote" and screen.commit else None
589
+ button = screen.query_one("#submit-commit", Button)
590
+ button.disabled = True
591
+ status.update("[dim]Submitting your signed commit...[/dim]")
592
+ try:
593
+ await asyncio.to_thread(
594
+ client.create_commit,
595
+ body,
596
+ parent_id,
597
+ quoted_commit_id,
598
+ )
599
+ except ApiError as error:
600
+ status.update(f"[bold red]Could not post: {escape(str(error))}[/bold red]")
601
+ except OSError as error:
602
+ status.update(f"[bold red]Could not post: {escape(str(error))}[/bold red]")
603
+ else:
604
+ confirmation = (
605
+ "Reply posted."
606
+ if parent_id is not None
607
+ else "Quote posted."
608
+ if quoted_commit_id is not None
609
+ else "Commit posted."
610
+ )
611
+ await self.pop_screen()
612
+ self.notify(confirmation)
613
+ self._start_log_load(self.active_log, reset=True)
614
+ finally:
615
+ if button.is_mounted:
616
+ button.disabled = False
617
+
618
+ def _start_log_load(self, log_name: LogName, reset: bool) -> None:
619
+ status = self.query_one(f"#{log_name}-status", Static)
620
+ if self.api_client is None:
621
+ status.display = True
622
+ status.update("Sign in to load your /dev/null journals.")
623
+ return
624
+ if self.log_loading[log_name]:
625
+ return
626
+ if not reset and self.log_cursors[log_name] is None:
627
+ return
628
+ if reset:
629
+ self.log_cursors[log_name] = None
630
+ self.query_one(f"#{log_name}-list", ListView).clear()
631
+ self.log_versions[log_name] += 1
632
+ version = self.log_versions[log_name]
633
+ self.log_loading[log_name] = True
634
+ self.run_worker(
635
+ self._load_log_page(log_name, self.log_cursors[log_name], reset, version),
636
+ group=f"log-{log_name}",
637
+ exclusive=True,
638
+ )
639
+
640
+ async def _load_log_page(
641
+ self,
642
+ log_name: LogName,
643
+ cursor: str | None,
644
+ reset: bool,
645
+ version: int,
646
+ ) -> None:
647
+ client = self.api_client
648
+ if client is None:
649
+ return
650
+ status = self.query_one(f"#{log_name}-status", Static)
651
+ commit_list = self.query_one(f"#{log_name}-list", ListView)
652
+ status.display = False
653
+ try:
654
+ log = await asyncio.to_thread(client.get_log, log_name, cursor)
655
+ except ApiError as error:
656
+ if version == self.log_versions[log_name] and self.is_running:
657
+ status.display = True
658
+ status.update(f"[bold red]Could not load journal: {escape(str(error))}[/bold red]")
659
+ except OSError as error:
660
+ if version == self.log_versions[log_name] and self.is_running:
661
+ status.display = True
662
+ status.update(f"[bold red]Could not load journal: {escape(str(error))}[/bold red]")
663
+ else:
664
+ if version != self.log_versions[log_name] or not self.is_running:
665
+ return
666
+ if reset:
667
+ commit_list.clear()
668
+ cards = [CommitCard(commit) for commit in log.commits]
669
+ commit_list.extend(cards)
670
+ self.log_cursors[log_name] = log.next_cursor
671
+ if not cards and log.suggestions:
672
+ names = ", ".join(f"@{user.username}" for user in log.suggestions)
673
+ status.display = True
674
+ status.update(f"No commits in Following yet. Suggested developers: {escape(names)}")
675
+ elif not cards:
676
+ status.display = True
677
+ status.update("No commits in this journal yet.")
678
+ else:
679
+ status.display = False
680
+ if log.next_cursor is not None:
681
+ self.call_after_refresh(
682
+ self._prefill_log_if_needed,
683
+ log_name,
684
+ version,
685
+ )
686
+ finally:
687
+ if version == self.log_versions[log_name]:
688
+ self.log_loading[log_name] = False
689
+
690
+ def _on_journal_scroll_end(self, log_name: LogName) -> None:
691
+ if self.query_one("#views", ContentSwitcher).current != f"{log_name}-view":
692
+ return
693
+ self._start_log_load(log_name, reset=False)
694
+
695
+ def _prefill_log_if_needed(self, log_name: LogName, version: int) -> None:
696
+ if not self.is_running or version != self.log_versions[log_name]:
697
+ return
698
+ list_view = self.query_one(f"#{log_name}-list", JournalListView)
699
+ if list_view.is_vertical_scroll_end:
700
+ self._on_journal_scroll_end(log_name)
701
+
702
+
703
+ async def _poll_callback_handoff(self) -> None:
704
+ authorization = self.pending_authorization
705
+ if authorization is None:
706
+ return
707
+ callback_path = callback_handoff_path(authorization.state)
708
+ try:
709
+ if callback_path.is_symlink():
710
+ callback_path.unlink()
711
+ raise ApiError("The browser callback handoff was not a regular file.")
712
+ if not callback_path.is_file():
713
+ return
714
+ if callback_path.stat().st_size > 8192:
715
+ callback_path.unlink()
716
+ raise ApiError("The browser callback URL is too long.")
717
+ callback_url = callback_path.read_text(encoding="utf-8")
718
+ callback_path.unlink()
719
+ except (OSError, ApiError) as error:
720
+ status = self.query_one("#sign-in-status", Static)
721
+ status.update(f"[bold red]{escape(str(error))}[/bold red]")
722
+ return
723
+ await self._complete_sign_in(callback_url)
724
+
725
+ def on_input_changed(self, event: Input.Changed) -> None:
726
+ query = event.value.casefold().strip()
727
+ for log_name in ("following", "latest", "trending"):
728
+ for item in self.query_one(f"#{log_name}-list", ListView).query(CommitCard):
729
+ item.display = not query or query in (
730
+ f"{item.commit.author_name or item.commit.username} "
731
+ f"@{item.commit.username} {item.commit.body}"
732
+ ).casefold()
733
+
734
+
735
+ def main() -> None:
736
+ """Run the TUI or handle a registered devnull:// callback."""
737
+
738
+ parser = argparse.ArgumentParser(prog="dev-null-tty")
739
+ mode = parser.add_mutually_exclusive_group()
740
+ mode.add_argument("--callback", help=argparse.SUPPRESS)
741
+ mode.add_argument("--register-handler", action="store_true", help="Register the per-user devnull:// handler")
742
+ arguments = parser.parse_args()
743
+ try:
744
+ if arguments.callback:
745
+ from .desktop import deliver_authorization_callback
746
+
747
+ deliver_authorization_callback(arguments.callback)
748
+ return
749
+ if arguments.register_handler:
750
+ print(register_uri_handler())
751
+ return
752
+ except ApiError as error:
753
+ print(f"dev-null-tty: {error}", file=sys.stderr)
754
+ raise SystemExit(1) from error
755
+ DevNullApp().run()
756
+
757
+
758
+ __all__ = ["Commit", "DevNullApp", "main"]