riptide-transfer 1.3.6__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,1059 @@
1
+ """Redesigned session screen for user-friendly PC-to-PC file transfer.
2
+
3
+ Features:
4
+ - Header: HOST banner in larger block font; client name with hostname/IP
5
+ and connection indicator (Connected ● green, Connecting ○, Disconnected ⊗ red).
6
+ - File Manager: Current folder at top of box with clear separator below it;
7
+ expanded height (16 rows); live typing search filter; spacebar selection;
8
+ pure white focused row highlight.
9
+ - Send Flow: Enter sends selected files immediately without a button;
10
+ selected files listed line-by-line with full absolute path in accent red;
11
+ summary header in shades of white and accent colors.
12
+ - Download Flow: Download button removed; Enter initiates request from input;
13
+ download input disabled while files are selected to send.
14
+ - Progress: Full-width progress bar showing overall batch progress;
15
+ evenly spaced metrics on line 2; current file and [sent / total size] on line 3,
16
+ with file name truncated to fit in one line.
17
+ - Layout: Full page vertical scroll container with footer docked at bottom,
18
+ footer items evenly spaced with shortcut keys in [].
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import ipaddress
23
+ import os
24
+ import threading
25
+ import time
26
+ import uuid
27
+ from pathlib import Path
28
+
29
+ from textual.app import ComposeResult
30
+ from textual.containers import Horizontal, Vertical, VerticalScroll
31
+ from textual.screen import Screen
32
+ from textual.widgets import Button, Input, Label, ListItem, ListView, ProgressBar, Static
33
+
34
+ from ..core.file_select import (
35
+ add_to_selection,
36
+ format_browser_row,
37
+ format_bytes,
38
+ list_browser_entries,
39
+ total_size,
40
+ )
41
+ from ..core.focus import focus_first, handle_arrow_key
42
+ from ..core.session import Session
43
+ from ..core.transfer import TransferProgress, format_bytes as fmt_bytes, format_eta
44
+ from ..theme import BANNER_CLIENT, BANNER_HOST
45
+
46
+
47
+ def _build_file_item(path: Path, selected: bool, is_focused: bool = False, max_name_chars: int | None = None) -> ListItem:
48
+ display, size, mtime = format_browser_row(path)
49
+ if max_name_chars is not None:
50
+ display = _truncate_filename(display, max_name_chars)
51
+ item = ListItem(
52
+ Horizontal(
53
+ Label("❯ " if is_focused else " ", classes="file-pointer"),
54
+ Label(display, classes="file-name"),
55
+ Label(size, classes="file-size"),
56
+ Label(mtime, classes="file-mtime"),
57
+ classes="file-row",
58
+ ),
59
+ classes="selected" if selected else "",
60
+ )
61
+ item._riptide_path = path # type: ignore[attr-defined]
62
+ return item
63
+
64
+
65
+ def _cell_len(text: str) -> int:
66
+ # Terminal cell width, not character count. 📁 is 1 character
67
+ # but 2 cells wide, which is exactly why folder names broke:
68
+ # a folder display truncated to 32 characters was 33 cells
69
+ # wide, wrapped to a second line, and the 1-line file row
70
+ # clipped that line. Rich is already a Textual dependency.
71
+ try:
72
+ from rich.cells import cell_len
73
+
74
+ return cell_len(text)
75
+ except Exception:
76
+ return len(text)
77
+
78
+
79
+ def _take_cells(text: str, max_cells: int, from_end: bool = False) -> str:
80
+ # Take characters whose total cell width fits in max_cells,
81
+ # without splitting a wide character in half.
82
+ if not from_end:
83
+ out: list[str] = []
84
+ used = 0
85
+ for ch in text:
86
+ width = _cell_len(ch)
87
+ if used + width > max_cells:
88
+ break
89
+ out.append(ch)
90
+ used += width
91
+ return "".join(out)
92
+ out_rev: list[str] = []
93
+ used = 0
94
+ for ch in reversed(text):
95
+ width = _cell_len(ch)
96
+ if used + width > max_cells:
97
+ break
98
+ out_rev.append(ch)
99
+ used += width
100
+ return "".join(reversed(out_rev))
101
+
102
+
103
+ def _truncate_filename(name: str, max_chars: int = 50) -> str:
104
+ # max_chars is really max terminal cells, not characters.
105
+ # Every caller passes a column width in cells, so a wide
106
+ # character such as 📁 must count as 2, or the truncated
107
+ # name still overflows its column and wraps or clips.
108
+ if _cell_len(name) <= max_chars:
109
+ return name
110
+ if max_chars <= 6:
111
+ return _take_cells(name, max_chars)
112
+ prefix_cells = (max_chars - 3) * 2 // 3
113
+ suffix_cells = (max_chars - 3) - prefix_cells
114
+ return f"{_take_cells(name, prefix_cells)}...{_take_cells(name, suffix_cells, from_end=True)}"
115
+
116
+
117
+ def _strip_surrounding_quotes(text: str) -> str:
118
+ """Strip double quotes at the start and end of a pasted path.
119
+
120
+ Terminals and file managers often paste a path as
121
+ "C:\\Users\\...\\file.pdf" or "/home/user/file.pdf", quotes
122
+ included. A path with those quotes would not be found, so
123
+ remove paired quotes, and also a lone leading or trailing
124
+ quote, then any surrounding whitespace again."""
125
+ text = text.strip()
126
+ while len(text) >= 2 and text[0] == '"' and text[-1] == '"':
127
+ text = text[1:-1].strip()
128
+ return text.strip('"').strip()
129
+
130
+ def is_ip_address(text: str) -> bool:
131
+ if not text:
132
+ return False
133
+ cleaned = text.strip("[]").split("%")[0]
134
+ try:
135
+ ipaddress.ip_address(cleaned)
136
+ return True
137
+ except ValueError:
138
+ return False
139
+
140
+
141
+ class SessionScreen(Screen):
142
+ def __init__(self, session: Session, role: str) -> None:
143
+ super().__init__()
144
+ self.session = session
145
+ self.role = role
146
+ self.cwd = session.cwd
147
+ self.browser_dir = Path(session.cwd)
148
+ self.selection: list[Path] = []
149
+ self._search_query: str = ""
150
+ self._connection_state: str = "connected"
151
+ self._transfer = TransferProgress(0)
152
+ self._overall_transfer = TransferProgress(0)
153
+ self._overall_total: int = 0
154
+ self._transfer_name = ""
155
+ self._pending_downloads: list[tuple[str, str]] = []
156
+ self._approve_btn: Button | None = None
157
+ self._deny_btn: Button | None = None
158
+ self._transfer_active: bool = False
159
+ self._progress_visible: bool = False
160
+ self._complete_timer = None
161
+ self._complete_message_kind: str | None = None
162
+ self._received_files: list[str] = []
163
+ # Saved name -> original name, for files renamed to (1), (2), etc.
164
+ self._renamed_files: dict[str, str] = {}
165
+ self._last_received_at: float = 0.0
166
+
167
+ # -- header & connection display ----------------------------------
168
+ def _get_peer_address(self) -> str:
169
+ try:
170
+ if self.session and self.session.sock:
171
+ peername = self.session.sock.getpeername()
172
+ if peername and isinstance(peername, tuple):
173
+ return str(peername[0])
174
+ except Exception:
175
+ pass
176
+ return getattr(self.session, "peer_host", "") or ""
177
+
178
+ def _format_client_info(self) -> str:
179
+ peer_name = getattr(self.session, "peer_device", "") or ""
180
+ peer_addr = self._get_peer_address()
181
+ peer_label = "Client" if self.role == "host" else "Host"
182
+ peer_host = getattr(self.session, "peer_host", "") or ""
183
+
184
+ # Display hostname if connection was made using hostname, else show IP
185
+ if peer_host and not is_ip_address(peer_host):
186
+ display_target = peer_host
187
+ else:
188
+ display_target = peer_addr or peer_host or "unknown"
189
+
190
+ if peer_name and peer_name != display_target:
191
+ return f"{peer_label}: [bold #FFFFFF]{peer_name}[/] [dim]({display_target})[/]"
192
+ return f"{peer_label}: [bold #FFFFFF]{display_target}[/]"
193
+
194
+ def _format_status_indicator(self) -> str:
195
+ if self._connection_state == "connected":
196
+ return "[bold #78DC8C]●[/] [#E8E8E8]Connected[/]"
197
+ elif self._connection_state == "connecting":
198
+ return "[bold #FFD166]○[/] [#E8E8E8]Connecting[/]"
199
+ else:
200
+ return "[bold #FF6B6B]⊗[/] [#E8E8E8]Disconnected[/] "
201
+
202
+ def _update_header(self) -> None:
203
+ try:
204
+ self.query_one("#peer_client_label", Static).update(self._format_client_info())
205
+ self.query_one("#peer_status_label", Static).update(self._format_status_indicator())
206
+ except Exception:
207
+ pass
208
+
209
+ def _screen_width(self) -> int:
210
+ # Full terminal width. Screen padding is 1 2, so content
211
+ # width is screen width minus 4. Fall back to 80 when the
212
+ # screen is not mounted yet, for example in unit tests
213
+ # that call helpers before mounting.
214
+ try:
215
+ width = self.size.width
216
+ if width:
217
+ return int(width)
218
+ except Exception:
219
+ pass
220
+ try:
221
+ width = self.app.screen.size.width
222
+ if width:
223
+ return int(width)
224
+ except Exception:
225
+ pass
226
+ return 80
227
+
228
+ def _content_width(self) -> int:
229
+ return max(20, self._screen_width() - 4)
230
+
231
+ # -- file manager -------------------------------------------------
232
+ def _browser_rows(self) -> list[Path]:
233
+ all_entries = list_browser_entries(self.browser_dir)
234
+ if self._search_query:
235
+ q = self._search_query.lower()
236
+ return [p for p in all_entries if q in p.name.lower()]
237
+
238
+ rows: list[Path] = []
239
+ if self.browser_dir.parent != self.browser_dir:
240
+ rows.append(self.browser_dir.parent)
241
+ rows.extend(all_entries)
242
+ return rows
243
+
244
+ def _refresh_browser(self, keep_index: int | None = None, auto_first: bool = False) -> None:
245
+ try:
246
+ lst = self.query_one("#cwd_list", ListView)
247
+ except Exception:
248
+ return
249
+ index = keep_index
250
+ if index is None:
251
+ try:
252
+ index = lst.index
253
+ except Exception:
254
+ index = 0
255
+ selected = set(self.selection)
256
+ rows = self._browser_rows()
257
+ # Auto-select the first real file/folder on directory change,
258
+ # not the ".." parent row. When searching, there is no parent
259
+ # row, so the first filtered row is index 0.
260
+ if auto_first and rows:
261
+ if self._search_query:
262
+ index = 0
263
+ elif rows[0] == self.browser_dir.parent and len(rows) > 1:
264
+ index = 1
265
+ else:
266
+ index = 0
267
+ lst.clear()
268
+ resolved_idx = max(0, min(index, len(rows) - 1)) if (rows and index is not None) else 0
269
+ # File row: pointer 2 + name 1fr + size 12 + mtime 22, inside
270
+ # the browser box (border 2 + padding 2). If the name is
271
+ # left at full length, the 1fr column collapses to 0 on a
272
+ # narrow window and the name is hidden completely. Truncate
273
+ # it to the width that is actually available, with ... .
274
+ name_max = max(12, self._content_width() - 4 - 36)
275
+ for idx, path in enumerate(rows):
276
+ is_parent = path == self.browser_dir.parent and path not in list_browser_entries(self.browser_dir)
277
+ is_focused = (idx == resolved_idx)
278
+ if is_parent:
279
+ item = ListItem(
280
+ Horizontal(
281
+ Label("❯ " if is_focused else " ", classes="file-pointer"),
282
+ Label("📁 ../", classes="file-name"),
283
+ Label("", classes="file-size"),
284
+ Label("", classes="file-mtime"),
285
+ classes="file-row",
286
+ )
287
+ )
288
+ item._riptide_path = path # type: ignore[attr-defined]
289
+ item._riptide_parent = True # type: ignore[attr-defined]
290
+ lst.append(item)
291
+ else:
292
+ lst.append(_build_file_item(path, path in selected, is_focused=is_focused, max_name_chars=name_max))
293
+ try:
294
+ if rows and index is not None:
295
+ lst.index = max(0, min(index, len(rows) - 1))
296
+ except Exception:
297
+ pass
298
+ self._update_folder_header()
299
+
300
+ def _update_folder_header(self) -> None:
301
+ try:
302
+ # Reserve room for the Find label at the right end, so
303
+ # a long folder path is truncated with ... instead of
304
+ # pushing the Find label off, or collapsing to nothing.
305
+ find_width = len(f"Find: {self._search_query}▌") + 1
306
+ path_max = max(12, self._content_width() - find_width - 3)
307
+ trunc_dir = _truncate_filename(str(self.browser_dir), path_max)
308
+ self.query_one("#browser_path", Static).update(f"📁 {trunc_dir}")
309
+ # Find label is always visible at the right corner of the
310
+ # file-selection box header, with the typed text in white
311
+ # and a blinking cursor, even when the query is empty.
312
+ cursor = "▌" if getattr(self, "_find_cursor_visible", True) else " "
313
+ self.query_one("#browser_search_indicator", Static).update(
314
+ f"[#E8E8E8]Find:[/] [bold #FFFFFF]{self._search_query}[/][bold #FF6B6B]{cursor}[/]"
315
+ )
316
+ except Exception:
317
+ pass
318
+
319
+ def _toggle_find_cursor(self) -> None:
320
+ self._find_cursor_visible = not getattr(self, "_find_cursor_visible", True)
321
+ self._update_folder_header()
322
+
323
+ def _go_up(self) -> None:
324
+ if self.browser_dir.parent != self.browser_dir:
325
+ self.browser_dir = self.browser_dir.parent
326
+ self._search_query = ""
327
+ self._refresh_browser(auto_first=True)
328
+
329
+ def _go_into(self, path: Path) -> None:
330
+ if path.is_dir():
331
+ self.browser_dir = path
332
+ self._search_query = ""
333
+ self._refresh_browser(auto_first=True)
334
+
335
+ # -- compose ------------------------------------------------------
336
+ def compose(self) -> ComposeResult:
337
+ banner = BANNER_HOST if self.role == "host" else BANNER_CLIENT
338
+ yield VerticalScroll(
339
+ Horizontal(
340
+ Static(banner, classes="session-header-text", id="session_host_title"),
341
+ Vertical(
342
+ Static(self._format_client_info(), id="peer_client_label"),
343
+ Static(self._format_status_indicator(), id="peer_status_label"),
344
+ id="session_peer_info",
345
+ ),
346
+ id="session_header_row",
347
+ ),
348
+ Vertical(
349
+ Horizontal(
350
+ Static(f"📁 {self.browser_dir}", id="browser_path"),
351
+ Static("Find: ▌", id="browser_search_indicator"),
352
+ id="browser_header_row",
353
+ ),
354
+ Static("─" * 200, id="browser_separator"),
355
+ Horizontal(
356
+ Label(" ", classes="file-header-pointer"),
357
+ Label("NAME", classes="file-header-name"),
358
+ Label("SIZE", classes="file-header-size"),
359
+ Label("MODIFIED", classes="file-header-mtime"),
360
+ classes="file-header-row",
361
+ ),
362
+ ListView(id="cwd_list"),
363
+ id="browser_box",
364
+ ),
365
+ Vertical(
366
+ Static(self._format_selected_header(), id="selected_header"),
367
+ Vertical(id="selected_files_list"),
368
+ id="selected_files_section",
369
+ ),
370
+ Vertical(
371
+ Static("DOWNLOAD FROM PEER", classes="composer-label"),
372
+ Input(
373
+ placeholder="Peer file path to download, e.g. /home/friend/report.pdf…",
374
+ id="download_input",
375
+ ),
376
+ Static("", id="download_denied_message", classes="download-denied-message"),
377
+ classes="composer",
378
+ id="download_composer",
379
+ ),
380
+ Vertical(
381
+ ProgressBar(total=100, id="bar"),
382
+ Horizontal(
383
+ Label("Speed: -", id="metric_speed", classes="progress-metric progress-metric-left"),
384
+ Label("Transferred: 0 B / 0 B", id="metric_transferred", classes="progress-metric"),
385
+ Label("Remaining: 0 B", id="metric_remaining", classes="progress-metric"),
386
+ Label("ETA: -", id="metric_eta", classes="progress-metric progress-metric-right"),
387
+ id="progress_metrics_row",
388
+ ),
389
+ Horizontal(
390
+ Label("", id="progress_file_name"),
391
+ Label("", id="progress_file_size"),
392
+ id="progress_file_row",
393
+ ),
394
+ Static("", id="transfer_complete_message"),
395
+ id="progress_section",
396
+ ),
397
+ id="session_scroll",
398
+ can_focus=False,
399
+ )
400
+ yield Horizontal(
401
+ Label(r"[bold #E8E8E8]\[↑/↓][/] [dim #FFFFFF]Move[/]", classes="keymap-item keymap-left"),
402
+ Label(r"[bold #E8E8E8]\[Space][/] [dim #FFFFFF]Open/Select[/]", classes="keymap-item"),
403
+ Label(r"[bold #E8E8E8]\[0][/] [dim #FFFFFF]Deselect all[/]", classes="keymap-item"),
404
+ Label(r"[bold #E8E8E8]\[Enter][/] [dim #FFFFFF]Send[/]", classes="keymap-item"),
405
+ Label(r"[bold #E8E8E8]\[Backspace][/] [dim #FFFFFF]Erase/Prev.Directory[/]", classes="keymap-item"),
406
+ Label(r"[bold #E8E8E8]\[Tab][/] [dim #FFFFFF]Cycle UI[/]", classes="keymap-item"),
407
+ Label(r"[bold #E8E8E8]\[Esc][/] [dim #FFFFFF]Leave[/]", classes="keymap-item keymap-right"),
408
+ id="footer_bar",
409
+ )
410
+ yield Vertical(
411
+ Vertical(
412
+ Static("📥 DOWNLOAD REQUEST", id="download_modal_title"),
413
+ Static("", id="download_approval"),
414
+ Horizontal(
415
+ Button("Accept", id="approve_download", classes="allow"),
416
+ Button("Deny", id="deny_download", classes="deny"),
417
+ id="download_modal_actions",
418
+ ),
419
+ Static(r"[bold #E8E8E8]\[Enter][/] [dim #FFFFFF]Accept[/] [bold #E8E8E8]\[Backspace][/] [dim #FFFFFF]Deny[/]", id="download_modal_footer"),
420
+ id="download_modal_card",
421
+ ),
422
+ id="download_modal_overlay",
423
+ )
424
+
425
+ def on_mount(self) -> None:
426
+ self.session.on_file_received = lambda name, ok, reason: self.app.call_from_thread(self._on_received, name, ok, reason)
427
+ self.session.on_ack = lambda name, ok, reason: self.app.call_from_thread(
428
+ self._set_status, f"Received ack for {name}: {'verified' if ok else 'FAILED ' + reason}"
429
+ )
430
+ self.session.on_error = lambda msg: self.app.call_from_thread(self._on_session_error, msg)
431
+ self.session.on_disconnect = lambda: self.app.call_from_thread(self._on_session_disconnect)
432
+ self.session.on_progress = lambda name, received, total: self.app.call_from_thread(
433
+ self._on_progress, name, received, total, received, total, "Receiving"
434
+ )
435
+ self.session.on_download_ask = lambda name, rid: self.app.call_from_thread(self._on_download_ask, name, rid)
436
+ self.session.on_download_denied = lambda name, rid: self.app.call_from_thread(self._on_download_denied, name, rid)
437
+ self.session.start()
438
+ self._find_cursor_visible = True
439
+ try:
440
+ self.set_interval(0.5, self._toggle_find_cursor)
441
+ except Exception:
442
+ pass
443
+ self._refresh_browser(auto_first=True)
444
+ self._refresh_selected()
445
+ try:
446
+ self._approve_btn = self.query_one("#approve_download", Button)
447
+ self._deny_btn = self.query_one("#deny_download", Button)
448
+ except Exception:
449
+ pass
450
+ self._set_download_approval_visible(False)
451
+ self._set_progress_visible(False)
452
+ self._update_header()
453
+ focus_first(self)
454
+
455
+ def _on_session_error(self, msg: str) -> None:
456
+ self._connection_state = "disconnected"
457
+ self._set_status(msg)
458
+ self._update_header()
459
+
460
+ def _on_session_disconnect(self) -> None:
461
+ # The session is over: the peer sent bye, or the read
462
+ # failed. Close our side and return to the Create/Join
463
+ # screen, where the host listener is re-armed and waiting
464
+ # for the next peer. Only pop when this screen is still on
465
+ # top, so a Leave pressed at the same moment cannot pop
466
+ # the screen underneath as well.
467
+ try:
468
+ self.session.close()
469
+ except Exception:
470
+ pass
471
+ try:
472
+ if self.app.screen is self:
473
+ self.app.pop_screen()
474
+ except Exception:
475
+ pass
476
+
477
+ def _set_download_approval_visible(self, visible: bool) -> None:
478
+ try:
479
+ overlay = self.query_one("#download_modal_overlay", Vertical)
480
+ if visible:
481
+ overlay.add_class("active")
482
+ overlay.styles.display = "block"
483
+ if self._approve_btn:
484
+ self._approve_btn.focus()
485
+ else:
486
+ overlay.remove_class("active")
487
+ overlay.styles.display = "none"
488
+ except Exception:
489
+ pass
490
+ for btn in (self._approve_btn, self._deny_btn):
491
+ if btn is not None:
492
+ try:
493
+ btn.styles.display = "block" if visible else "none"
494
+ except Exception:
495
+ pass
496
+
497
+ def _update_download_composer(self) -> None:
498
+ # Download composer is hidden completely while any file is
499
+ # selected to send, or while a transfer is in progress
500
+ # (progress bar visible, sending or receiving). It returns
501
+ # when neither applies.
502
+ hidden = bool(self.selection) or self._transfer_active or self._progress_visible
503
+ try:
504
+ dl_input = self.query_one("#download_input", Input)
505
+ dl_input.disabled = hidden
506
+ dl_composer = self.query_one("#download_composer", Vertical)
507
+ if hidden:
508
+ dl_composer.add_class("disabled")
509
+ dl_composer.styles.display = "none"
510
+ if self.focused == dl_input:
511
+ self.query_one("#cwd_list", ListView).focus()
512
+ else:
513
+ dl_composer.remove_class("disabled")
514
+ dl_composer.styles.display = "block"
515
+ except Exception:
516
+ pass
517
+
518
+ def _cancel_complete_timer(self) -> None:
519
+ try:
520
+ if self._complete_timer is not None:
521
+ self._complete_timer.stop()
522
+ except Exception:
523
+ pass
524
+ self._complete_timer = None
525
+
526
+ def _hide_transfer_complete(self) -> None:
527
+ # Only the Sent message auto-hides. A Received message stays
528
+ # until the next transfer, so a list of files can be read.
529
+ if self._complete_message_kind != "sent":
530
+ return
531
+ if self._transfer_active or self._progress_visible:
532
+ return
533
+ self._complete_timer = None
534
+ self._complete_message_kind = None
535
+ try:
536
+ self.query_one("#transfer_complete_message", Static).styles.display = "none"
537
+ self.query_one("#progress_section", Vertical).styles.display = "none"
538
+ except Exception:
539
+ pass
540
+
541
+ def _set_progress_visible(self, visible: bool) -> None:
542
+ self._progress_visible = visible
543
+ if visible:
544
+ # A new transfer replaces any previous completion message,
545
+ # and must not be hidden by an old Sent-message timer.
546
+ self._cancel_complete_timer()
547
+ self._complete_message_kind = None
548
+ try:
549
+ section = self.query_one("#progress_section", Vertical)
550
+ section.styles.display = "block" if visible else "none"
551
+ if visible:
552
+ self.query_one("#transfer_complete_message", Static).styles.display = "none"
553
+ self.query_one("#bar", ProgressBar).styles.display = "block"
554
+ self.query_one("#progress_metrics_row", Horizontal).styles.display = "block"
555
+ self.query_one("#progress_file_row", Horizontal).styles.display = "block"
556
+ except Exception:
557
+ pass
558
+ self._update_download_composer()
559
+
560
+ def _show_transfer_complete(self, sent_count: int, failed_count: int = 0) -> None:
561
+ # In place of the progress bar, at the bottom of the page:
562
+ # the progress section stays visible, but the bar, metrics
563
+ # and file row are hidden and only the green statement shows.
564
+ # It goes away after 5 seconds.
565
+ try:
566
+ section = self.query_one("#progress_section", Vertical)
567
+ section.styles.display = "block"
568
+ self.query_one("#bar", ProgressBar).styles.display = "none"
569
+ self.query_one("#progress_metrics_row", Horizontal).styles.display = "none"
570
+ self.query_one("#progress_file_row", Horizontal).styles.display = "none"
571
+ noun = "file" if sent_count == 1 else "files"
572
+ text = f"Sent {sent_count} {noun} successfully."
573
+ if failed_count:
574
+ failed_noun = "file" if failed_count == 1 else "files"
575
+ text = f"Sent {sent_count} {noun} successfully, {failed_count} {failed_noun} failed."
576
+ msg = self.query_one("#transfer_complete_message", Static)
577
+ msg.update(text)
578
+ msg.styles.display = "block"
579
+ except Exception:
580
+ pass
581
+ self._complete_message_kind = "sent"
582
+ self._cancel_complete_timer()
583
+ try:
584
+ self._complete_timer = self.set_timer(5, self._hide_transfer_complete)
585
+ except Exception:
586
+ self._complete_timer = None
587
+
588
+ def _show_received_message(self) -> None:
589
+ # Receiver side, in the same place as the Sent message.
590
+ # One file: Received file "name" successfully.
591
+ # Several files: Received N files, then one file per line.
592
+ # No 5-second timer here, so a list can be read and scrolled.
593
+ # The message lives inside the page's VerticalScroll, so a
594
+ # long list scrolls with the page.
595
+ if not self._received_files:
596
+ return
597
+ try:
598
+ section = self.query_one("#progress_section", Vertical)
599
+ section.styles.display = "block"
600
+ self.query_one("#bar", ProgressBar).styles.display = "none"
601
+ self.query_one("#progress_metrics_row", Horizontal).styles.display = "none"
602
+ self.query_one("#progress_file_row", Horizontal).styles.display = "none"
603
+ content_width = self._content_width()
604
+ if len(self._received_files) == 1:
605
+ # 'Received file "' + '" successfully.' = 30 chars
606
+ # around the name, so the name itself must fit in
607
+ # the rest, with ... if it does not.
608
+ trunc_name = _truncate_filename(self._received_files[0], max(12, content_width - 30))
609
+ text = f'Received file "{trunc_name}" successfully.'
610
+ orig = self._renamed_files.get(self._received_files[0])
611
+ if orig:
612
+ trunc_orig = _truncate_filename(orig, max(12, content_width - 30))
613
+ text += f' (original "{trunc_orig}" already existed).'
614
+ else:
615
+ lines = [f"Received {len(self._received_files)} files:"]
616
+ for name in self._received_files:
617
+ trunc_name = _truncate_filename(name, max(12, content_width - 2))
618
+ orig = self._renamed_files.get(name)
619
+ if orig:
620
+ trunc_orig = _truncate_filename(orig, max(12, content_width - 2))
621
+ lines.append(f"• {trunc_name} (was {trunc_orig})")
622
+ else:
623
+ lines.append(f"• {trunc_name}")
624
+ text = "\n".join(lines)
625
+ msg = self.query_one("#transfer_complete_message", Static)
626
+ msg.update(text)
627
+ msg.styles.display = "block"
628
+ except Exception:
629
+ pass
630
+ self._complete_message_kind = "received"
631
+ self._cancel_complete_timer()
632
+ def _show_next_download_approval(self) -> None:
633
+ if not self._pending_downloads:
634
+ try:
635
+ self.query_one("#download_approval", Static).update("")
636
+ except Exception:
637
+ pass
638
+ self._set_download_approval_visible(False)
639
+ return
640
+ name, _rid = self._pending_downloads[0]
641
+ try:
642
+ # Modal card is 60 wide with padding 1 2, so its content
643
+ # is 56 wide, or less on a window narrower than the card.
644
+ card_content = max(12, min(56, self._screen_width() - 8))
645
+ trunc_name = _truncate_filename(name, card_content)
646
+ self.query_one("#download_approval", Static).update(
647
+ f"Peer wants to download:\n[bold #FFFFFF]{trunc_name}[/]\n\n"
648
+ f"[dim #A1A1AA]Allow to send it, Deny to refuse.[/]"
649
+ )
650
+ except Exception:
651
+ pass
652
+ self._set_download_approval_visible(True)
653
+
654
+ def _on_download_ask(self, name: str, request_id: str) -> None:
655
+ self._pending_downloads.append((name, request_id))
656
+ if len(self._pending_downloads) == 1:
657
+ self._show_next_download_approval()
658
+ else:
659
+ self._set_status(f"Peer also wants to download: {name} (queued)")
660
+
661
+ def _answer_download(self, allow: bool) -> None:
662
+ if not self._pending_downloads:
663
+ return
664
+ name, rid = self._pending_downloads.pop(0)
665
+ self._show_next_download_approval()
666
+ if allow:
667
+ # Approved download, sent in a worker thread with progress,
668
+ # so the approver side gets the same progress bar and
669
+ # Sent message as a normal send. Previously respond_download
670
+ # sent with no progress callback, so the approver saw nothing.
671
+ self._received_files = []
672
+ self._renamed_files = {}
673
+ threading.Thread(target=lambda: self._send_approved_download(name, rid), daemon=True).start()
674
+ else:
675
+ threading.Thread(target=lambda: self.session.respond_download(name, rid, False), daemon=True).start()
676
+ self._set_status(f"{'Allowed' if allow else 'Denied'} download of {name}")
677
+
678
+ def _send_approved_download(self, name: str, request_id: str) -> None:
679
+ # Resolve the path the same way Session.respond_download does,
680
+ # so the progress batch total matches the file being sent.
681
+ path = Path(name).expanduser()
682
+ if not path.is_absolute():
683
+ path = self.session.cwd / path
684
+ total = path.stat().st_size if path.is_file() else 0
685
+ self._transfer_active = True
686
+ self.app.call_from_thread(self._set_progress_visible, True)
687
+
688
+ def on_file_prog(sent_name: str, sent: int, single_total: int) -> None:
689
+ self.app.call_from_thread(
690
+ self._on_progress, sent_name, sent, single_total, sent, total, "Sending"
691
+ )
692
+
693
+ ok = self.session.respond_download(name, request_id, True, on_progress=on_file_prog)
694
+ self.app.call_from_thread(self._finish_send, 1 if ok else 0, 0 if ok else 1)
695
+
696
+ # -- status / progress --------------------------------------------
697
+ def _set_status(self, text: str) -> None:
698
+ # The Status subheader was removed from the page. Connection
699
+ # state is still shown in the header (Connected ● / etc.),
700
+ # and transfer progress is shown in the progress section.
701
+ # Keep the last message in memory for debugging/tests, and
702
+ # do not crash if the old #status widget is gone.
703
+ self._last_status = text
704
+ try:
705
+ self.query_one("#status", Static).update(f"Status: {text}")
706
+ except Exception:
707
+ pass
708
+
709
+ def _on_received(self, name: str, ok: bool, reason: str) -> None:
710
+ self._set_progress_visible(False)
711
+ self._set_status(f"Received {name}: {'verified' if ok else 'FAILED ' + reason}")
712
+ self._refresh_browser()
713
+ if not ok:
714
+ return
715
+ # Batch received files together: a sender sends files
716
+ # back to back, so a new batch is a gap of 10 seconds or
717
+ # more since the last received file, or a user-started
718
+ # send or download request, which clears the list below.
719
+ now = time.monotonic()
720
+ if self._last_received_at and now - self._last_received_at > 10:
721
+ self._received_files = []
722
+ self._renamed_files = {}
723
+ self._last_received_at = now
724
+ if reason.startswith("renamed from "):
725
+ self._renamed_files[name] = reason[len("renamed from "):]
726
+ if name not in self._received_files:
727
+ self._received_files.append(name)
728
+ self._show_received_message()
729
+ def _on_progress(
730
+ self,
731
+ name: str,
732
+ file_sent: int,
733
+ file_total: int,
734
+ batch_sent: int,
735
+ batch_total: int,
736
+ direction: str,
737
+ ) -> None:
738
+ self._set_progress_visible(True)
739
+ if self._overall_transfer.total != batch_total:
740
+ self._overall_transfer = TransferProgress(batch_total)
741
+ self._overall_transfer.update(batch_sent)
742
+
743
+ try:
744
+ bar = self.query_one("#bar", ProgressBar)
745
+ bar.progress = (batch_sent / batch_total * 100) if batch_total else 100
746
+
747
+ speed = fmt_bytes(self._overall_transfer.speed_ema) if self._overall_transfer.speed_ema else "-"
748
+ remaining = fmt_bytes(self._overall_transfer.remaining)
749
+ eta = format_eta(self._overall_transfer.eta_seconds)
750
+
751
+ self.query_one("#metric_speed", Label).update(f"Speed: {speed}/s")
752
+ self.query_one("#metric_transferred", Label).update(
753
+ f"{direction}: {format_bytes(batch_sent)} / {format_bytes(batch_total)}"
754
+ )
755
+ self.query_one("#metric_remaining", Label).update(f"Remaining: {remaining}")
756
+ self.query_one("#metric_eta", Label).update(f"ETA: {eta}")
757
+
758
+ size_text = f"[{format_bytes(file_sent)} / {format_bytes(file_total)}]"
759
+ # Dynamic, not a fixed 50: the name label is 1fr and
760
+ # the size label is auto, so the name must fit in
761
+ # content width minus the size label, or it is clipped
762
+ # or, on a narrow window, collapses to nothing.
763
+ prefix = f"{direction}: "
764
+ name_max = max(12, self._content_width() - len(prefix) - len(size_text))
765
+ trunc_name = _truncate_filename(name, name_max)
766
+ self.query_one("#progress_file_name", Label).update(f"{prefix}{trunc_name}")
767
+ self.query_one("#progress_file_size", Label).update(size_text)
768
+ except Exception:
769
+ pass
770
+
771
+ def _format_selected_header(self) -> str:
772
+ if not self.selection:
773
+ return r"[#D7D7D7]Selected Files (0)[/] [dim]·[/] [#FFFFFF]\[Space][/][#D7D7D7] to select file[/]"
774
+ total = total_size(self.selection)
775
+ return (
776
+ f"[bold #FFFFFF]Selected Files ({len(self.selection)})[/] "
777
+ f"[#71717A]·[/] [#E8E8E8]Total: {format_bytes(total)}[/] "
778
+ )
779
+
780
+ def _refresh_selected(self) -> None:
781
+ try:
782
+ self.query_one("#selected_header", Static).update(self._format_selected_header())
783
+ selected_container = self.query_one("#selected_files_list", Vertical)
784
+ selected_container.remove_children()
785
+ for p in self.selection:
786
+ size_str = format_bytes(p.stat().st_size) if p.is_file() else ""
787
+ dim_size = f" [dim #FFFFFF]({size_str})[/]" if size_str else ""
788
+ # Full absolute paths are the longest strings on the
789
+ # page. Truncate them to the width available, with
790
+ # ..., keeping the start and the file name at the end.
791
+ size_plain_len = len(size_str) + 4 if size_str else 0
792
+ path_max = max(12, self._content_width() - 2 - size_plain_len)
793
+ trunc_path = _truncate_filename(str(p.resolve()), path_max)
794
+ selected_container.mount(
795
+ Static(f"[bold #FF6B6B]• {trunc_path}[/]{dim_size}", classes="selected-file-item")
796
+ )
797
+ except Exception:
798
+ pass
799
+
800
+ # Requirement 6: download input cannot be accessed when 'send' files are selected.
801
+ # It is hidden completely, not just dimmed, while any file is selected,
802
+ # and also while a transfer is in progress (see _update_download_composer).
803
+ self._update_download_composer()
804
+
805
+ # -- input handling ------------------------------------------------
806
+ # The send file input field was removed: files are now selected
807
+ # only from the file manager box, with Space, and sent with Enter.
808
+ # Tab completion for absolute paths remains in core/file_select
809
+ # for any future input, but is not wired to a send input here.
810
+ def on_input_submitted(self, event: Input.Submitted) -> None:
811
+ if event.input.id == "download_input":
812
+ # Requirement 6: Enter key initiates the request
813
+ name = _strip_surrounding_quotes(event.value)
814
+ event.input.value = ""
815
+ if name:
816
+ self._download(name)
817
+
818
+ def on_input_changed(self, event: Input.Changed) -> None:
819
+ if event.input.id == "download_input":
820
+ self._clear_download_denied()
821
+
822
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
823
+ if self.selection:
824
+ self._send()
825
+ else:
826
+ self._set_status("Select files first with Space, then press Enter to send.")
827
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
828
+ try:
829
+ lst = self.query_one("#cwd_list", ListView)
830
+ for child in lst.children:
831
+ try:
832
+ ptr = child.query_one(".file-pointer", Label)
833
+ ptr.update("❯ " if child == event.item else " ")
834
+ except Exception:
835
+ pass
836
+ except Exception:
837
+ pass
838
+
839
+ def on_button_pressed(self, event: Button.Pressed) -> None:
840
+ if event.button.id == "approve_download":
841
+ self._answer_download(True)
842
+ elif event.button.id == "deny_download":
843
+ self._answer_download(False)
844
+ elif event.button.id == "leave":
845
+ self._leave_session()
846
+
847
+ def _leave_session(self) -> None:
848
+ self._connection_state = "disconnected"
849
+ self._update_header()
850
+ try:
851
+ self.session.close()
852
+ except Exception:
853
+ pass
854
+ self.app.pop_screen()
855
+
856
+ def _send(self) -> None:
857
+ if not self.selection:
858
+ self._set_status("Failed: select at least one file first.")
859
+ return
860
+ # A new user-started send ends any previous received batch.
861
+ self._received_files = []
862
+ self._renamed_files = {}
863
+ files = list(self.selection)
864
+ self.selection = []
865
+ # Set transfer-active before refreshing, so the download
866
+ # composer stays hidden even though the selection is now empty.
867
+ self._transfer_active = True
868
+ self._refresh_selected()
869
+ self._refresh_browser()
870
+
871
+ total_batch_size = sum(p.stat().st_size for p in files if p.is_file())
872
+ self._overall_total = total_batch_size
873
+ self._overall_transfer = TransferProgress(total_batch_size)
874
+
875
+ def worker():
876
+ self.app.call_from_thread(self._set_progress_visible, True)
877
+ completed_bytes = 0
878
+ sent_count = 0
879
+ failed_count = 0
880
+ for path in files:
881
+ file_size = path.stat().st_size if path.is_file() else 0
882
+ self.app.call_from_thread(self._set_status, f"Sending {path.name}...")
883
+ def on_file_prog(name, sent, single_total):
884
+ batch_sent = completed_bytes + sent
885
+ self.app.call_from_thread(
886
+ self._on_progress,
887
+ name, sent, single_total, batch_sent, total_batch_size, "Sending"
888
+ )
889
+
890
+ ok = self.session.send_file(path, on_progress=on_file_prog)
891
+ completed_bytes += file_size
892
+ if ok:
893
+ sent_count += 1
894
+ else:
895
+ failed_count += 1
896
+ self.app.call_from_thread(
897
+ self._set_status,
898
+ f"Sent {path.name}" if ok else f"Failed to send {path.name}",
899
+ )
900
+ self.app.call_from_thread(self._finish_send, sent_count, failed_count)
901
+
902
+ threading.Thread(target=worker, daemon=True).start()
903
+
904
+ def _finish_send(self, sent_count: int, failed_count: int) -> None:
905
+ self._transfer_active = False
906
+ self._progress_visible = False
907
+ self._show_transfer_complete(sent_count, failed_count)
908
+ self._update_download_composer()
909
+ def _download(self, name: str) -> None:
910
+ name = _strip_surrounding_quotes(name)
911
+ self._clear_download_denied()
912
+ if self.selection:
913
+ self._set_status("Cannot download while files are selected to send. Send or unselect them first.")
914
+ return
915
+ # A new download request ends any previous received batch.
916
+ self._received_files = []
917
+ self._renamed_files = {}
918
+ self._set_status(f"Waiting for peer approval for {name}...")
919
+ ok = self.session.request_download(name, request_id=str(uuid.uuid4()))
920
+ if not ok:
921
+ self._set_status(f"Failed to request {name}")
922
+ def _on_download_denied(self, name: str, request_id: str) -> None:
923
+ self._set_status(f"Peer denied download of {name}")
924
+ try:
925
+ msg = self.query_one("#download_denied_message", Static)
926
+ # "✗ Peer denied download request for " is 36 chars.
927
+ trunc_name = _truncate_filename(name, max(12, self._content_width() - 36))
928
+ msg.update(f"✗ Peer denied download request for {trunc_name}")
929
+ msg.add_class("active")
930
+ msg.styles.display = "block"
931
+ except Exception:
932
+ pass
933
+
934
+ def _clear_download_denied(self) -> None:
935
+ try:
936
+ msg = self.query_one("#download_denied_message", Static)
937
+ msg.update("")
938
+ msg.remove_class("active")
939
+ msg.styles.display = "none"
940
+ except Exception:
941
+ pass
942
+
943
+
944
+ def _is_download_modal_active(self) -> bool:
945
+ return bool(self._pending_downloads)
946
+
947
+ async def on_key(self, event) -> None:
948
+ if self._is_download_modal_active():
949
+ if event.key == "enter":
950
+ event.stop()
951
+ self._answer_download(True)
952
+ return
953
+ if event.key in ("backspace", "delete"):
954
+ event.stop()
955
+ self._answer_download(False)
956
+ return
957
+ if event.key in ("left", "right", "tab"):
958
+ event.stop()
959
+ if self.focused == self._deny_btn and self._approve_btn:
960
+ self._approve_btn.focus()
961
+ elif self._deny_btn:
962
+ self._deny_btn.focus()
963
+ return
964
+ if event.key == "escape":
965
+ event.stop()
966
+ self._answer_download(False)
967
+ return
968
+ event.stop()
969
+ return
970
+
971
+ if event.key == "escape":
972
+ if self._search_query:
973
+ event.stop()
974
+ self._search_query = ""
975
+ self._refresh_browser(keep_index=0)
976
+ return
977
+ self._leave_session()
978
+ return
979
+
980
+ focused = self.focused
981
+
982
+ # 0: deselect all files. Not when typing in an input, where
983
+ # 0 is a digit in the path.
984
+ if event.key == "0" and not isinstance(focused, Input):
985
+ event.stop()
986
+ self.selection = []
987
+ self._refresh_selected()
988
+ self._refresh_browser()
989
+ return
990
+
991
+ # Spacebar: open folders (or parent ..) or select/toggle files
992
+ if event.key == "space" and isinstance(focused, ListView):
993
+ event.stop()
994
+ try:
995
+ item = self.query_one("#cwd_list", ListView).highlighted_child
996
+ path = getattr(item, "_riptide_path", None)
997
+ except Exception:
998
+ path = None
999
+ if path is not None:
1000
+ if getattr(item, "_riptide_parent", False):
1001
+ self._search_query = ""
1002
+ self._go_up()
1003
+ return
1004
+ if path.is_dir():
1005
+ self._search_query = ""
1006
+ self._go_into(path)
1007
+ return
1008
+ if path in self.selection:
1009
+ self.selection.remove(path)
1010
+ else:
1011
+ add_to_selection(self.selection, [path])
1012
+ self._refresh_selected()
1013
+ self._refresh_browser()
1014
+ return
1015
+
1016
+ # Enter key sends files
1017
+ if event.key == "enter":
1018
+ if isinstance(focused, Input) and focused.value.strip():
1019
+ return
1020
+ event.stop()
1021
+ if self.selection:
1022
+ self._send()
1023
+ else:
1024
+ self._set_status("Select files first with Space, then press Enter to send.")
1025
+ return
1026
+
1027
+ # Tab cycles between UI elements
1028
+ if event.key == "tab":
1029
+ event.stop()
1030
+ self.focus_next()
1031
+ return
1032
+ if event.key == "shift+tab":
1033
+ event.stop()
1034
+ self.focus_previous()
1035
+ return
1036
+ # Backspace handling in ListView: search backspace or go up
1037
+ if event.key == "backspace" and isinstance(focused, ListView):
1038
+ event.stop()
1039
+ if self._search_query:
1040
+ self._search_query = self._search_query[:-1]
1041
+ self._refresh_browser(keep_index=0)
1042
+ else:
1043
+ self._go_up()
1044
+ return
1045
+
1046
+ # Requirement 4: Typing searches the files when focused on ListView
1047
+ if isinstance(focused, ListView):
1048
+ if event.character and event.character.isprintable() and event.key not in ("space", "enter", "tab"):
1049
+ event.stop()
1050
+ self._search_query += event.character
1051
+ self._refresh_browser(keep_index=0)
1052
+ return
1053
+
1054
+ # Arrow key navigation between widgets
1055
+ if event.key in ("up", "down") and not isinstance(focused, ListView):
1056
+ if handle_arrow_key(self, event.key):
1057
+ event.stop()
1058
+ return
1059
+