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.
- dev_null_tty/__init__.py +758 -0
- dev_null_tty/__main__.py +5 -0
- dev_null_tty/api.py +532 -0
- dev_null_tty/desktop.py +157 -0
- dev_null_tty/styles.tcss +185 -0
- dev_null_tty-0.1.0.dist-info/METADATA +99 -0
- dev_null_tty-0.1.0.dist-info/RECORD +9 -0
- dev_null_tty-0.1.0.dist-info/WHEEL +4 -0
- dev_null_tty-0.1.0.dist-info/entry_points.txt +3 -0
dev_null_tty/__init__.py
ADDED
|
@@ -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"]
|