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.
Files changed (87) hide show
  1. ferp/__init__.py +3 -0
  2. ferp/__main__.py +4 -0
  3. ferp/__version__.py +1 -0
  4. ferp/app.py +9 -0
  5. ferp/cli.py +160 -0
  6. ferp/core/__init__.py +0 -0
  7. ferp/core/app.py +1312 -0
  8. ferp/core/bundle_installer.py +245 -0
  9. ferp/core/command_provider.py +77 -0
  10. ferp/core/dependency_manager.py +59 -0
  11. ferp/core/fs_controller.py +70 -0
  12. ferp/core/fs_watcher.py +144 -0
  13. ferp/core/messages.py +49 -0
  14. ferp/core/path_actions.py +124 -0
  15. ferp/core/paths.py +3 -0
  16. ferp/core/protocols.py +8 -0
  17. ferp/core/script_controller.py +515 -0
  18. ferp/core/script_protocol.py +35 -0
  19. ferp/core/script_runner.py +421 -0
  20. ferp/core/settings.py +16 -0
  21. ferp/core/settings_store.py +69 -0
  22. ferp/core/state.py +156 -0
  23. ferp/core/task_store.py +164 -0
  24. ferp/core/transcript_logger.py +95 -0
  25. ferp/domain/__init__.py +0 -0
  26. ferp/domain/scripts.py +29 -0
  27. ferp/fscp/host/__init__.py +11 -0
  28. ferp/fscp/host/host.py +439 -0
  29. ferp/fscp/host/managed_process.py +113 -0
  30. ferp/fscp/host/process_registry.py +124 -0
  31. ferp/fscp/protocol/__init__.py +13 -0
  32. ferp/fscp/protocol/errors.py +2 -0
  33. ferp/fscp/protocol/messages.py +55 -0
  34. ferp/fscp/protocol/schemas/__init__.py +0 -0
  35. ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
  36. ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
  37. ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
  38. ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
  39. ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
  40. ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
  41. ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
  42. ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
  43. ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
  44. ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
  45. ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
  46. ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
  47. ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
  48. ferp/fscp/protocol/state.py +16 -0
  49. ferp/fscp/protocol/validator.py +123 -0
  50. ferp/fscp/scripts/__init__.py +0 -0
  51. ferp/fscp/scripts/runtime/__init__.py +4 -0
  52. ferp/fscp/scripts/runtime/__main__.py +40 -0
  53. ferp/fscp/scripts/runtime/errors.py +14 -0
  54. ferp/fscp/scripts/runtime/io.py +64 -0
  55. ferp/fscp/scripts/runtime/script.py +149 -0
  56. ferp/fscp/scripts/runtime/state.py +17 -0
  57. ferp/fscp/scripts/runtime/worker.py +13 -0
  58. ferp/fscp/scripts/sdk.py +548 -0
  59. ferp/fscp/transcript/__init__.py +3 -0
  60. ferp/fscp/transcript/events.py +14 -0
  61. ferp/resources/__init__.py +0 -0
  62. ferp/services/__init__.py +3 -0
  63. ferp/services/file_listing.py +120 -0
  64. ferp/services/monday_sync.py +155 -0
  65. ferp/services/releases.py +214 -0
  66. ferp/services/scripts.py +90 -0
  67. ferp/services/update_check.py +130 -0
  68. ferp/styles/index.tcss +638 -0
  69. ferp/themes/themes.py +238 -0
  70. ferp/widgets/__init__.py +17 -0
  71. ferp/widgets/dialogs.py +167 -0
  72. ferp/widgets/file_tree.py +991 -0
  73. ferp/widgets/forms.py +146 -0
  74. ferp/widgets/output_panel.py +244 -0
  75. ferp/widgets/panels.py +13 -0
  76. ferp/widgets/process_list.py +158 -0
  77. ferp/widgets/readme_modal.py +59 -0
  78. ferp/widgets/scripts.py +192 -0
  79. ferp/widgets/task_capture.py +74 -0
  80. ferp/widgets/task_list.py +493 -0
  81. ferp/widgets/top_bar.py +110 -0
  82. ferp-0.7.1.dist-info/METADATA +128 -0
  83. ferp-0.7.1.dist-info/RECORD +87 -0
  84. ferp-0.7.1.dist-info/WHEEL +5 -0
  85. ferp-0.7.1.dist-info/entry_points.txt +2 -0
  86. ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
  87. 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)