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
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
|