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
ferp/core/app.py ADDED
@@ -0,0 +1,1312 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Sequence
12
+
13
+ from platformdirs import user_cache_path, user_config_path, user_data_path
14
+ from rich.markup import escape
15
+ from textual import on
16
+ from textual.app import App, ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.containers import Horizontal, Vertical, VerticalScroll
19
+ from textual.css.query import NoMatches
20
+ from textual.theme import Theme
21
+ from textual.timer import Timer
22
+ from textual.widgets import Footer
23
+ from textual.worker import Worker, WorkerState
24
+
25
+ from ferp import __version__
26
+ from ferp.core.bundle_installer import ScriptBundleInstaller
27
+ from ferp.core.command_provider import FerpCommandProvider
28
+ from ferp.core.dependency_manager import ScriptDependencyManager
29
+ from ferp.core.fs_controller import FileSystemController
30
+ from ferp.core.fs_watcher import FileTreeWatcher
31
+ from ferp.core.messages import (
32
+ CreatePathRequest,
33
+ DeletePathRequest,
34
+ DirectorySelectRequest,
35
+ NavigateRequest,
36
+ RenamePathRequest,
37
+ RunScriptRequest,
38
+ ShowReadmeRequest,
39
+ )
40
+ from ferp.core.path_actions import PathActionController
41
+ from ferp.core.paths import APP_AUTHOR, APP_NAME, SCRIPTS_REPO_URL
42
+ from ferp.core.script_controller import ScriptLifecycleController
43
+ from ferp.core.script_runner import ScriptResult
44
+ from ferp.core.settings_store import SettingsStore
45
+ from ferp.core.state import AppStateStore, FileTreeStateStore, TaskListStateStore
46
+ from ferp.core.task_store import Task, TaskStore
47
+ from ferp.core.transcript_logger import TranscriptLogger
48
+ from ferp.fscp.host.process_registry import ProcessRecord
49
+ from ferp.services.file_listing import (
50
+ DirectoryListingResult,
51
+ collect_directory_listing,
52
+ snapshot_directory,
53
+ )
54
+ from ferp.services.monday_sync import sync_monday_board
55
+ from ferp.services.releases import (
56
+ fetch_namespace_index,
57
+ update_scripts_from_namespace_release,
58
+ )
59
+ from ferp.services.scripts import build_execution_context
60
+ from ferp.services.update_check import UpdateCheckResult, check_for_update
61
+ from ferp.themes.themes import ALL_THEMES
62
+ from ferp.widgets.dialogs import ConfirmDialog, InputDialog, SelectDialog
63
+ from ferp.widgets.file_tree import FileTree, FileTreeFilterWidget, FileTreeHeader
64
+ from ferp.widgets.output_panel import ScriptOutputPanel
65
+ from ferp.widgets.process_list import ProcessListScreen
66
+ from ferp.widgets.readme_modal import ReadmeScreen
67
+ from ferp.widgets.scripts import ScriptManager
68
+ from ferp.widgets.task_list import TaskListScreen
69
+ from ferp.widgets.top_bar import TopBar
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class AppPaths:
74
+ app_root: Path
75
+ config_dir: Path
76
+ config_file: Path
77
+ settings_file: Path
78
+ data_dir: Path
79
+ cache_dir: Path
80
+ logs_dir: Path
81
+ tasks_file: Path
82
+ scripts_dir: Path
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class DeletePathResult:
87
+ target: Path
88
+ error: str | None = None
89
+
90
+
91
+ DEFAULT_SETTINGS: dict[str, Any] = {
92
+ "userPreferences": {"theme": "slate-copper", "startupPath": str(Path().home())},
93
+ "logs": {"maxFiles": 50, "maxAgeDays": 14},
94
+ "integrations": {
95
+ "monday": {
96
+ "apiToken": "",
97
+ "boardId": "9752384724",
98
+ }
99
+ },
100
+ }
101
+
102
+
103
+ class Ferp(App):
104
+ TITLE = "ferp"
105
+ CSS_PATH = Path(__file__).parent.parent / "styles" / "index.tcss"
106
+ COMMANDS = App.COMMANDS | {FerpCommandProvider}
107
+
108
+ BINDINGS = [
109
+ Binding(
110
+ "l", "show_task_list", "Show tasks", show=False, tooltip="Show task list"
111
+ ),
112
+ Binding(
113
+ "t", "capture_task", "Add task", show=False, tooltip="Capture new task"
114
+ ),
115
+ Binding(
116
+ "m",
117
+ "toggle_maximize",
118
+ "Maximize",
119
+ show=False,
120
+ tooltip="Maximize/minimize the focused widget",
121
+ ),
122
+ Binding(
123
+ "?",
124
+ "toggle_help",
125
+ "Toggle all keys",
126
+ show=True,
127
+ tooltip="Show/hide help panel",
128
+ ),
129
+ ]
130
+
131
+ @property
132
+ def current_path(self) -> Path:
133
+ value = self.state_store.state.current_path
134
+ return Path(value) if value else Path()
135
+
136
+ @current_path.setter
137
+ def current_path(self, value: Path) -> None:
138
+ self.state_store.set_current_path(str(value))
139
+
140
+ def __init__(self, start_path: Path | None = None) -> None:
141
+ self._paths = self._prepare_paths()
142
+ self.app_root = self._paths.app_root
143
+ self._dev_config_enabled = os.environ.get("FERP_DEV_CONFIG") == "1"
144
+ self.settings_store = SettingsStore(self._paths.settings_file)
145
+ self.settings = self.settings_store.load()
146
+ self.state_store = AppStateStore()
147
+ initial_path = self._resolve_start_path(start_path)
148
+ self.state_store.set_current_path(str(initial_path))
149
+ self.file_tree_store = FileTreeStateStore()
150
+ self.task_list_store = TaskListStateStore()
151
+ self.scripts_dir = self._paths.scripts_dir
152
+ self.task_store = TaskStore(self._paths.tasks_file)
153
+ self._pending_task_totals: tuple[int, int] = (0, 0)
154
+ self._directory_listing_token = 0
155
+ self._listing_in_progress = False
156
+ self._pending_navigation_path: Path | None = None
157
+ self._pending_refresh = False
158
+ self._refresh_timer: Timer | None = None
159
+ self._task_list_screen: TaskListScreen | None = None
160
+ self._process_list_screen: ProcessListScreen | None = None
161
+ self._pending_exit = False
162
+ self._is_shutting_down = False
163
+ super().__init__()
164
+ self.fs_controller = FileSystemController()
165
+ self._file_tree_watcher = FileTreeWatcher(
166
+ call_from_thread=self.call_from_thread,
167
+ refresh_callback=self._refresh_listing_from_watcher,
168
+ missing_callback=self._handle_missing_directory,
169
+ snapshot_func=snapshot_directory,
170
+ timer_factory=self.set_timer,
171
+ )
172
+ self.script_controller = ScriptLifecycleController(self)
173
+ self.transcript_logger = TranscriptLogger(
174
+ self._paths.logs_dir,
175
+ lambda: self.settings_store.log_preferences(self.settings),
176
+ )
177
+ self.bundle_installer = ScriptBundleInstaller(self)
178
+ self.path_actions = PathActionController(
179
+ present_input=self._present_input_dialog,
180
+ present_confirm=self._present_confirm_dialog,
181
+ show_error=self.show_error,
182
+ refresh_listing=self.schedule_refresh_listing,
183
+ fs_controller=self.fs_controller,
184
+ delete_handler=self._start_delete_path,
185
+ )
186
+
187
+ def _prepare_paths(self) -> AppPaths:
188
+ app_root = Path(__file__).parent.parent
189
+ config_dir = Path(user_config_path(APP_NAME, APP_AUTHOR))
190
+ dev_config_enabled = os.environ.get("FERP_DEV_CONFIG") == "1"
191
+ config_file = (
192
+ app_root / "scripts" / "config.json"
193
+ if dev_config_enabled
194
+ else config_dir / "config.json"
195
+ )
196
+ settings_file = config_dir / "settings.json"
197
+ data_dir = Path(user_data_path(APP_NAME, APP_AUTHOR))
198
+ cache_dir = Path(user_cache_path(APP_NAME, APP_AUTHOR))
199
+ logs_dir = data_dir / "logs"
200
+ tasks_file = cache_dir / "tasks.json"
201
+ scripts_dir = app_root / "scripts"
202
+
203
+ for directory in (config_dir, data_dir, cache_dir, logs_dir, scripts_dir):
204
+ directory.mkdir(parents=True, exist_ok=True)
205
+
206
+ default_config_file = app_root / "scripts" / "config.json"
207
+
208
+ if not config_file.exists() and not dev_config_enabled:
209
+ if default_config_file.exists():
210
+ config_file.write_text(
211
+ default_config_file.read_text(encoding="utf-8"),
212
+ encoding="utf-8",
213
+ )
214
+ else:
215
+ config_file.write_text(
216
+ json.dumps({"scripts": []}, indent=2) + "\n",
217
+ encoding="utf-8",
218
+ )
219
+ if not tasks_file.exists():
220
+ tasks_file.write_text("[]", encoding="utf-8")
221
+ if not settings_file.exists():
222
+ settings_file.write_text(
223
+ json.dumps(DEFAULT_SETTINGS, indent=4),
224
+ encoding="utf-8",
225
+ )
226
+
227
+ return AppPaths(
228
+ app_root=app_root,
229
+ config_dir=config_dir,
230
+ config_file=config_file,
231
+ settings_file=settings_file,
232
+ data_dir=data_dir,
233
+ cache_dir=cache_dir,
234
+ logs_dir=logs_dir,
235
+ tasks_file=tasks_file,
236
+ scripts_dir=scripts_dir,
237
+ )
238
+
239
+ def _resolve_start_path(self, start_path: Path | None) -> Path:
240
+ def normalize(candidate: Path | str | None) -> Path | None:
241
+ if candidate is None:
242
+ return None
243
+ try:
244
+ return Path(candidate).expanduser()
245
+ except (TypeError, ValueError):
246
+ return None
247
+
248
+ preferences = self.settings.get("userPreferences", {})
249
+ candidates = [
250
+ normalize(start_path),
251
+ normalize(preferences.get("startupPath")),
252
+ Path.home(),
253
+ ]
254
+
255
+ for candidate in candidates:
256
+ if candidate and candidate.exists():
257
+ return candidate
258
+
259
+ return Path.home()
260
+
261
+ def resolve_startup_path(self) -> Path:
262
+ return self._resolve_start_path(None)
263
+
264
+ def compose(self) -> ComposeResult:
265
+ output_panel = ScriptOutputPanel(state_store=self.state_store)
266
+ scroll_container = VerticalScroll(
267
+ output_panel, can_focus=True, id="output_panel_container", can_maximize=True
268
+ )
269
+ scroll_container.border_title = "Process Output"
270
+ yield TopBar(
271
+ app_title=Ferp.TITLE,
272
+ app_version=__version__,
273
+ state_store=self.state_store,
274
+ )
275
+ with Vertical(id="app_main_container"):
276
+ yield Horizontal(
277
+ Vertical(
278
+ FileTreeHeader(id="file_list_header"),
279
+ FileTree(id="file_list", state_store=self.file_tree_store),
280
+ id="file_list_container",
281
+ ),
282
+ Vertical(
283
+ ScriptManager(
284
+ self._resolve_script_config_paths(),
285
+ scripts_root=self._paths.scripts_dir,
286
+ id="scripts_panel",
287
+ ),
288
+ scroll_container,
289
+ id="details_pane",
290
+ ),
291
+ id="main_pane",
292
+ )
293
+ yield FileTreeFilterWidget(
294
+ id="file_tree_filter",
295
+ state_store=self.file_tree_store,
296
+ )
297
+ yield Footer(id="app_footer")
298
+
299
+ def _resolve_script_config_paths(self) -> list[Path]:
300
+ if not self._dev_config_enabled:
301
+ return [self._paths.config_file]
302
+
303
+ scripts_root = self.app_root / "scripts"
304
+ config_paths: list[Path] = []
305
+ default_config = scripts_root / "config.json"
306
+ if default_config.exists():
307
+ config_paths.append(default_config)
308
+ config_paths.extend(sorted(scripts_root.glob("*/config.json")))
309
+ return config_paths
310
+
311
+ def on_mount(self) -> None:
312
+ for theme in ALL_THEMES:
313
+ self.register_theme(theme)
314
+ self.console.set_window_title("FERP")
315
+ self.theme_changed_signal.subscribe(self, self.on_theme_changed)
316
+ preferred = self.settings.get("userPreferences", {}).get("theme")
317
+ fallback = DEFAULT_SETTINGS["userPreferences"]["theme"]
318
+ try:
319
+ self.theme = preferred or fallback
320
+ except Exception:
321
+ self.theme = fallback
322
+ if preferred != fallback:
323
+ self.settings_store.update_theme(self.settings, fallback)
324
+ self.state_store.set_current_path(str(self.current_path))
325
+ self.state_store.set_status("Ready")
326
+ self.update_cache_timestamp()
327
+ self._check_for_updates()
328
+ self.refresh_listing()
329
+ self.task_store.subscribe(self._handle_task_update)
330
+
331
+ def on_theme_changed(self, theme: Theme) -> None:
332
+ self.settings_store.update_theme(self.settings, theme.name)
333
+
334
+ def _command_install_script_bundle(self) -> None:
335
+ prompt = "Path to the script bundle (.ferp)"
336
+ default_value = str(self.current_path)
337
+
338
+ def after(value: str | None) -> None:
339
+ if not value:
340
+ return
341
+ try:
342
+ bundle_path = Path(value).expanduser()
343
+ if not bundle_path.is_absolute():
344
+ bundle_path = (self.current_path / bundle_path).resolve()
345
+ except Exception as exc:
346
+ self.notify(f"{exc}", severity="error", timeout=4)
347
+ return
348
+ if not bundle_path.exists():
349
+ self.notify(
350
+ f"No bundle found at {bundle_path}",
351
+ severity="error",
352
+ timeout=4,
353
+ )
354
+ return
355
+ if not bundle_path.is_file():
356
+ self.notify(
357
+ f"Bundle path must point to a file: {bundle_path}",
358
+ severity="error",
359
+ timeout=4,
360
+ )
361
+ return
362
+ if bundle_path.suffix.lower() != ".ferp":
363
+ self.notify(
364
+ "Bundles must be supplied as .ferp archives.",
365
+ severity="error",
366
+ timeout=4,
367
+ )
368
+ return
369
+ self.bundle_installer.start_install(bundle_path)
370
+
371
+ self.push_screen(
372
+ InputDialog(prompt, default=default_value),
373
+ after,
374
+ )
375
+
376
+ def _command_refresh_file_tree(self) -> None:
377
+ self.refresh_listing()
378
+
379
+ def _command_reload_scripts(self) -> None:
380
+ scripts_panel = self.query_one(ScriptManager)
381
+ scripts_panel.load_scripts()
382
+
383
+ def _command_open_latest_log(self) -> None:
384
+ logs_dir = self._paths.logs_dir
385
+ candidates = [entry for entry in logs_dir.glob("*.log") if entry.is_file()]
386
+
387
+ if not candidates:
388
+ self.notify("No log files found.", severity="error", timeout=3)
389
+ return
390
+
391
+ try:
392
+ latest = max(candidates, key=lambda entry: entry.stat().st_mtime)
393
+ except OSError as exc:
394
+ self.notify(f"{exc}", severity="error", timeout=3)
395
+ return
396
+
397
+ try:
398
+ if sys.platform == "darwin":
399
+ subprocess.run(["open", str(latest)], check=False)
400
+ elif sys.platform == "win32":
401
+ subprocess.run(["cmd", "/c", "start", "", str(latest)], check=False)
402
+ else:
403
+ subprocess.run(["xdg-open", str(latest)], check=False)
404
+ except Exception as exc:
405
+ self.notify(f"{exc}", severity="error", timeout=3)
406
+
407
+ def _command_open_user_guide(self) -> None:
408
+ guide_path = self.app_root / "resources" / "USERS_GUIDE.md"
409
+ if not guide_path.exists():
410
+ self.notify("User guide not found.", severity="error", timeout=3)
411
+ return
412
+ try:
413
+ content = guide_path.read_text(encoding="utf-8")
414
+ except Exception as exc:
415
+ self.notify(f"{exc}", severity="error", timeout=3)
416
+ return
417
+ screen = ReadmeScreen("FERP User Guide", content, id="readme_screen")
418
+ self.push_screen(screen)
419
+
420
+ def _command_show_processes(self) -> None:
421
+ self._ensure_process_list_screen()
422
+ self.push_screen("process_list")
423
+
424
+ def _command_set_startup_directory(self) -> None:
425
+ prompt = "Startup directory"
426
+ preferences = self.settings.get("userPreferences", {})
427
+ default_value = str(preferences.get("startupPath") or self.current_path)
428
+
429
+ def after(value: str | None) -> None:
430
+ if not value:
431
+ return
432
+ try:
433
+ path = Path(value).expanduser()
434
+ if not path.is_absolute():
435
+ path = (self.current_path / path).resolve()
436
+ except Exception as exc:
437
+ self.notify(f"{exc}", severity="error", timeout=3)
438
+ return
439
+ if not path.exists() or not path.is_dir():
440
+ self.notify(
441
+ f"{path} is not a valid directory.", severity="error", timeout=3
442
+ )
443
+ return
444
+ self.settings_store.update_startup_path(self.settings, path)
445
+ self.notify(f"Startup directory updated: {path}", timeout=3)
446
+
447
+ self.push_screen(InputDialog(prompt, default=default_value), after)
448
+
449
+ def _command_install_default_scripts(self) -> None:
450
+ self.notify("Fetching available namespaces...", timeout=4)
451
+ self.run_worker(
452
+ self._fetch_default_script_namespaces,
453
+ group="default_scripts_namespace",
454
+ exclusive=True,
455
+ thread=True,
456
+ )
457
+
458
+ def _command_sync_monday_board(self) -> None:
459
+ monday_settings = self.settings.get("integrations", {}).get("monday", {})
460
+ token = str(monday_settings.get("apiToken") or "").strip()
461
+ board_id = monday_settings.get("boardId")
462
+ try:
463
+ board_id_value = int(board_id)
464
+ except (TypeError, ValueError):
465
+ self.notify(
466
+ "Monday board id missing. Set integrations.monday.boardId in settings.json.",
467
+ severity="error",
468
+ timeout=4,
469
+ )
470
+ return
471
+
472
+ def start_sync(api_token: str) -> None:
473
+ self.notify("Syncing Monday board...", timeout=5)
474
+ self.run_worker(
475
+ lambda token=api_token, board=board_id_value: self._sync_monday_board(
476
+ token, board
477
+ ),
478
+ group="monday_sync",
479
+ exclusive=True,
480
+ thread=True,
481
+ )
482
+
483
+ if not token:
484
+ prompt = "Monday API token"
485
+
486
+ def after(value: str | None) -> None:
487
+ if not value:
488
+ return
489
+ token_value = value.strip()
490
+ if not token_value:
491
+ return
492
+ self.settings.setdefault("integrations", {}).setdefault("monday", {})[
493
+ "apiToken"
494
+ ] = token_value
495
+ self.settings_store.save(self.settings)
496
+ start_sync(token_value)
497
+
498
+ self.push_screen(InputDialog(prompt), after)
499
+ return
500
+
501
+ start_sync(token)
502
+
503
+ def _command_upgrade_app(self) -> None:
504
+ prompt = "Upgrade FERP now via pipx and restart?"
505
+
506
+ def after(value: bool | None) -> None:
507
+ if not value:
508
+ return
509
+ pipx_path = shutil.which("pipx")
510
+ if not pipx_path:
511
+ self.notify(
512
+ "pipx not found on PATH. Please run 'pipx upgrade ferp' manually.",
513
+ severity="error",
514
+ timeout=5,
515
+ )
516
+ return
517
+ self.notify("Upgrading FERP via pipx...", timeout=5)
518
+ self.run_worker(
519
+ lambda: self._upgrade_app(pipx_path),
520
+ group="app_upgrade",
521
+ exclusive=True,
522
+ thread=True,
523
+ )
524
+
525
+ self.push_screen(ConfirmDialog(prompt), after)
526
+
527
+ def _check_for_updates(self) -> None:
528
+ cache_path = self._paths.cache_dir / "update_check.json"
529
+ self.run_worker(
530
+ lambda: check_for_update(
531
+ "ferp",
532
+ str(__version__),
533
+ cache_path,
534
+ ttl_seconds=2 * 60 * 60,
535
+ ),
536
+ group="update_check",
537
+ exclusive=True,
538
+ thread=True,
539
+ )
540
+
541
+ def _fetch_default_script_namespaces(self) -> dict[str, object]:
542
+ try:
543
+ release_version, index_payload = fetch_namespace_index(SCRIPTS_REPO_URL)
544
+ namespaces = index_payload.get("namespaces", [])
545
+ if not isinstance(namespaces, list):
546
+ raise RuntimeError("namespaces.json is missing a namespaces list.")
547
+ options = [
548
+ str(entry.get("id", "")).strip()
549
+ for entry in namespaces
550
+ if isinstance(entry, dict)
551
+ ]
552
+ options = sorted({opt for opt in options if opt and opt != "core"})
553
+ if not options:
554
+ raise RuntimeError("No namespaces available to install.")
555
+ return {
556
+ "release_version": release_version,
557
+ "options": options,
558
+ }
559
+ except Exception as exc:
560
+ return {"error": str(exc)}
561
+
562
+ def _install_default_scripts(self, namespace: str) -> dict[str, str | bool]:
563
+ try:
564
+ dev_config_enabled = os.environ.get("FERP_DEV_CONFIG") == "1"
565
+ release_version = update_scripts_from_namespace_release(
566
+ SCRIPTS_REPO_URL,
567
+ self.scripts_dir,
568
+ namespace=namespace,
569
+ dry_run=dev_config_enabled,
570
+ )
571
+
572
+ if dev_config_enabled:
573
+ return {
574
+ "release_status": "Default scripts update skipped (dry run).",
575
+ "release_detail": (
576
+ "FERP_DEV_CONFIG=1; downloaded assets were discarded."
577
+ ),
578
+ "release_version": release_version,
579
+ }
580
+
581
+ core_config = self.scripts_dir / "core" / "config.json"
582
+ namespace_config = self.scripts_dir / namespace / "config.json"
583
+ if not core_config.exists():
584
+ raise FileNotFoundError(f"No default config found at {core_config}")
585
+ if not namespace_config.exists():
586
+ raise FileNotFoundError(
587
+ f"No namespace config found at {namespace_config}"
588
+ )
589
+
590
+ core_data = json.loads(core_config.read_text(encoding="utf-8"))
591
+ namespace_data = json.loads(namespace_config.read_text(encoding="utf-8"))
592
+ core_scripts = core_data.get("scripts", [])
593
+ namespace_scripts = namespace_data.get("scripts", [])
594
+ if not isinstance(core_scripts, list) or not isinstance(
595
+ namespace_scripts, list
596
+ ):
597
+ raise RuntimeError("Namespace config is missing a scripts list.")
598
+
599
+ merged = {"scripts": [*core_scripts, *namespace_scripts]}
600
+ self._paths.config_dir.mkdir(parents=True, exist_ok=True)
601
+ self._paths.config_file.write_text(
602
+ json.dumps(merged, indent=2) + "\n", encoding="utf-8"
603
+ )
604
+ config_status = f"Installed core + {namespace} scripts."
605
+
606
+ dependency_manager = ScriptDependencyManager(
607
+ self._paths.config_file, python_executable=sys.executable
608
+ )
609
+ dependency_manager.install_for_scripts()
610
+ except Exception as exc:
611
+ return {
612
+ "error": str(exc),
613
+ "release_status": "Default scripts update failed.",
614
+ }
615
+
616
+ return {
617
+ "config_path": str(self._paths.config_file),
618
+ "release_status": "Scripts updated to latest release.",
619
+ "release_detail": config_status,
620
+ "release_version": release_version,
621
+ }
622
+
623
+ def _upgrade_app(self, pipx_path: str) -> dict[str, object]:
624
+ current_version = str(__version__)
625
+ cache_path = self._paths.cache_dir / "update_check.json"
626
+ check = check_for_update(
627
+ "ferp",
628
+ current_version,
629
+ cache_path,
630
+ ttl_seconds=2 * 60 * 60,
631
+ )
632
+ latest_version = check.latest
633
+ check_error = check.error
634
+ if check.ok and not check.is_update and not check_error:
635
+ return {
636
+ "ok": True,
637
+ "no_update": True,
638
+ "current": current_version,
639
+ "latest": latest_version,
640
+ }
641
+ try:
642
+ result = subprocess.run(
643
+ [pipx_path, "upgrade", "ferp"],
644
+ check=False,
645
+ capture_output=True,
646
+ text=True,
647
+ )
648
+ except Exception as exc:
649
+ return {"ok": False, "error": str(exc)}
650
+ return {
651
+ "ok": result.returncode == 0,
652
+ "code": result.returncode,
653
+ "stdout": result.stdout.strip(),
654
+ "stderr": result.stderr.strip(),
655
+ "current": current_version,
656
+ "latest": latest_version,
657
+ "check_error": check_error,
658
+ }
659
+
660
+ def _sync_monday_board(self, api_token: str, board_id: int) -> dict[str, object]:
661
+ cache_path = self._paths.cache_dir / "publishers_cache.json"
662
+ try:
663
+ return sync_monday_board(api_token, board_id, cache_path)
664
+ except Exception as exc:
665
+ return {"error": str(exc)}
666
+
667
+ def _request_process_abort(self, record: ProcessRecord) -> bool:
668
+ active_handle = self.script_controller.active_process_handle
669
+ if not active_handle or record.handle != active_handle:
670
+ return False
671
+ return self.script_controller.request_abort(
672
+ "Termination requested from process list."
673
+ )
674
+
675
+ def _present_input_dialog(
676
+ self,
677
+ dialog: InputDialog,
678
+ callback: Callable[[str | None], None],
679
+ ) -> None:
680
+ self.push_screen(dialog, callback)
681
+
682
+ def _present_confirm_dialog(
683
+ self,
684
+ dialog: ConfirmDialog,
685
+ callback: Callable[[bool | None], None],
686
+ ) -> None:
687
+ self.push_screen(dialog, callback)
688
+
689
+ @on(NavigateRequest)
690
+ def handle_navigation(self, event: NavigateRequest) -> None:
691
+ self._request_navigation(event.path)
692
+
693
+ @on(DirectorySelectRequest)
694
+ def handle_directory_selection(self, event: DirectorySelectRequest) -> None:
695
+ self._request_navigation(event.path)
696
+
697
+ @on(CreatePathRequest)
698
+ def handle_create_path(self, event: CreatePathRequest) -> None:
699
+ self.path_actions.create_path(event.base, is_directory=event.is_directory)
700
+
701
+ @on(DeletePathRequest)
702
+ def handle_delete_path(self, event: DeletePathRequest) -> None:
703
+ self.path_actions.delete_path(event.target)
704
+
705
+ @on(RenamePathRequest)
706
+ def handle_rename_path(self, event: RenamePathRequest) -> None:
707
+ self.path_actions.rename_path(event.target)
708
+
709
+ @on(ShowReadmeRequest)
710
+ def show_readme(self, event: ShowReadmeRequest) -> None:
711
+ if not event.readme_path:
712
+ content = "_No README found for this script._"
713
+ else:
714
+ content = event.readme_path.read_text(encoding="utf-8")
715
+
716
+ screen = ReadmeScreen(event.script.name, content, id="readme_screen")
717
+ self.push_screen(screen)
718
+
719
+ @on(RunScriptRequest)
720
+ def handle_script_run(self, event: RunScriptRequest) -> None:
721
+ if self.script_controller.is_running:
722
+ return # ignore silently for now
723
+
724
+ try:
725
+ context = build_execution_context(
726
+ app_root=self.app_root,
727
+ current_path=self.current_path,
728
+ selected_path=self.file_tree_store.state.last_selected_path,
729
+ script=event.script,
730
+ )
731
+ self.script_controller.run_script(event.script, context)
732
+ except Exception as e:
733
+ self.state_store.update_script_run(
734
+ phase="error",
735
+ script_name=event.script.name,
736
+ target_path=self.current_path,
737
+ input_prompt=None,
738
+ progress_message="",
739
+ progress_line="",
740
+ progress_current=None,
741
+ progress_total=None,
742
+ progress_unit="",
743
+ result=None,
744
+ transcript_path=None,
745
+ error=str(e),
746
+ )
747
+
748
+ def render_script_output(
749
+ self,
750
+ script_name: str,
751
+ result: ScriptResult,
752
+ ) -> None:
753
+ target = self.script_controller.active_target or self.current_path
754
+
755
+ transcript_path = None
756
+ if result.transcript:
757
+ transcript_path = self.transcript_logger.write(
758
+ script_name,
759
+ target,
760
+ result,
761
+ )
762
+
763
+ self.state_store.update_script_run(
764
+ phase="result",
765
+ script_name=script_name,
766
+ target_path=target,
767
+ input_prompt=None,
768
+ progress_message="",
769
+ progress_line="",
770
+ progress_current=None,
771
+ progress_total=None,
772
+ progress_unit="",
773
+ result=result,
774
+ transcript_path=transcript_path,
775
+ error=None,
776
+ )
777
+
778
+ def on_exit(self) -> None:
779
+ self._is_shutting_down = True
780
+ self._stop_file_tree_watch()
781
+
782
+ async def action_quit(self) -> None:
783
+ if not self.script_controller.is_running:
784
+ self._is_shutting_down = True
785
+ self.exit()
786
+ return
787
+
788
+ def after(value: bool | None) -> None:
789
+ if not value:
790
+ return
791
+ self._pending_exit = True
792
+ self._is_shutting_down = True
793
+ if not self.script_controller.request_abort("App exit requested."):
794
+ self._pending_exit = False
795
+ self._is_shutting_down = True
796
+ self.exit()
797
+
798
+ self.push_screen(
799
+ ConfirmDialog(
800
+ "A script is still running. Abort it and quit?",
801
+ id="confirm_quit_dialog",
802
+ ),
803
+ after,
804
+ )
805
+
806
+ def _maybe_exit_after_script(self) -> None:
807
+ if not self._pending_exit:
808
+ return
809
+ if self.script_controller.is_running:
810
+ return
811
+ self._pending_exit = False
812
+ self.exit()
813
+
814
+ @property
815
+ def is_shutting_down(self) -> bool:
816
+ return self._is_shutting_down
817
+
818
+ def show_error(self, error: BaseException) -> None:
819
+ self.notify(f"{error}", severity="error", timeout=4)
820
+
821
+ def _start_delete_path(self, target: Path) -> None:
822
+ file_tree = self.query_one(FileTree)
823
+ file_tree.set_pending_delete_index(file_tree.index)
824
+ label = target.name or str(target)
825
+ self.notify(f"Deleting '{escape(label)}'...", timeout=2)
826
+ self._stop_file_tree_watch()
827
+ self.run_worker(
828
+ lambda: self._delete_path_worker(target),
829
+ group="delete_path",
830
+ thread=True,
831
+ )
832
+
833
+ def _delete_path_worker(self, target: Path) -> DeletePathResult:
834
+ try:
835
+ self.fs_controller.delete_path(target)
836
+ except OSError as exc:
837
+ return DeletePathResult(target=target, error=str(exc))
838
+ return DeletePathResult(target=target, error=None)
839
+
840
+ def _render_default_scripts_update(self, payload: dict[str, Any]) -> None:
841
+ error = payload.get("error")
842
+ if error:
843
+ self.notify(
844
+ f"Default scripts update failed: {error}",
845
+ severity="error",
846
+ timeout=4,
847
+ )
848
+ return
849
+ config_path = payload.get("config_path", "")
850
+ release_status = payload.get("release_status", "")
851
+ release_detail = payload.get("release_detail", "")
852
+ release_version = payload.get("release_version", "")
853
+
854
+ summary = "Default scripts updated."
855
+ if release_version:
856
+ summary = f"{summary} {release_version}"
857
+ if release_status:
858
+ summary = f"{summary} ({release_status})"
859
+ if config_path:
860
+ summary = f"{summary} Config: {config_path}"
861
+ if release_detail:
862
+ summary = f"{summary} {release_detail}"
863
+ self.notify(summary, timeout=4)
864
+
865
+ scripts_panel = self.query_one(ScriptManager)
866
+ scripts_panel.load_scripts()
867
+
868
+ def _prompt_default_scripts_namespace(self, options: list[str]) -> None:
869
+ prompt = "Select a namespace to install"
870
+
871
+ def after(value: str | None) -> None:
872
+ if not value:
873
+ return
874
+ namespace = value.strip()
875
+ if not namespace:
876
+ self.notify(
877
+ "Select a namespace to continue.", severity="error", timeout=4
878
+ )
879
+ return
880
+ dev_config_enabled = os.environ.get("FERP_DEV_CONFIG") == "1"
881
+ if dev_config_enabled:
882
+ self.notify("Updating default scripts (dry run)...", timeout=5)
883
+ else:
884
+ self.notify("Updating default scripts...", timeout=5)
885
+ self.run_worker(
886
+ lambda ns=namespace: self._install_default_scripts(ns),
887
+ group="default_scripts_update",
888
+ exclusive=True,
889
+ thread=True,
890
+ )
891
+
892
+ dialog = SelectDialog(
893
+ prompt,
894
+ options,
895
+ )
896
+ self.push_screen(dialog, after)
897
+
898
+ def _render_monday_sync(self, payload: dict[str, Any]) -> None:
899
+ error = payload.get("error")
900
+ if error:
901
+ self.notify(
902
+ f"Monday sync failed: {escape(str(error))}",
903
+ severity="error",
904
+ timeout=4,
905
+ )
906
+ return
907
+ board_name = payload.get("board_name", "")
908
+ group_count = payload.get("group_count", 0)
909
+ publisher_count = payload.get("publisher_count", 0)
910
+ skipped = payload.get("skipped", 0)
911
+
912
+ details = (
913
+ f"\nGroups {group_count}\nPublishers {publisher_count}\nSkipped {skipped}"
914
+ )
915
+ title = (
916
+ f"Monday sync updated ({escape(str(board_name))})"
917
+ if board_name
918
+ else "Monday sync updated"
919
+ )
920
+ self.notify(f"{title}. {details}", timeout=5)
921
+ self.update_cache_timestamp()
922
+
923
+ def refresh_listing(self) -> None:
924
+ if self._is_shutting_down:
925
+ return
926
+ if self._refresh_timer is not None:
927
+ self._refresh_timer.stop()
928
+ self._refresh_timer = None
929
+ if self._listing_in_progress:
930
+ self._pending_refresh = True
931
+ return
932
+
933
+ self._listing_in_progress = True
934
+ self.state_store.set_current_path(str(self.current_path))
935
+
936
+ try:
937
+ file_tree = self.query_one(FileTree)
938
+ except NoMatches:
939
+ self._listing_in_progress = False
940
+ return
941
+ if not file_tree.is_attached:
942
+ self._listing_in_progress = False
943
+ return
944
+ file_tree.show_loading(self.current_path)
945
+
946
+ self._directory_listing_token += 1
947
+ token = self._directory_listing_token
948
+ path = self.current_path
949
+
950
+ self.run_worker(
951
+ lambda directory=path, token=token: collect_directory_listing(
952
+ directory, token
953
+ ),
954
+ group="directory_listing",
955
+ exclusive=True,
956
+ thread=True,
957
+ )
958
+
959
+ def schedule_refresh_listing(
960
+ self, *, delay: float = 0.2, suppress_focus: bool = False
961
+ ) -> None:
962
+ if self._is_shutting_down:
963
+ return
964
+ if self._listing_in_progress:
965
+ self._pending_refresh = True
966
+ return
967
+ if suppress_focus:
968
+ try:
969
+ file_tree = self.query_one(FileTree)
970
+ except Exception:
971
+ file_tree = None
972
+ if file_tree is not None:
973
+ file_tree.suppress_focus_once()
974
+ if self._refresh_timer is not None:
975
+ self._refresh_timer.stop()
976
+ self._refresh_timer = None
977
+ self._refresh_timer = self.set_timer(delay, self.refresh_listing)
978
+
979
+ def _refresh_listing_from_watcher(self) -> None:
980
+ if self._listing_in_progress:
981
+ self._pending_refresh = True
982
+ return
983
+ self.refresh_listing()
984
+
985
+ def _handle_directory_listing_result(self, result: DirectoryListingResult) -> None:
986
+ if result.token != self._directory_listing_token:
987
+ return
988
+ if self._is_shutting_down:
989
+ self._finalize_directory_listing()
990
+ return
991
+
992
+ try:
993
+ file_tree = self.query_one(FileTree)
994
+ except NoMatches:
995
+ self._finalize_directory_listing()
996
+ return
997
+ if not file_tree.is_attached:
998
+ self._finalize_directory_listing()
999
+ return
1000
+ if result.error:
1001
+ if not result.path.exists():
1002
+ self._handle_missing_directory(result.path)
1003
+ self._finalize_directory_listing()
1004
+ return
1005
+ file_tree.show_error(
1006
+ result.path, f"Unable to load directory: {result.error}"
1007
+ )
1008
+ self._finalize_directory_listing()
1009
+ return
1010
+
1011
+ file_tree.show_listing(result.path, result.entries)
1012
+
1013
+ if self._file_tree_watcher is not None:
1014
+ self._file_tree_watcher.update_snapshot(result.path)
1015
+ self._start_file_tree_watch()
1016
+ self._finalize_directory_listing()
1017
+
1018
+ def _finalize_directory_listing(self) -> None:
1019
+ self._listing_in_progress = False
1020
+ pending_path = self._pending_navigation_path
1021
+ if pending_path is not None:
1022
+ self._pending_navigation_path = None
1023
+ self._begin_navigation(pending_path)
1024
+ return
1025
+ if self._pending_refresh:
1026
+ self._pending_refresh = False
1027
+ self.refresh_listing()
1028
+
1029
+ def _request_navigation(self, path: Path) -> None:
1030
+ if not path.exists() or not path.is_dir():
1031
+ if not self.current_path.exists():
1032
+ self._handle_missing_directory(self.current_path)
1033
+ return
1034
+ if self._listing_in_progress:
1035
+ self._pending_navigation_path = path
1036
+ return
1037
+ self._begin_navigation(path)
1038
+
1039
+ def _handle_missing_directory(self, missing: Path) -> None:
1040
+ target = self._nearest_existing_parent(missing)
1041
+ if target is None:
1042
+ target = self.resolve_startup_path()
1043
+
1044
+ if target.exists() and target != self.current_path:
1045
+ self.notify(
1046
+ f"Directory removed. Jumped to '{escape(str(target))}'.",
1047
+ timeout=3,
1048
+ )
1049
+
1050
+ if self._listing_in_progress:
1051
+ self._pending_navigation_path = target
1052
+ return
1053
+
1054
+ self._begin_navigation(target)
1055
+
1056
+ def _nearest_existing_parent(self, missing: Path) -> Path | None:
1057
+ candidate = missing
1058
+ while True:
1059
+ parent = candidate.parent
1060
+ if parent == candidate:
1061
+ return None
1062
+ if parent.exists():
1063
+ return parent
1064
+ candidate = parent
1065
+
1066
+ def _begin_navigation(self, path: Path) -> None:
1067
+ self._pending_navigation_path = None
1068
+ self._pending_refresh = False
1069
+ self.current_path = path
1070
+ self.state_store.set_current_path(str(self.current_path))
1071
+ self._stop_file_tree_watch()
1072
+ self.refresh_listing()
1073
+
1074
+ def _start_file_tree_watch(self) -> None:
1075
+ if self._file_tree_watcher is not None:
1076
+ self._file_tree_watcher.start(self.current_path)
1077
+
1078
+ def _stop_file_tree_watch(self) -> None:
1079
+ if self._file_tree_watcher is not None:
1080
+ self._file_tree_watcher.stop()
1081
+
1082
+ def _handle_task_update(self, tasks: Sequence[Task]) -> None:
1083
+ completed = sum(1 for task in tasks if task.completed)
1084
+ total = len(tasks)
1085
+ self._pending_task_totals = (completed, total)
1086
+
1087
+ def action_capture_task(self) -> None:
1088
+ screen = self._ensure_task_list_screen()
1089
+ screen.action_capture_task()
1090
+
1091
+ def action_show_task_list(self) -> None:
1092
+ self._ensure_task_list_screen()
1093
+ self.push_screen("task_list")
1094
+
1095
+ def _ensure_task_list_screen(self) -> TaskListScreen:
1096
+ if self._task_list_screen is None:
1097
+ screen = TaskListScreen(self.task_store, state_store=self.task_list_store)
1098
+ self.install_screen(screen, name="task_list")
1099
+ self._task_list_screen = screen
1100
+ return self._task_list_screen
1101
+
1102
+ def _ensure_process_list_screen(self) -> ProcessListScreen:
1103
+ if self._process_list_screen is None:
1104
+ screen = ProcessListScreen(
1105
+ self.script_controller.process_registry,
1106
+ self._request_process_abort,
1107
+ )
1108
+ self.install_screen(screen, name="process_list")
1109
+ self._process_list_screen = screen
1110
+ return self._process_list_screen
1111
+
1112
+ def action_toggle_help(self) -> None:
1113
+ try:
1114
+ self.screen.query_one("HelpPanel")
1115
+ except NoMatches:
1116
+ self.action_show_help_panel()
1117
+ else:
1118
+ self.action_hide_help_panel()
1119
+
1120
+ def action_toggle_maximize(self) -> None:
1121
+ screen = self.screen
1122
+ if screen.maximized is not None:
1123
+ screen.action_minimize()
1124
+ return
1125
+ focused = screen.focused
1126
+ if focused is not None and focused.allow_maximize:
1127
+ screen.action_maximize()
1128
+
1129
+ def update_cache_timestamp(self) -> None:
1130
+ cache_path = self._paths.cache_dir / "publishers_cache.json"
1131
+
1132
+ if cache_path.exists():
1133
+ updated_at = datetime.fromtimestamp(
1134
+ cache_path.stat().st_mtime, tz=timezone.utc
1135
+ )
1136
+ else:
1137
+ updated_at = datetime(1970, 1, 1, tzinfo=timezone.utc)
1138
+
1139
+ self.state_store.set_cache_updated_at(updated_at)
1140
+
1141
+ @on(Worker.StateChanged)
1142
+ def _on_worker_state_changed(self, event: Worker.StateChanged) -> None:
1143
+ worker = event.worker
1144
+ if worker.group == "directory_listing":
1145
+ if event.state is WorkerState.SUCCESS:
1146
+ result = worker.result
1147
+ if isinstance(result, DirectoryListingResult):
1148
+ self._handle_directory_listing_result(result)
1149
+ elif event.state is WorkerState.ERROR:
1150
+ error = worker.error or RuntimeError("Directory listing failed.")
1151
+ file_tree = self.query_one(FileTree)
1152
+ file_tree.show_error(self.current_path, str(error))
1153
+ self._finalize_directory_listing()
1154
+ return
1155
+ if worker.group == "update_check":
1156
+ if event.state is WorkerState.SUCCESS:
1157
+ result = worker.result
1158
+ if (
1159
+ isinstance(result, UpdateCheckResult)
1160
+ and result.ok
1161
+ and result.is_update
1162
+ ):
1163
+ self.notify("A new verion of FERP is avaiable.", timeout=6)
1164
+ return
1165
+ if self.bundle_installer.handle_worker_state(event):
1166
+ return
1167
+ if self.script_controller.handle_worker_state(event):
1168
+ return
1169
+ if worker.group == "default_scripts_namespace":
1170
+ if event.state is WorkerState.SUCCESS:
1171
+ result = worker.result
1172
+ if isinstance(result, dict):
1173
+ error = result.get("error")
1174
+ if error:
1175
+ self.notify(
1176
+ f"Namespace fetch failed: {error}",
1177
+ severity="error",
1178
+ timeout=4,
1179
+ )
1180
+ return
1181
+ options = result.get("options", [])
1182
+ if isinstance(options, list) and options:
1183
+ self._prompt_default_scripts_namespace(
1184
+ [str(option) for option in options]
1185
+ )
1186
+ return
1187
+ self.notify(
1188
+ "No namespaces available for installation.",
1189
+ severity="error",
1190
+ timeout=4,
1191
+ )
1192
+ elif event.state is WorkerState.ERROR:
1193
+ error = worker.error or RuntimeError("Namespace fetch failed.")
1194
+ self.notify(
1195
+ f"Namespace fetch failed: {error}",
1196
+ severity="error",
1197
+ timeout=4,
1198
+ )
1199
+ return
1200
+ if worker.group == "default_scripts_update":
1201
+ if event.state is WorkerState.SUCCESS:
1202
+ result = worker.result
1203
+ if isinstance(result, dict):
1204
+ self._render_default_scripts_update(result)
1205
+ elif event.state is WorkerState.ERROR:
1206
+ error = worker.error or RuntimeError("Default script update failed.")
1207
+ self.notify(
1208
+ f"Default scripts update failed: {error}",
1209
+ severity="error",
1210
+ timeout=4,
1211
+ )
1212
+ return
1213
+ if worker.group == "app_upgrade":
1214
+ if event.state is WorkerState.SUCCESS:
1215
+ result = worker.result
1216
+ if isinstance(result, dict):
1217
+ if result.get("no_update") is True:
1218
+ current = result.get("current") or ""
1219
+ latest = result.get("latest") or current
1220
+ label = latest or current
1221
+ message = (
1222
+ f"FERP is already up to date ({label})."
1223
+ if label
1224
+ else "FERP is already up to date."
1225
+ )
1226
+ self.notify(message, timeout=5)
1227
+ return
1228
+ check_error = result.get("check_error")
1229
+ if check_error:
1230
+ self.notify(
1231
+ f"Update check failed ({check_error}); running upgrade anyway.",
1232
+ timeout=5,
1233
+ )
1234
+ if result.get("ok") is True:
1235
+ self.notify(
1236
+ "Upgrade complete. Please restart after the app exits. Exiting...",
1237
+ timeout=5,
1238
+ )
1239
+ self.set_timer(2.0, self.exit)
1240
+ else:
1241
+ error = result.get("error") or result.get("stderr") or ""
1242
+ code = result.get("code")
1243
+ detail = f" (exit {code})" if code is not None else ""
1244
+ message = (
1245
+ f"Upgrade failed{detail}. {error}".strip()
1246
+ if error
1247
+ else f"Upgrade failed{detail}."
1248
+ )
1249
+ self.notify(message, severity="error", timeout=6)
1250
+ elif event.state is WorkerState.ERROR:
1251
+ error = worker.error or RuntimeError("Upgrade failed.")
1252
+ self.notify(f"Upgrade failed: {error}", severity="error", timeout=6)
1253
+ return
1254
+ if worker.group == "delete_path":
1255
+ if event.state is WorkerState.SUCCESS:
1256
+ result = worker.result
1257
+ if isinstance(result, DeletePathResult):
1258
+ if result.error:
1259
+ self.notify(
1260
+ f"Delete failed: {result.error}",
1261
+ severity="error",
1262
+ timeout=3,
1263
+ )
1264
+ file_tree = self.query_one(FileTree)
1265
+ file_tree.set_pending_delete_index(None)
1266
+ self._start_file_tree_watch()
1267
+ return
1268
+ label = result.target.name or str(result.target)
1269
+ self.notify(f"Deleted '{escape(label)}'.", timeout=3)
1270
+ self.refresh_listing()
1271
+ elif event.state is WorkerState.ERROR:
1272
+ error = worker.error or RuntimeError("Delete failed.")
1273
+ self.notify(f"Delete failed: {error}", severity="error", timeout=3)
1274
+ file_tree = self.query_one(FileTree)
1275
+ file_tree.set_pending_delete_index(None)
1276
+ self._start_file_tree_watch()
1277
+ return
1278
+ if worker.group == "bulk_rename":
1279
+ if event.state is WorkerState.SUCCESS:
1280
+ result = worker.result
1281
+ if isinstance(result, dict):
1282
+ errors = (
1283
+ result.get("errors")
1284
+ if isinstance(result.get("errors"), list)
1285
+ else []
1286
+ )
1287
+ count = result.get("count", 0)
1288
+ if errors:
1289
+ self.notify(
1290
+ f"Rename completed with errors ({len(errors)} issue(s)).",
1291
+ severity="warning",
1292
+ timeout=3,
1293
+ )
1294
+ else:
1295
+ self.notify(f"Rename complete: {count} file(s).", timeout=3)
1296
+ self.refresh_listing()
1297
+ elif event.state is WorkerState.ERROR:
1298
+ error = worker.error or RuntimeError("Bulk rename failed.")
1299
+ self.notify(
1300
+ f"Bulk rename failed: {error}", severity="warning", timeout=3
1301
+ )
1302
+ self.refresh_listing()
1303
+ return
1304
+ if worker.group == "monday_sync":
1305
+ if event.state is WorkerState.SUCCESS:
1306
+ result = worker.result
1307
+ if isinstance(result, dict):
1308
+ self._render_monday_sync(result)
1309
+ elif event.state is WorkerState.ERROR:
1310
+ error = worker.error or RuntimeError("Monday sync failed.")
1311
+ self.notify(f"Monday sync failed: {error}", severity="error", timeout=4)
1312
+ return