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.
- riptide/__init__.py +28 -0
- riptide/__main__.py +4 -0
- riptide/app.py +45 -0
- riptide/core/__init__.py +1 -0
- riptide/core/config.py +176 -0
- riptide/core/file_select.py +89 -0
- riptide/core/firewall.py +552 -0
- riptide/core/focus.py +82 -0
- riptide/core/identity.py +243 -0
- riptide/core/nat.py +102 -0
- riptide/core/session.py +887 -0
- riptide/core/transfer.py +89 -0
- riptide/screens/__init__.py +1 -0
- riptide/screens/create.py +529 -0
- riptide/screens/join.py +479 -0
- riptide/screens/menu.py +94 -0
- riptide/screens/session_screen.py +1059 -0
- riptide/theme.py +1049 -0
- riptide_transfer-1.3.6.dist-info/METADATA +176 -0
- riptide_transfer-1.3.6.dist-info/RECORD +23 -0
- riptide_transfer-1.3.6.dist-info/WHEEL +5 -0
- riptide_transfer-1.3.6.dist-info/entry_points.txt +2 -0
- riptide_transfer-1.3.6.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|