ferp 0.7.1__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.
- ferp/__init__.py +3 -0
- ferp/__main__.py +4 -0
- ferp/__version__.py +1 -0
- ferp/app.py +9 -0
- ferp/cli.py +160 -0
- ferp/core/__init__.py +0 -0
- ferp/core/app.py +1312 -0
- ferp/core/bundle_installer.py +245 -0
- ferp/core/command_provider.py +77 -0
- ferp/core/dependency_manager.py +59 -0
- ferp/core/fs_controller.py +70 -0
- ferp/core/fs_watcher.py +144 -0
- ferp/core/messages.py +49 -0
- ferp/core/path_actions.py +124 -0
- ferp/core/paths.py +3 -0
- ferp/core/protocols.py +8 -0
- ferp/core/script_controller.py +515 -0
- ferp/core/script_protocol.py +35 -0
- ferp/core/script_runner.py +421 -0
- ferp/core/settings.py +16 -0
- ferp/core/settings_store.py +69 -0
- ferp/core/state.py +156 -0
- ferp/core/task_store.py +164 -0
- ferp/core/transcript_logger.py +95 -0
- ferp/domain/__init__.py +0 -0
- ferp/domain/scripts.py +29 -0
- ferp/fscp/host/__init__.py +11 -0
- ferp/fscp/host/host.py +439 -0
- ferp/fscp/host/managed_process.py +113 -0
- ferp/fscp/host/process_registry.py +124 -0
- ferp/fscp/protocol/__init__.py +13 -0
- ferp/fscp/protocol/errors.py +2 -0
- ferp/fscp/protocol/messages.py +55 -0
- ferp/fscp/protocol/schemas/__init__.py +0 -0
- ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
- ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
- ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
- ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
- ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
- ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
- ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
- ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
- ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
- ferp/fscp/protocol/state.py +16 -0
- ferp/fscp/protocol/validator.py +123 -0
- ferp/fscp/scripts/__init__.py +0 -0
- ferp/fscp/scripts/runtime/__init__.py +4 -0
- ferp/fscp/scripts/runtime/__main__.py +40 -0
- ferp/fscp/scripts/runtime/errors.py +14 -0
- ferp/fscp/scripts/runtime/io.py +64 -0
- ferp/fscp/scripts/runtime/script.py +149 -0
- ferp/fscp/scripts/runtime/state.py +17 -0
- ferp/fscp/scripts/runtime/worker.py +13 -0
- ferp/fscp/scripts/sdk.py +548 -0
- ferp/fscp/transcript/__init__.py +3 -0
- ferp/fscp/transcript/events.py +14 -0
- ferp/resources/__init__.py +0 -0
- ferp/services/__init__.py +3 -0
- ferp/services/file_listing.py +120 -0
- ferp/services/monday_sync.py +155 -0
- ferp/services/releases.py +214 -0
- ferp/services/scripts.py +90 -0
- ferp/services/update_check.py +130 -0
- ferp/styles/index.tcss +638 -0
- ferp/themes/themes.py +238 -0
- ferp/widgets/__init__.py +17 -0
- ferp/widgets/dialogs.py +167 -0
- ferp/widgets/file_tree.py +991 -0
- ferp/widgets/forms.py +146 -0
- ferp/widgets/output_panel.py +244 -0
- ferp/widgets/panels.py +13 -0
- ferp/widgets/process_list.py +158 -0
- ferp/widgets/readme_modal.py +59 -0
- ferp/widgets/scripts.py +192 -0
- ferp/widgets/task_capture.py +74 -0
- ferp/widgets/task_list.py +493 -0
- ferp/widgets/top_bar.py +110 -0
- ferp-0.7.1.dist-info/METADATA +128 -0
- ferp-0.7.1.dist-info/RECORD +87 -0
- ferp-0.7.1.dist-info/WHEEL +5 -0
- ferp-0.7.1.dist-info/entry_points.txt +2 -0
- ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
- ferp-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Pattern, Sequence, cast
|
|
10
|
+
|
|
11
|
+
from textual import on
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Horizontal
|
|
15
|
+
from textual.events import Click
|
|
16
|
+
from textual.timer import Timer
|
|
17
|
+
from textual.widget import Widget
|
|
18
|
+
from textual.widgets import Input, Label, ListItem, ListView, LoadingIndicator
|
|
19
|
+
|
|
20
|
+
from ferp.core.messages import (
|
|
21
|
+
CreatePathRequest,
|
|
22
|
+
DeletePathRequest,
|
|
23
|
+
DirectorySelectRequest,
|
|
24
|
+
NavigateRequest,
|
|
25
|
+
RenamePathRequest,
|
|
26
|
+
)
|
|
27
|
+
from ferp.core.protocols import AppWithPath
|
|
28
|
+
from ferp.core.state import FileTreeState, FileTreeStateStore
|
|
29
|
+
from ferp.widgets.dialogs import BulkRenameConfirmDialog
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ferp.core.app import Ferp
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FileListingEntry:
|
|
37
|
+
path: Path
|
|
38
|
+
display_name: str
|
|
39
|
+
char_count: int
|
|
40
|
+
type_label: str
|
|
41
|
+
modified_ts: float | None
|
|
42
|
+
is_dir: bool
|
|
43
|
+
search_blob: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@lru_cache(maxsize=2048)
|
|
47
|
+
def _format_timestamp(timestamp: float) -> str:
|
|
48
|
+
return datetime.strftime(datetime.fromtimestamp(timestamp), "%x %I:%S %p")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split_replace_input(value: str) -> tuple[str, str, str] | None:
|
|
52
|
+
if value.startswith("/"):
|
|
53
|
+
second = value.find("/", 1)
|
|
54
|
+
if second == -1:
|
|
55
|
+
return None
|
|
56
|
+
pattern = value[1:second]
|
|
57
|
+
replacement = value[second + 1 :]
|
|
58
|
+
return "regex", pattern, replacement
|
|
59
|
+
|
|
60
|
+
if "/" not in value:
|
|
61
|
+
return None
|
|
62
|
+
pattern, replacement = value.split("/", 1)
|
|
63
|
+
return "literal", pattern, replacement
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _filter_query_for_input(value: str) -> str:
|
|
67
|
+
parsed = _split_replace_input(value)
|
|
68
|
+
if parsed is None:
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
mode, pattern, _replacement = parsed
|
|
72
|
+
if mode == "regex":
|
|
73
|
+
return f"/{pattern}"
|
|
74
|
+
return pattern
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _escape_regex_replacement(replacement: str) -> str:
|
|
78
|
+
return replacement.replace("\\", r"\\").replace("$", r"\$")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _split_stem_suffix(name: str) -> tuple[str, str]:
|
|
82
|
+
suffix = Path(name).suffix
|
|
83
|
+
if suffix in ("", "."):
|
|
84
|
+
suffix = ""
|
|
85
|
+
stem = name[: -len(suffix)] if suffix else name
|
|
86
|
+
return stem, suffix
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _replace_in_stem(
|
|
90
|
+
name: str, *, matcher: Pattern[str], replacement: str
|
|
91
|
+
) -> tuple[str, str, int]:
|
|
92
|
+
stem, suffix = _split_stem_suffix(name)
|
|
93
|
+
new_stem, count = matcher.subn(replacement, stem)
|
|
94
|
+
new_name = f"{new_stem}{suffix}"
|
|
95
|
+
return new_name, new_stem, count
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FileItem(ListItem):
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
path: Path,
|
|
102
|
+
*,
|
|
103
|
+
metadata: FileListingEntry | None = None,
|
|
104
|
+
is_header: bool = False,
|
|
105
|
+
classes: str | None = None,
|
|
106
|
+
**kwargs,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.path = path
|
|
109
|
+
self.is_header = is_header
|
|
110
|
+
self.metadata = metadata
|
|
111
|
+
|
|
112
|
+
if is_header:
|
|
113
|
+
row = Horizontal(
|
|
114
|
+
Label("Name", classes="file_tree_cell file_tree_name file_tree_header"),
|
|
115
|
+
Label(
|
|
116
|
+
"Chars", classes="file_tree_cell file_tree_chars file_tree_header"
|
|
117
|
+
),
|
|
118
|
+
Label("Type", classes="file_tree_cell file_tree_type file_tree_header"),
|
|
119
|
+
Label(
|
|
120
|
+
"Modified",
|
|
121
|
+
classes="file_tree_cell file_tree_modified file_tree_header",
|
|
122
|
+
),
|
|
123
|
+
classes="file_tree_row",
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
if metadata is None:
|
|
127
|
+
raise ValueError("metadata required for non-header FileItems")
|
|
128
|
+
row = Horizontal(
|
|
129
|
+
Label(
|
|
130
|
+
metadata.display_name,
|
|
131
|
+
classes="file_tree_cell file_tree_name",
|
|
132
|
+
markup=False,
|
|
133
|
+
),
|
|
134
|
+
Label(
|
|
135
|
+
str(metadata.char_count), classes="file_tree_cell file_tree_chars"
|
|
136
|
+
),
|
|
137
|
+
Label(
|
|
138
|
+
metadata.type_label,
|
|
139
|
+
classes="file_tree_cell file_tree_type",
|
|
140
|
+
markup=False,
|
|
141
|
+
),
|
|
142
|
+
Label(
|
|
143
|
+
self._format_modified(metadata),
|
|
144
|
+
classes="file_tree_cell file_tree_modified",
|
|
145
|
+
),
|
|
146
|
+
classes=f"file_tree_row {'file_tree_type_dir' if metadata.is_dir else 'file_tree_type_file'}",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
super().__init__(row, classes=classes, **kwargs)
|
|
150
|
+
self.disabled = is_header
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _resolve_modified_ts(metadata: FileListingEntry) -> float | None:
|
|
154
|
+
return metadata.modified_ts
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _format_modified(cls, metadata: FileListingEntry) -> str:
|
|
158
|
+
modified_ts = cls._resolve_modified_ts(metadata)
|
|
159
|
+
if modified_ts is None:
|
|
160
|
+
return "--"
|
|
161
|
+
return _format_timestamp(modified_ts)
|
|
162
|
+
|
|
163
|
+
def on_click(self, event: Click) -> None:
|
|
164
|
+
if event.chain < 2:
|
|
165
|
+
return
|
|
166
|
+
parent = self.parent
|
|
167
|
+
if isinstance(parent, FileTree):
|
|
168
|
+
parent._activate_item(self)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ChunkNavigatorItem(ListItem):
|
|
172
|
+
"""Interactive row to navigate between file list chunks."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, label: str, *, direction: str) -> None:
|
|
175
|
+
super().__init__(
|
|
176
|
+
Label(label, classes="file_tree_notice"), classes="item_notice"
|
|
177
|
+
)
|
|
178
|
+
self.direction = direction
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class FileTreeHeader(Widget):
|
|
182
|
+
"""Static header row for the file tree list."""
|
|
183
|
+
|
|
184
|
+
def compose(self) -> ComposeResult:
|
|
185
|
+
yield Horizontal(
|
|
186
|
+
Label("Name", classes="file_tree_name file_tree_header"),
|
|
187
|
+
Label("Chars", classes="file_tree_chars file_tree_header"),
|
|
188
|
+
Label("Type", classes="file_tree_type file_tree_header"),
|
|
189
|
+
Label("Modified", classes="file_tree_modified file_tree_header"),
|
|
190
|
+
classes="file_tree_header_row",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class FileTreeFilterWidget(Widget):
|
|
195
|
+
"""Hidden input bar for filtering file tree entries."""
|
|
196
|
+
|
|
197
|
+
DEBOUNCE_DELAY = 0.4
|
|
198
|
+
BINDINGS = [
|
|
199
|
+
Binding("escape", "hide", "Hide filter", show=True),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self, *, state_store: FileTreeStateStore, id: str | None = None
|
|
204
|
+
) -> None:
|
|
205
|
+
super().__init__(id=id)
|
|
206
|
+
self._state_store = state_store
|
|
207
|
+
self.display = "none"
|
|
208
|
+
self._input: Input | None = None
|
|
209
|
+
self._debounce_timer: Timer | None = None
|
|
210
|
+
self._pending_value = ""
|
|
211
|
+
|
|
212
|
+
def compose(self) -> ComposeResult:
|
|
213
|
+
yield Input(
|
|
214
|
+
placeholder="Filter entries (type to refine) — use find/replace or /regex/replace",
|
|
215
|
+
id="file_tree_filter_input",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def on_mount(self) -> None:
|
|
219
|
+
self._input = self.query_one(Input)
|
|
220
|
+
|
|
221
|
+
def show(self, value: str) -> None:
|
|
222
|
+
self.display = "block"
|
|
223
|
+
if self._input:
|
|
224
|
+
self._input.value = value
|
|
225
|
+
self._input.focus()
|
|
226
|
+
self._pending_value = value
|
|
227
|
+
|
|
228
|
+
def hide(self) -> None:
|
|
229
|
+
self.display = "none"
|
|
230
|
+
if self._debounce_timer is not None:
|
|
231
|
+
self._debounce_timer.stop()
|
|
232
|
+
self._debounce_timer = None
|
|
233
|
+
file_tree = self.app.query_one("#file_list")
|
|
234
|
+
file_tree.focus()
|
|
235
|
+
|
|
236
|
+
def action_hide(self) -> None:
|
|
237
|
+
self.hide()
|
|
238
|
+
|
|
239
|
+
@on(Input.Changed)
|
|
240
|
+
def handle_changed(self, event: Input.Changed) -> None:
|
|
241
|
+
if self._input is None or event.input is not self._input:
|
|
242
|
+
return
|
|
243
|
+
self._pending_value = event.value
|
|
244
|
+
self._schedule_filter_apply()
|
|
245
|
+
|
|
246
|
+
@on(Input.Submitted)
|
|
247
|
+
def handle_submit(self, event: Input.Submitted) -> None:
|
|
248
|
+
if self._input is None or event.input is not self._input:
|
|
249
|
+
return
|
|
250
|
+
file_tree = self.app.query_one("#file_list", FileTree)
|
|
251
|
+
file_tree.handle_filter_submit(event.value)
|
|
252
|
+
self.hide()
|
|
253
|
+
|
|
254
|
+
@on(Input.Blurred)
|
|
255
|
+
def handle_blur(self, event: Input.Blurred) -> None:
|
|
256
|
+
if self._input is None or event.input is not self._input:
|
|
257
|
+
return
|
|
258
|
+
self.hide()
|
|
259
|
+
|
|
260
|
+
def _schedule_filter_apply(self) -> None:
|
|
261
|
+
if self._debounce_timer is not None:
|
|
262
|
+
self._debounce_timer.stop()
|
|
263
|
+
self._debounce_timer = None
|
|
264
|
+
self._debounce_timer = self.set_timer(
|
|
265
|
+
self.DEBOUNCE_DELAY,
|
|
266
|
+
self._apply_pending_filter,
|
|
267
|
+
name="file-tree-filter-debounce",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _apply_pending_filter(self) -> None:
|
|
271
|
+
if self._debounce_timer is not None:
|
|
272
|
+
self._debounce_timer.stop()
|
|
273
|
+
self._debounce_timer = None
|
|
274
|
+
self._state_store.set_filter_query(_filter_query_for_input(self._pending_value))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class FileTree(ListView):
|
|
278
|
+
CHUNK_SIZE = 50
|
|
279
|
+
FILTER_TITLE_MAX = 24
|
|
280
|
+
CHUNK_DEBOUNCE_S = 0.25
|
|
281
|
+
FAST_CURSOR_STEP = 5
|
|
282
|
+
BINDINGS = [
|
|
283
|
+
Binding("enter", "activate_item", "Select directory", show=False),
|
|
284
|
+
Binding("g", "cursor_top", "To top", show=False),
|
|
285
|
+
Binding("G", "cursor_bottom", "To bottom", key_display="G", show=False),
|
|
286
|
+
Binding("k", "cursor_up", "Cursor up", show=False),
|
|
287
|
+
Binding(
|
|
288
|
+
"K", "cursor_up_fast", "Cursor up (half-page)", key_display="K", show=False
|
|
289
|
+
),
|
|
290
|
+
Binding("j", "cursor_down", "Cursor down", show=False),
|
|
291
|
+
Binding(
|
|
292
|
+
"J",
|
|
293
|
+
"cursor_down_fast",
|
|
294
|
+
"Cursor down (half-page)",
|
|
295
|
+
key_display="J",
|
|
296
|
+
show=False,
|
|
297
|
+
),
|
|
298
|
+
Binding("ctrl+t", "open_terminal", "Terminal", show=False),
|
|
299
|
+
Binding(
|
|
300
|
+
"u",
|
|
301
|
+
"go_parent",
|
|
302
|
+
"Go to parent",
|
|
303
|
+
show=True,
|
|
304
|
+
tooltip="Go to parent directory",
|
|
305
|
+
),
|
|
306
|
+
Binding(
|
|
307
|
+
"h",
|
|
308
|
+
"go_home",
|
|
309
|
+
"Go to start",
|
|
310
|
+
show=True,
|
|
311
|
+
tooltip="Go to default startup path",
|
|
312
|
+
),
|
|
313
|
+
Binding(
|
|
314
|
+
"r",
|
|
315
|
+
"rename_entry",
|
|
316
|
+
"Rename",
|
|
317
|
+
show=False,
|
|
318
|
+
tooltip="Rename selected file or directory",
|
|
319
|
+
),
|
|
320
|
+
Binding(
|
|
321
|
+
"n",
|
|
322
|
+
"new_file",
|
|
323
|
+
"New File",
|
|
324
|
+
show=False,
|
|
325
|
+
tooltip="Create new file in current directory",
|
|
326
|
+
),
|
|
327
|
+
Binding(
|
|
328
|
+
"N",
|
|
329
|
+
"new_directory",
|
|
330
|
+
"New Directory",
|
|
331
|
+
key_display="N",
|
|
332
|
+
show=False,
|
|
333
|
+
tooltip="Create new directory in current directory",
|
|
334
|
+
),
|
|
335
|
+
Binding(
|
|
336
|
+
"delete",
|
|
337
|
+
"delete_entry",
|
|
338
|
+
"Delete",
|
|
339
|
+
show=False,
|
|
340
|
+
tooltip="Delete selected file or directory",
|
|
341
|
+
),
|
|
342
|
+
Binding(
|
|
343
|
+
"ctrl+f",
|
|
344
|
+
"open_finder",
|
|
345
|
+
"Open in FS",
|
|
346
|
+
show=True,
|
|
347
|
+
tooltip="Open current directory in system file explorer",
|
|
348
|
+
),
|
|
349
|
+
Binding(
|
|
350
|
+
"ctrl+o",
|
|
351
|
+
"open_selected_file",
|
|
352
|
+
"Open file",
|
|
353
|
+
show=True,
|
|
354
|
+
tooltip="Open selected file with default application",
|
|
355
|
+
),
|
|
356
|
+
Binding(
|
|
357
|
+
"[", "prev_chunk", "Prev chunk", show=False, tooltip="Load previous chunk"
|
|
358
|
+
),
|
|
359
|
+
Binding("]", "next_chunk", "Next chunk", show=False, tooltip="Load next chunk"),
|
|
360
|
+
Binding("/", "filter_entries", "Filter", show=True, tooltip="Filter entries"),
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
def __init__(self, *args, state_store: FileTreeStateStore, **kwargs) -> None:
|
|
364
|
+
super().__init__(*args, initial_index=None, **kwargs)
|
|
365
|
+
self._state_store = state_store
|
|
366
|
+
self._state_subscription = self._handle_state_update
|
|
367
|
+
self._all_entries: list[FileListingEntry] = []
|
|
368
|
+
self._filtered_entries: list[FileListingEntry] = []
|
|
369
|
+
self._filter_query = state_store.state.filter_query
|
|
370
|
+
self._filter_error = False
|
|
371
|
+
self._chunk_start = 0
|
|
372
|
+
self._current_listing_path = state_store.state.current_listing_path
|
|
373
|
+
self._selection_history = dict(state_store.state.selection_history)
|
|
374
|
+
self._last_chunk_direction: str | None = None
|
|
375
|
+
self._pending_delete_index: int | None = None
|
|
376
|
+
self._pending_chunk_delta = 0
|
|
377
|
+
self._chunk_timer: Timer | None = None
|
|
378
|
+
self._listing_changed = False
|
|
379
|
+
self._suppress_focus_once = False
|
|
380
|
+
self._current_chunk_items: dict[Path, FileItem] = {}
|
|
381
|
+
|
|
382
|
+
def set_pending_delete_index(self, index: int | None) -> None:
|
|
383
|
+
self._pending_delete_index = index
|
|
384
|
+
|
|
385
|
+
def on_mount(self) -> None:
|
|
386
|
+
self._state_store.subscribe(self._state_subscription)
|
|
387
|
+
self._update_border_title()
|
|
388
|
+
|
|
389
|
+
def on_unmount(self) -> None:
|
|
390
|
+
self._state_store.unsubscribe(self._state_subscription)
|
|
391
|
+
|
|
392
|
+
def action_go_parent(self) -> None:
|
|
393
|
+
app = cast(AppWithPath, self.app)
|
|
394
|
+
self.post_message(NavigateRequest(app.current_path.parent))
|
|
395
|
+
|
|
396
|
+
def action_go_home(self) -> None:
|
|
397
|
+
self._state_store.clear_selection_history()
|
|
398
|
+
app = cast(AppWithPath, self.app)
|
|
399
|
+
self.post_message(NavigateRequest(app.resolve_startup_path()))
|
|
400
|
+
|
|
401
|
+
def action_open_finder(self) -> None:
|
|
402
|
+
app = cast(AppWithPath, self.app)
|
|
403
|
+
self._open_with_default_app(app.current_path)
|
|
404
|
+
|
|
405
|
+
def action_open_selected_file(self) -> None:
|
|
406
|
+
item = self.highlighted_child
|
|
407
|
+
if not isinstance(item, FileItem) or item.is_header:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
path = item.path
|
|
411
|
+
if path.is_file():
|
|
412
|
+
self._open_with_default_app(path)
|
|
413
|
+
|
|
414
|
+
def _open_with_default_app(self, path: Path) -> None:
|
|
415
|
+
target = str(path)
|
|
416
|
+
|
|
417
|
+
if sys.platform == "darwin":
|
|
418
|
+
if path.is_file() and path.suffix.lower() == ".zip":
|
|
419
|
+
subprocess.run(["open", "-R", target])
|
|
420
|
+
return
|
|
421
|
+
subprocess.run(["open", target])
|
|
422
|
+
elif sys.platform == "win32":
|
|
423
|
+
subprocess.run(["explorer", target])
|
|
424
|
+
else:
|
|
425
|
+
subprocess.run(["xdg-open", target])
|
|
426
|
+
|
|
427
|
+
def _open_terminal_window(self, path: Path) -> None:
|
|
428
|
+
if sys.platform == "darwin":
|
|
429
|
+
subprocess.Popen(["open", "-a", "Terminal", str(path)])
|
|
430
|
+
return
|
|
431
|
+
if sys.platform == "win32":
|
|
432
|
+
for candidate in ("pwsh", "powershell"):
|
|
433
|
+
if shutil.which(candidate):
|
|
434
|
+
subprocess.Popen(["cmd", "/c", "start", "", candidate], cwd=path)
|
|
435
|
+
return
|
|
436
|
+
subprocess.Popen(["cmd", "/c", "start", "", "cmd"], cwd=path)
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
candidates = (
|
|
440
|
+
"x-terminal-emulator",
|
|
441
|
+
"gnome-terminal",
|
|
442
|
+
"konsole",
|
|
443
|
+
"xfce4-terminal",
|
|
444
|
+
"xterm",
|
|
445
|
+
"alacritty",
|
|
446
|
+
"kitty",
|
|
447
|
+
)
|
|
448
|
+
for candidate in candidates:
|
|
449
|
+
if shutil.which(candidate):
|
|
450
|
+
subprocess.Popen([candidate], cwd=path)
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
def _restore_selection(self) -> None:
|
|
454
|
+
should_focus = self._should_focus_after_render()
|
|
455
|
+
pending_index = self._pending_delete_index
|
|
456
|
+
if pending_index is not None:
|
|
457
|
+
self._pending_delete_index = None
|
|
458
|
+
if len(self.children) <= 1:
|
|
459
|
+
self.index = None
|
|
460
|
+
return
|
|
461
|
+
if pending_index < len(self.children):
|
|
462
|
+
self.index = pending_index
|
|
463
|
+
else:
|
|
464
|
+
self.index = len(self.children) - 1
|
|
465
|
+
if should_focus:
|
|
466
|
+
self.focus()
|
|
467
|
+
return
|
|
468
|
+
current_dir = self._current_listing_path
|
|
469
|
+
history_target: Path | None = None
|
|
470
|
+
if current_dir is not None:
|
|
471
|
+
history_target = self._selection_history.get(current_dir)
|
|
472
|
+
|
|
473
|
+
prefer_history = self._listing_changed
|
|
474
|
+
target = self._state_store.state.last_selected_path
|
|
475
|
+
self._listing_changed = False
|
|
476
|
+
|
|
477
|
+
def _select_path(path: Path | None) -> bool:
|
|
478
|
+
if path is None:
|
|
479
|
+
return False
|
|
480
|
+
item = self._current_chunk_items.get(path)
|
|
481
|
+
if item is None:
|
|
482
|
+
return False
|
|
483
|
+
try:
|
|
484
|
+
self.index = self.children.index(item)
|
|
485
|
+
except ValueError:
|
|
486
|
+
return False
|
|
487
|
+
if should_focus:
|
|
488
|
+
self.focus()
|
|
489
|
+
return True
|
|
490
|
+
|
|
491
|
+
if prefer_history and _select_path(history_target):
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
if _select_path(target):
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
if _select_path(history_target):
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
direction = self._last_chunk_direction
|
|
501
|
+
if direction in {"prev", "next"}:
|
|
502
|
+
for idx, child in enumerate(self.children):
|
|
503
|
+
if isinstance(child, FileItem) and not child.is_header:
|
|
504
|
+
self.index = idx
|
|
505
|
+
if should_focus:
|
|
506
|
+
self.focus()
|
|
507
|
+
return
|
|
508
|
+
else:
|
|
509
|
+
for idx, child in enumerate(self.children):
|
|
510
|
+
if isinstance(child, FileItem) and not child.is_header:
|
|
511
|
+
self.index = idx
|
|
512
|
+
if should_focus:
|
|
513
|
+
self.focus()
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
self.index = None
|
|
517
|
+
|
|
518
|
+
def _should_focus_after_render(self) -> bool:
|
|
519
|
+
if self._suppress_focus_once:
|
|
520
|
+
self._suppress_focus_once = False
|
|
521
|
+
return False
|
|
522
|
+
focused = getattr(self.app, "focused", None)
|
|
523
|
+
if isinstance(focused, Input) and focused.id == "file_tree_filter_input":
|
|
524
|
+
return False
|
|
525
|
+
try:
|
|
526
|
+
filter_widget = self.app.query_one(
|
|
527
|
+
"#file_tree_filter", FileTreeFilterWidget
|
|
528
|
+
)
|
|
529
|
+
except Exception:
|
|
530
|
+
return True
|
|
531
|
+
return filter_widget.display != "block"
|
|
532
|
+
|
|
533
|
+
def suppress_focus_once(self) -> None:
|
|
534
|
+
self._suppress_focus_once = True
|
|
535
|
+
|
|
536
|
+
def show_loading(self, path: Path) -> None:
|
|
537
|
+
app = self.app
|
|
538
|
+
with app.batch_update():
|
|
539
|
+
self.clear()
|
|
540
|
+
self._current_chunk_items = {}
|
|
541
|
+
indicator = LoadingIndicator()
|
|
542
|
+
indicator.loading = True
|
|
543
|
+
placeholder = ListItem(indicator, classes="item_loading")
|
|
544
|
+
placeholder.can_focus = False
|
|
545
|
+
self.append(placeholder)
|
|
546
|
+
|
|
547
|
+
def show_error(self, path: Path, message: str) -> None:
|
|
548
|
+
app = self.app
|
|
549
|
+
with app.batch_update():
|
|
550
|
+
self.clear()
|
|
551
|
+
self._current_chunk_items = {}
|
|
552
|
+
notice = ListItem(
|
|
553
|
+
Label(message, classes="file_tree_error"), classes="item_error"
|
|
554
|
+
)
|
|
555
|
+
notice.can_focus = False
|
|
556
|
+
self.append(notice)
|
|
557
|
+
|
|
558
|
+
def show_listing(self, path: Path, entries: Sequence[FileListingEntry]) -> None:
|
|
559
|
+
app = self.app
|
|
560
|
+
with app.batch_update():
|
|
561
|
+
previous_path = self._current_listing_path
|
|
562
|
+
self._state_store.set_current_listing_path(path)
|
|
563
|
+
self._current_listing_path = path
|
|
564
|
+
self._listing_changed = previous_path != path
|
|
565
|
+
self._all_entries = list(entries)
|
|
566
|
+
if previous_path != path:
|
|
567
|
+
self._state_store.set_last_selected_path(None)
|
|
568
|
+
self._set_filter("")
|
|
569
|
+
self._apply_filter()
|
|
570
|
+
self._update_border_title()
|
|
571
|
+
self._chunk_start = 0
|
|
572
|
+
self._last_chunk_direction = None
|
|
573
|
+
self._render_current_chunk()
|
|
574
|
+
|
|
575
|
+
def _apply_filter(self) -> None:
|
|
576
|
+
self._filter_error = False
|
|
577
|
+
if not self._filter_query:
|
|
578
|
+
self._filtered_entries = self._all_entries
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
if self._filter_query.startswith("/"):
|
|
582
|
+
pattern = self._filter_query[1:]
|
|
583
|
+
if not pattern:
|
|
584
|
+
self._filtered_entries = self._all_entries
|
|
585
|
+
return
|
|
586
|
+
try:
|
|
587
|
+
matcher = re.compile(pattern, re.IGNORECASE)
|
|
588
|
+
except re.error:
|
|
589
|
+
self._filter_error = True
|
|
590
|
+
self._filtered_entries = []
|
|
591
|
+
return
|
|
592
|
+
self._filtered_entries = [
|
|
593
|
+
entry for entry in self._all_entries if matcher.search(entry.path.name)
|
|
594
|
+
]
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
query = self._filter_query.casefold()
|
|
598
|
+
self._filtered_entries = [
|
|
599
|
+
entry for entry in self._all_entries if query in entry.search_blob
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
def handle_filter_submit(self, value: str) -> None:
|
|
603
|
+
parsed = self._parse_replace_request(value)
|
|
604
|
+
if parsed is None:
|
|
605
|
+
self._set_filter(_filter_query_for_input(value))
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
filter_query, pattern, replacement, is_regex = parsed
|
|
609
|
+
self._set_filter(filter_query)
|
|
610
|
+
self._confirm_replace(pattern, replacement, is_regex)
|
|
611
|
+
|
|
612
|
+
def _parse_replace_request(self, value: str) -> tuple[str, str, str, bool] | None:
|
|
613
|
+
parsed = _split_replace_input(value)
|
|
614
|
+
if parsed is None:
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
mode, pattern, replacement = parsed
|
|
618
|
+
pattern = pattern.strip()
|
|
619
|
+
if not pattern:
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
if mode == "regex":
|
|
623
|
+
filter_query = f"/{pattern}"
|
|
624
|
+
return filter_query, pattern, replacement, True
|
|
625
|
+
|
|
626
|
+
return pattern, pattern, replacement, False
|
|
627
|
+
|
|
628
|
+
def _confirm_replace(self, pattern: str, replacement: str, is_regex: bool) -> None:
|
|
629
|
+
app = cast("Ferp", self.app)
|
|
630
|
+
if is_regex:
|
|
631
|
+
try:
|
|
632
|
+
matcher = re.compile(pattern, re.IGNORECASE)
|
|
633
|
+
except re.error as exc:
|
|
634
|
+
app.show_error(exc)
|
|
635
|
+
return
|
|
636
|
+
else:
|
|
637
|
+
matcher = re.compile(re.escape(pattern), re.IGNORECASE)
|
|
638
|
+
replacement = _escape_regex_replacement(replacement)
|
|
639
|
+
|
|
640
|
+
# Regex replacements must use Python's \g<name> or \g<number> syntax.
|
|
641
|
+
|
|
642
|
+
sources = {entry.path for entry in self._filtered_entries if not entry.is_dir}
|
|
643
|
+
if not sources:
|
|
644
|
+
app.show_error(RuntimeError("No files match the current filter."))
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
plan: list[tuple[Path, Path]] = []
|
|
648
|
+
invalid: list[str] = []
|
|
649
|
+
conflicts: list[str] = []
|
|
650
|
+
planned_targets: dict[str, str] = {}
|
|
651
|
+
|
|
652
|
+
for entry in self._filtered_entries:
|
|
653
|
+
if entry.is_dir:
|
|
654
|
+
continue
|
|
655
|
+
name = entry.path.name
|
|
656
|
+
stem, _suffix = _split_stem_suffix(name)
|
|
657
|
+
new_name, new_stem, count = _replace_in_stem(
|
|
658
|
+
name, matcher=matcher, replacement=replacement
|
|
659
|
+
)
|
|
660
|
+
if count == 0 or new_stem == stem:
|
|
661
|
+
continue
|
|
662
|
+
if not new_stem:
|
|
663
|
+
invalid.append(f"{name} -> (empty name)")
|
|
664
|
+
continue
|
|
665
|
+
if Path(new_name).name != new_name:
|
|
666
|
+
invalid.append(f"{name} -> {new_name}")
|
|
667
|
+
continue
|
|
668
|
+
if new_name in planned_targets:
|
|
669
|
+
conflicts.append(
|
|
670
|
+
f"{name} -> {new_name} (already used by {planned_targets[new_name]})"
|
|
671
|
+
)
|
|
672
|
+
continue
|
|
673
|
+
destination = entry.path.with_name(new_name)
|
|
674
|
+
if destination.exists() and destination != entry.path:
|
|
675
|
+
conflicts.append(f"{name} -> {new_name} (already exists)")
|
|
676
|
+
continue
|
|
677
|
+
if destination in sources and destination != entry.path:
|
|
678
|
+
conflicts.append(f"{name} -> {new_name} (conflicts with another file)")
|
|
679
|
+
continue
|
|
680
|
+
planned_targets[new_name] = name
|
|
681
|
+
plan.append((entry.path, destination))
|
|
682
|
+
|
|
683
|
+
if not plan:
|
|
684
|
+
app.show_error(RuntimeError("No files would be renamed."))
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
if invalid or conflicts:
|
|
688
|
+
details = []
|
|
689
|
+
if invalid:
|
|
690
|
+
details.append("Invalid names:\n" + "\n".join(invalid[:5]))
|
|
691
|
+
if conflicts:
|
|
692
|
+
details.append("Conflicts:\n" + "\n".join(conflicts[:5]))
|
|
693
|
+
message = "Cannot complete replace.\n" + "\n".join(details)
|
|
694
|
+
app.show_error(RuntimeError(message))
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
preview_rows = [(src.name, dest.name) for src, dest in plan[:5]]
|
|
698
|
+
more_count = max(len(plan) - len(preview_rows), 0)
|
|
699
|
+
mode = "regex" if is_regex else "text"
|
|
700
|
+
title = f"Rename {len(plan)} file(s) using {mode} replace?"
|
|
701
|
+
body = preview_rows
|
|
702
|
+
|
|
703
|
+
def after(confirmed: bool | None) -> None:
|
|
704
|
+
if not confirmed:
|
|
705
|
+
return
|
|
706
|
+
self._set_filter("")
|
|
707
|
+
app._stop_file_tree_watch()
|
|
708
|
+
self.show_loading(app.current_path)
|
|
709
|
+
app.notify(f"Renaming {len(plan)} file(s)...", timeout=2)
|
|
710
|
+
app.run_worker(
|
|
711
|
+
lambda rename_plan=plan: self._bulk_rename_worker(rename_plan),
|
|
712
|
+
group="bulk_rename",
|
|
713
|
+
thread=True,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
app.push_screen(BulkRenameConfirmDialog(title, body, more_count), after)
|
|
717
|
+
|
|
718
|
+
def _bulk_rename_worker(self, plan: list[tuple[Path, Path]]) -> dict[str, object]:
|
|
719
|
+
errors: list[str] = []
|
|
720
|
+
app = cast("Ferp", self.app)
|
|
721
|
+
for source, destination in plan:
|
|
722
|
+
try:
|
|
723
|
+
app.fs_controller.rename_path(
|
|
724
|
+
source,
|
|
725
|
+
destination,
|
|
726
|
+
overwrite=False,
|
|
727
|
+
)
|
|
728
|
+
except Exception as exc: # pragma: no cover - UI path
|
|
729
|
+
errors.append(f"{source.name}: {exc}")
|
|
730
|
+
return {"count": len(plan), "errors": errors}
|
|
731
|
+
|
|
732
|
+
def _set_filter(self, value: str, *, from_store: bool = False) -> None:
|
|
733
|
+
query = value.strip()
|
|
734
|
+
if query == self._filter_query:
|
|
735
|
+
return
|
|
736
|
+
self._filter_query = query
|
|
737
|
+
if not from_store:
|
|
738
|
+
self._state_store.set_filter_query(self._filter_query)
|
|
739
|
+
self._apply_filter()
|
|
740
|
+
self._update_border_title()
|
|
741
|
+
self._chunk_start = 0
|
|
742
|
+
self._last_chunk_direction = None
|
|
743
|
+
self._render_current_chunk()
|
|
744
|
+
|
|
745
|
+
def handle_filter_preview(self, value: str) -> None:
|
|
746
|
+
self._set_filter(_filter_query_for_input(value))
|
|
747
|
+
|
|
748
|
+
def _update_border_title(self) -> None:
|
|
749
|
+
title = "File Navigator"
|
|
750
|
+
if self._filter_query:
|
|
751
|
+
truncated = self._filter_query
|
|
752
|
+
if len(truncated) > self.FILTER_TITLE_MAX:
|
|
753
|
+
truncated = f"{truncated[: self.FILTER_TITLE_MAX - 3]}..."
|
|
754
|
+
if self._filter_error:
|
|
755
|
+
title = f'{title} (filter: "{truncated}" - invalid regex)'
|
|
756
|
+
else:
|
|
757
|
+
title = f'{title} (filter: "{truncated}")'
|
|
758
|
+
try:
|
|
759
|
+
container = self.app.query_one("#file_list_container")
|
|
760
|
+
except Exception:
|
|
761
|
+
self.border_title = title
|
|
762
|
+
else:
|
|
763
|
+
container.border_title = title
|
|
764
|
+
|
|
765
|
+
def action_filter_entries(self) -> None:
|
|
766
|
+
try:
|
|
767
|
+
filter_widget = self.app.query_one(
|
|
768
|
+
"#file_tree_filter", FileTreeFilterWidget
|
|
769
|
+
)
|
|
770
|
+
except Exception:
|
|
771
|
+
return
|
|
772
|
+
filter_widget.show(self._filter_query)
|
|
773
|
+
|
|
774
|
+
def _handle_state_update(self, state: FileTreeState) -> None:
|
|
775
|
+
self._current_listing_path = state.current_listing_path
|
|
776
|
+
self._selection_history = dict(state.selection_history)
|
|
777
|
+
if state.filter_query == self._filter_query:
|
|
778
|
+
return
|
|
779
|
+
self._set_filter(state.filter_query, from_store=True)
|
|
780
|
+
|
|
781
|
+
def _append_notice(self, message: str) -> None:
|
|
782
|
+
notice = ListItem(
|
|
783
|
+
Label(message, classes="file_tree_notice"), classes="item_notice"
|
|
784
|
+
)
|
|
785
|
+
notice.can_focus = False
|
|
786
|
+
self.append(notice)
|
|
787
|
+
|
|
788
|
+
def _render_current_chunk(self) -> None:
|
|
789
|
+
path = self._current_listing_path
|
|
790
|
+
if path is None:
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
app = self.app
|
|
794
|
+
with app.batch_update():
|
|
795
|
+
self.scroll_to(y=0, animate=False)
|
|
796
|
+
self.clear()
|
|
797
|
+
self._current_chunk_items = {}
|
|
798
|
+
|
|
799
|
+
total = len(self._filtered_entries)
|
|
800
|
+
if total == 0:
|
|
801
|
+
if self._all_entries:
|
|
802
|
+
self._append_notice("No items match the current filter.")
|
|
803
|
+
else:
|
|
804
|
+
self._append_notice("No files in this directory.")
|
|
805
|
+
self.call_after_refresh(self._restore_selection)
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
max_start = (
|
|
809
|
+
0 if total == 0 else (total - 1) // self.CHUNK_SIZE * self.CHUNK_SIZE
|
|
810
|
+
)
|
|
811
|
+
start = max(0, min(self._chunk_start, max_start))
|
|
812
|
+
self._chunk_start = start
|
|
813
|
+
end = min(start + self.CHUNK_SIZE, total)
|
|
814
|
+
if start > 0:
|
|
815
|
+
prev_start = max(0, start - self.CHUNK_SIZE)
|
|
816
|
+
prev_end = start
|
|
817
|
+
prev_label = f"Show previous {self.CHUNK_SIZE} (items {prev_start + 1}-{prev_end})"
|
|
818
|
+
self.append(ChunkNavigatorItem(prev_label, direction="prev"))
|
|
819
|
+
|
|
820
|
+
for entry in self._filtered_entries[start:end]:
|
|
821
|
+
classes = "item_dir" if entry.is_dir else "item_file"
|
|
822
|
+
item = FileItem(entry.path, metadata=entry, classes=classes)
|
|
823
|
+
self.append(item)
|
|
824
|
+
self._current_chunk_items[entry.path] = item
|
|
825
|
+
|
|
826
|
+
if end < total:
|
|
827
|
+
next_end = min(total, end + self.CHUNK_SIZE)
|
|
828
|
+
next_label = (
|
|
829
|
+
f"Showing items {start + 1}-{end} of {total}. "
|
|
830
|
+
f"Press Enter to load {end + 1}-{next_end}"
|
|
831
|
+
)
|
|
832
|
+
self.append(ChunkNavigatorItem(next_label, direction="next"))
|
|
833
|
+
|
|
834
|
+
self.call_after_refresh(self._restore_selection)
|
|
835
|
+
|
|
836
|
+
@on(ListView.Highlighted)
|
|
837
|
+
def emit_highlight(self, event: ListView.Highlighted) -> None:
|
|
838
|
+
item = event.item
|
|
839
|
+
|
|
840
|
+
if isinstance(item, FileItem) and not item.is_header:
|
|
841
|
+
self._state_store.set_last_selected_path(item.path)
|
|
842
|
+
else:
|
|
843
|
+
pass
|
|
844
|
+
|
|
845
|
+
@on(ListView.Selected)
|
|
846
|
+
def emit_selection(self, event: ListView.Selected) -> None:
|
|
847
|
+
item = event.item
|
|
848
|
+
if isinstance(item, ChunkNavigatorItem):
|
|
849
|
+
total = len(self._filtered_entries)
|
|
850
|
+
if total == 0:
|
|
851
|
+
return
|
|
852
|
+
delta = -1 if item.direction == "prev" else 1
|
|
853
|
+
self._schedule_chunk_move(delta)
|
|
854
|
+
|
|
855
|
+
def action_activate_item(self) -> None:
|
|
856
|
+
item = self.highlighted_child
|
|
857
|
+
if isinstance(item, FileItem) and not item.is_header:
|
|
858
|
+
self._activate_item(item)
|
|
859
|
+
return
|
|
860
|
+
if isinstance(item, ChunkNavigatorItem):
|
|
861
|
+
self._activate_chunk_item(item)
|
|
862
|
+
|
|
863
|
+
def _activate_item(self, item: FileItem) -> None:
|
|
864
|
+
if not item.path.is_dir():
|
|
865
|
+
return
|
|
866
|
+
if self._current_listing_path is not None:
|
|
867
|
+
self._state_store.update_selection_history(
|
|
868
|
+
self._current_listing_path, item.path
|
|
869
|
+
)
|
|
870
|
+
self._state_store.set_last_selected_path(None)
|
|
871
|
+
self.post_message(DirectorySelectRequest(item.path))
|
|
872
|
+
|
|
873
|
+
def _activate_chunk_item(self, item: ChunkNavigatorItem) -> None:
|
|
874
|
+
total = len(self._filtered_entries)
|
|
875
|
+
if total == 0:
|
|
876
|
+
return
|
|
877
|
+
if item.direction == "prev":
|
|
878
|
+
self._chunk_start = max(0, self._chunk_start - self.CHUNK_SIZE)
|
|
879
|
+
self._last_chunk_direction = "prev"
|
|
880
|
+
else:
|
|
881
|
+
max_start = (total - 1) // self.CHUNK_SIZE * self.CHUNK_SIZE
|
|
882
|
+
self._chunk_start = min(self._chunk_start + self.CHUNK_SIZE, max_start)
|
|
883
|
+
self._last_chunk_direction = "next"
|
|
884
|
+
self._render_current_chunk()
|
|
885
|
+
self._state_store.set_last_selected_path(None)
|
|
886
|
+
|
|
887
|
+
def action_prev_chunk(self) -> None:
|
|
888
|
+
self._schedule_chunk_move(-1)
|
|
889
|
+
|
|
890
|
+
def action_next_chunk(self) -> None:
|
|
891
|
+
self._schedule_chunk_move(1)
|
|
892
|
+
|
|
893
|
+
def _schedule_chunk_move(self, delta: int) -> None:
|
|
894
|
+
self._pending_chunk_delta += delta
|
|
895
|
+
if self._chunk_timer is not None:
|
|
896
|
+
self._chunk_timer.stop()
|
|
897
|
+
self._chunk_timer = None
|
|
898
|
+
self._chunk_timer = self.set_timer(
|
|
899
|
+
self.CHUNK_DEBOUNCE_S,
|
|
900
|
+
self._apply_pending_chunk_move,
|
|
901
|
+
name="file-tree-chunk-debounce",
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def _apply_pending_chunk_move(self) -> None:
|
|
905
|
+
if self._chunk_timer is not None:
|
|
906
|
+
self._chunk_timer.stop()
|
|
907
|
+
self._chunk_timer = None
|
|
908
|
+
delta = self._pending_chunk_delta
|
|
909
|
+
self._pending_chunk_delta = 0
|
|
910
|
+
if delta == 0:
|
|
911
|
+
return
|
|
912
|
+
total = len(self._filtered_entries)
|
|
913
|
+
if total == 0:
|
|
914
|
+
return
|
|
915
|
+
if total <= self.CHUNK_SIZE:
|
|
916
|
+
return
|
|
917
|
+
max_start = (total - 1) // self.CHUNK_SIZE * self.CHUNK_SIZE
|
|
918
|
+
next_start = self._chunk_start + (delta * self.CHUNK_SIZE)
|
|
919
|
+
next_start = max(0, min(next_start, max_start))
|
|
920
|
+
if next_start == self._chunk_start:
|
|
921
|
+
return
|
|
922
|
+
self._chunk_start = next_start
|
|
923
|
+
self._last_chunk_direction = "next" if delta > 0 else "prev"
|
|
924
|
+
self._render_current_chunk()
|
|
925
|
+
|
|
926
|
+
def action_cursor_down(self) -> None:
|
|
927
|
+
super().action_cursor_down()
|
|
928
|
+
|
|
929
|
+
def action_cursor_up(self) -> None:
|
|
930
|
+
super().action_cursor_up()
|
|
931
|
+
|
|
932
|
+
def action_cursor_down_fast(self) -> None:
|
|
933
|
+
for _ in range(self.FAST_CURSOR_STEP):
|
|
934
|
+
super().action_cursor_down()
|
|
935
|
+
|
|
936
|
+
def action_cursor_up_fast(self) -> None:
|
|
937
|
+
for _ in range(self.FAST_CURSOR_STEP):
|
|
938
|
+
super().action_cursor_up()
|
|
939
|
+
|
|
940
|
+
def action_cursor_top(self) -> None:
|
|
941
|
+
if self.children:
|
|
942
|
+
self.index = 0
|
|
943
|
+
self.scroll_to(y=0)
|
|
944
|
+
|
|
945
|
+
def action_cursor_bottom(self) -> None:
|
|
946
|
+
if self.children:
|
|
947
|
+
self.index = len(self.children) - 1
|
|
948
|
+
|
|
949
|
+
def _visible_item_count(self) -> int:
|
|
950
|
+
if not self.children:
|
|
951
|
+
return 0
|
|
952
|
+
|
|
953
|
+
first = self.children[0]
|
|
954
|
+
row_height = first.size.height
|
|
955
|
+
|
|
956
|
+
if row_height <= 0:
|
|
957
|
+
return 0
|
|
958
|
+
|
|
959
|
+
return self.size.height // row_height
|
|
960
|
+
|
|
961
|
+
def _selected_path(self) -> Path | None:
|
|
962
|
+
item = self.highlighted_child
|
|
963
|
+
if isinstance(item, FileItem) and not item.is_header:
|
|
964
|
+
return item.path
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
def action_new_file(self) -> None:
|
|
968
|
+
app = cast(AppWithPath, self.app)
|
|
969
|
+
base = app.current_path
|
|
970
|
+
self.post_message(CreatePathRequest(base, is_directory=False))
|
|
971
|
+
|
|
972
|
+
def action_new_directory(self) -> None:
|
|
973
|
+
app = cast(AppWithPath, self.app)
|
|
974
|
+
base = app.current_path
|
|
975
|
+
self.post_message(CreatePathRequest(base, is_directory=True))
|
|
976
|
+
|
|
977
|
+
def action_delete_entry(self) -> None:
|
|
978
|
+
path = self._selected_path()
|
|
979
|
+
if path is None:
|
|
980
|
+
return
|
|
981
|
+
self.post_message(DeletePathRequest(path))
|
|
982
|
+
|
|
983
|
+
def action_rename_entry(self) -> None:
|
|
984
|
+
path = self._selected_path()
|
|
985
|
+
if path is None:
|
|
986
|
+
return
|
|
987
|
+
self.post_message(RenamePathRequest(path))
|
|
988
|
+
|
|
989
|
+
def action_open_terminal(self) -> None:
|
|
990
|
+
app = cast(AppWithPath, self.app)
|
|
991
|
+
self._open_terminal_window(app.current_path)
|