ferp 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ferp/__init__.py +3 -0
- ferp/__main__.py +4 -0
- ferp/__version__.py +1 -0
- ferp/app.py +9 -0
- ferp/cli.py +160 -0
- ferp/core/__init__.py +0 -0
- ferp/core/app.py +1312 -0
- ferp/core/bundle_installer.py +245 -0
- ferp/core/command_provider.py +77 -0
- ferp/core/dependency_manager.py +59 -0
- ferp/core/fs_controller.py +70 -0
- ferp/core/fs_watcher.py +144 -0
- ferp/core/messages.py +49 -0
- ferp/core/path_actions.py +124 -0
- ferp/core/paths.py +3 -0
- ferp/core/protocols.py +8 -0
- ferp/core/script_controller.py +515 -0
- ferp/core/script_protocol.py +35 -0
- ferp/core/script_runner.py +421 -0
- ferp/core/settings.py +16 -0
- ferp/core/settings_store.py +69 -0
- ferp/core/state.py +156 -0
- ferp/core/task_store.py +164 -0
- ferp/core/transcript_logger.py +95 -0
- ferp/domain/__init__.py +0 -0
- ferp/domain/scripts.py +29 -0
- ferp/fscp/host/__init__.py +11 -0
- ferp/fscp/host/host.py +439 -0
- ferp/fscp/host/managed_process.py +113 -0
- ferp/fscp/host/process_registry.py +124 -0
- ferp/fscp/protocol/__init__.py +13 -0
- ferp/fscp/protocol/errors.py +2 -0
- ferp/fscp/protocol/messages.py +55 -0
- ferp/fscp/protocol/schemas/__init__.py +0 -0
- ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
- ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
- ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
- ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
- ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
- ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
- ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
- ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
- ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
- ferp/fscp/protocol/state.py +16 -0
- ferp/fscp/protocol/validator.py +123 -0
- ferp/fscp/scripts/__init__.py +0 -0
- ferp/fscp/scripts/runtime/__init__.py +4 -0
- ferp/fscp/scripts/runtime/__main__.py +40 -0
- ferp/fscp/scripts/runtime/errors.py +14 -0
- ferp/fscp/scripts/runtime/io.py +64 -0
- ferp/fscp/scripts/runtime/script.py +149 -0
- ferp/fscp/scripts/runtime/state.py +17 -0
- ferp/fscp/scripts/runtime/worker.py +13 -0
- ferp/fscp/scripts/sdk.py +548 -0
- ferp/fscp/transcript/__init__.py +3 -0
- ferp/fscp/transcript/events.py +14 -0
- ferp/resources/__init__.py +0 -0
- ferp/services/__init__.py +3 -0
- ferp/services/file_listing.py +120 -0
- ferp/services/monday_sync.py +155 -0
- ferp/services/releases.py +214 -0
- ferp/services/scripts.py +90 -0
- ferp/services/update_check.py +130 -0
- ferp/styles/index.tcss +638 -0
- ferp/themes/themes.py +238 -0
- ferp/widgets/__init__.py +17 -0
- ferp/widgets/dialogs.py +167 -0
- ferp/widgets/file_tree.py +991 -0
- ferp/widgets/forms.py +146 -0
- ferp/widgets/output_panel.py +244 -0
- ferp/widgets/panels.py +13 -0
- ferp/widgets/process_list.py +158 -0
- ferp/widgets/readme_modal.py +59 -0
- ferp/widgets/scripts.py +192 -0
- ferp/widgets/task_capture.py +74 -0
- ferp/widgets/task_list.py +493 -0
- ferp/widgets/top_bar.py +110 -0
- ferp-0.7.1.dist-info/METADATA +128 -0
- ferp-0.7.1.dist-info/RECORD +87 -0
- ferp-0.7.1.dist-info/WHEEL +5 -0
- ferp-0.7.1.dist-info/entry_points.txt +2 -0
- ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
- ferp-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ferp.core.fs_controller import FileSystemController
|
|
7
|
+
from ferp.widgets.dialogs import ConfirmDialog, InputDialog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PathActionController:
|
|
11
|
+
"""Orchestrates file/directory creation and deletion prompts."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
present_input: Callable[[InputDialog, Callable[[str | None], None]], None],
|
|
17
|
+
present_confirm: Callable[[ConfirmDialog, Callable[[bool | None], None]], None],
|
|
18
|
+
show_error: Callable[[BaseException], None],
|
|
19
|
+
refresh_listing: Callable[[], None],
|
|
20
|
+
fs_controller: FileSystemController,
|
|
21
|
+
delete_handler: Callable[[Path], None],
|
|
22
|
+
) -> None:
|
|
23
|
+
self._present_input = present_input
|
|
24
|
+
self._present_confirm = present_confirm
|
|
25
|
+
self._show_error = show_error
|
|
26
|
+
self._refresh_listing = refresh_listing
|
|
27
|
+
self._fs = fs_controller
|
|
28
|
+
self._delete_handler = delete_handler
|
|
29
|
+
|
|
30
|
+
def create_path(self, base: Path, *, is_directory: bool) -> None:
|
|
31
|
+
parent = base if base.is_dir() else base.parent
|
|
32
|
+
default_name = "New Folder" if is_directory else "New File.txt"
|
|
33
|
+
|
|
34
|
+
def after(name: str | None) -> None:
|
|
35
|
+
if not name:
|
|
36
|
+
return
|
|
37
|
+
target = parent / name
|
|
38
|
+
|
|
39
|
+
def perform(overwrite: bool) -> None:
|
|
40
|
+
try:
|
|
41
|
+
self._fs.create_path(
|
|
42
|
+
target,
|
|
43
|
+
is_directory=is_directory,
|
|
44
|
+
overwrite=overwrite,
|
|
45
|
+
)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
self._show_error(exc)
|
|
48
|
+
return
|
|
49
|
+
self._refresh_listing()
|
|
50
|
+
|
|
51
|
+
if target.exists():
|
|
52
|
+
self._present_confirm(
|
|
53
|
+
ConfirmDialog(f"'{target.name}' exists. Overwrite?"),
|
|
54
|
+
lambda confirmed: perform(True) if confirmed else None,
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
perform(False)
|
|
59
|
+
|
|
60
|
+
self._present_input(
|
|
61
|
+
InputDialog("Enter name", default=default_name),
|
|
62
|
+
after,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def delete_path(self, target: Path) -> None:
|
|
66
|
+
if not target.exists():
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
def after(confirmed: bool | None) -> None:
|
|
70
|
+
if not confirmed:
|
|
71
|
+
return
|
|
72
|
+
self._delete_handler(target)
|
|
73
|
+
|
|
74
|
+
self._present_confirm(
|
|
75
|
+
ConfirmDialog(f"Delete '{target.name}'?"),
|
|
76
|
+
after,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def rename_path(self, target: Path) -> None:
|
|
80
|
+
if not target.exists():
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
name = target.name
|
|
84
|
+
if name.endswith("."):
|
|
85
|
+
suffix = ""
|
|
86
|
+
else:
|
|
87
|
+
suffix = target.suffix
|
|
88
|
+
stem = name[: -len(suffix)] if suffix else name
|
|
89
|
+
|
|
90
|
+
def perform(overwrite: bool) -> None:
|
|
91
|
+
try:
|
|
92
|
+
self._fs.rename_path(target, destination, overwrite=overwrite)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
self._show_error(exc)
|
|
95
|
+
return
|
|
96
|
+
self._refresh_listing()
|
|
97
|
+
|
|
98
|
+
def after(name: str | None) -> None:
|
|
99
|
+
if not name:
|
|
100
|
+
return
|
|
101
|
+
nonlocal destination
|
|
102
|
+
new_name = name
|
|
103
|
+
if suffix:
|
|
104
|
+
if not new_name.endswith(suffix):
|
|
105
|
+
new_name = f"{new_name}{suffix}"
|
|
106
|
+
destination = target.with_name(new_name)
|
|
107
|
+
if destination == target:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if destination.exists():
|
|
111
|
+
self._present_confirm(
|
|
112
|
+
ConfirmDialog(f"'{destination.name}' exists. Overwrite?"),
|
|
113
|
+
lambda confirmed: perform(True) if confirmed else None,
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
perform(False)
|
|
118
|
+
|
|
119
|
+
destination = target
|
|
120
|
+
default_name = stem
|
|
121
|
+
self._present_input(
|
|
122
|
+
InputDialog("Enter new name", default=default_name),
|
|
123
|
+
after,
|
|
124
|
+
)
|
ferp/core/paths.py
ADDED
ferp/core/protocols.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
7
|
+
|
|
8
|
+
from textual.worker import Worker, WorkerState
|
|
9
|
+
|
|
10
|
+
from ferp.core.script_runner import (
|
|
11
|
+
ScriptInputRequest,
|
|
12
|
+
ScriptResult,
|
|
13
|
+
ScriptRunner,
|
|
14
|
+
ScriptStatus,
|
|
15
|
+
)
|
|
16
|
+
from ferp.domain.scripts import Script
|
|
17
|
+
from ferp.services.scripts import ScriptExecutionContext
|
|
18
|
+
from ferp.widgets.dialogs import ConfirmDialog
|
|
19
|
+
from ferp.widgets.file_tree import FileTree
|
|
20
|
+
from ferp.widgets.forms import BooleanField, PromptDialog, SelectionField
|
|
21
|
+
from ferp.widgets.scripts import ScriptManager
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ferp.core.app import Ferp
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScriptLifecycleController:
|
|
28
|
+
"""Coordinates script execution, prompts, and progress UI."""
|
|
29
|
+
|
|
30
|
+
_POST_SCRIPT_REFRESH_DELAY_S = 0.25
|
|
31
|
+
|
|
32
|
+
def __init__(self, app: "Ferp") -> None:
|
|
33
|
+
self._app = app
|
|
34
|
+
self._runner = ScriptRunner(
|
|
35
|
+
app.app_root,
|
|
36
|
+
app._paths.cache_dir,
|
|
37
|
+
self._handle_script_progress,
|
|
38
|
+
)
|
|
39
|
+
self._progress_lines: list[str] = []
|
|
40
|
+
self._progress_started_at: datetime | None = None
|
|
41
|
+
self._script_running = False
|
|
42
|
+
self._active_script_name: str | None = None
|
|
43
|
+
self._active_target: Path | None = None
|
|
44
|
+
self._active_worker: Worker | None = None
|
|
45
|
+
self._abort_worker: Worker | None = None
|
|
46
|
+
self._input_screen: PromptDialog | ConfirmDialog | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_running(self) -> bool:
|
|
50
|
+
return self._script_running
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def active_target(self) -> Path | None:
|
|
54
|
+
return self._active_target
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def active_script_name(self) -> str | None:
|
|
58
|
+
return self._active_script_name
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def process_registry(self):
|
|
62
|
+
return self._runner.process_registry
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def active_process_handle(self) -> str | None:
|
|
66
|
+
return self._runner.active_process_handle
|
|
67
|
+
|
|
68
|
+
def run_script(self, script: Script, context: ScriptExecutionContext) -> None:
|
|
69
|
+
if self._script_running:
|
|
70
|
+
return
|
|
71
|
+
self._active_script_name = script.name
|
|
72
|
+
self._active_target = context.target_path
|
|
73
|
+
self._start_worker(lambda: self._runner.start(context))
|
|
74
|
+
|
|
75
|
+
def abort_active(self, reason: str = "Operation canceled by user.") -> bool:
|
|
76
|
+
if not self._script_running:
|
|
77
|
+
return False
|
|
78
|
+
self._dismiss_input_screen()
|
|
79
|
+
cancelled = self._runner.abort(reason)
|
|
80
|
+
if cancelled:
|
|
81
|
+
script_name = self._active_script_name or "Script"
|
|
82
|
+
self._app.render_script_output(script_name, cancelled)
|
|
83
|
+
self._schedule_post_script_refresh()
|
|
84
|
+
self._reset_after_script()
|
|
85
|
+
return cancelled is not None
|
|
86
|
+
|
|
87
|
+
def request_abort(self, reason: str = "Operation canceled by user.") -> bool:
|
|
88
|
+
if not self._script_running:
|
|
89
|
+
return False
|
|
90
|
+
if self._abort_worker is not None:
|
|
91
|
+
return True
|
|
92
|
+
self._dismiss_input_screen()
|
|
93
|
+
|
|
94
|
+
def abort() -> ScriptResult | None:
|
|
95
|
+
return self._runner.abort(reason)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
self._abort_worker = self._app.run_worker(
|
|
99
|
+
abort,
|
|
100
|
+
group="script_abort",
|
|
101
|
+
exclusive=True,
|
|
102
|
+
thread=True,
|
|
103
|
+
)
|
|
104
|
+
except Exception:
|
|
105
|
+
self._abort_worker = None
|
|
106
|
+
raise
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def handle_worker_state(self, event: Worker.StateChanged) -> bool:
|
|
110
|
+
worker = event.worker
|
|
111
|
+
if worker.group not in {"scripts", "script_abort"}:
|
|
112
|
+
return False
|
|
113
|
+
if worker.group == "scripts":
|
|
114
|
+
if self._active_worker is None:
|
|
115
|
+
if not self._script_running:
|
|
116
|
+
return True
|
|
117
|
+
elif worker is not self._active_worker:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
state = event.state
|
|
121
|
+
if state is WorkerState.RUNNING:
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
if worker.group == "script_abort":
|
|
125
|
+
if state is WorkerState.SUCCESS:
|
|
126
|
+
result = worker.result
|
|
127
|
+
if isinstance(result, ScriptResult):
|
|
128
|
+
self._app.render_script_output(
|
|
129
|
+
self._active_script_name or "Script",
|
|
130
|
+
result,
|
|
131
|
+
)
|
|
132
|
+
self._schedule_post_script_refresh()
|
|
133
|
+
self._reset_after_script()
|
|
134
|
+
elif state is WorkerState.ERROR:
|
|
135
|
+
error = worker.error or RuntimeError("Script cancellation failed.")
|
|
136
|
+
self._set_script_error(error)
|
|
137
|
+
self._schedule_post_script_refresh()
|
|
138
|
+
self._reset_after_script()
|
|
139
|
+
if state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
140
|
+
self._abort_worker = None
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
if state is WorkerState.SUCCESS:
|
|
144
|
+
result = worker.result
|
|
145
|
+
if not isinstance(result, ScriptResult):
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
if result.status is ScriptStatus.WAITING_INPUT:
|
|
149
|
+
if result.input_request:
|
|
150
|
+
self._handle_input_request(result.input_request)
|
|
151
|
+
else:
|
|
152
|
+
self._set_script_error(RuntimeError("Missing FSCP input details."))
|
|
153
|
+
self._runner.abort("Protocol error.")
|
|
154
|
+
self._reset_after_script()
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
self._app.render_script_output(
|
|
158
|
+
self._active_script_name or "Script",
|
|
159
|
+
result,
|
|
160
|
+
)
|
|
161
|
+
self._schedule_post_script_refresh()
|
|
162
|
+
self._reset_after_script()
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
if state is WorkerState.ERROR:
|
|
166
|
+
error = worker.error
|
|
167
|
+
if error is not None:
|
|
168
|
+
self._set_script_error(error)
|
|
169
|
+
else:
|
|
170
|
+
self._set_script_error(RuntimeError("Script worker failed."))
|
|
171
|
+
self._runner.abort("Worker failed.")
|
|
172
|
+
self._schedule_post_script_refresh()
|
|
173
|
+
self._reset_after_script()
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
if state is WorkerState.CANCELLED:
|
|
177
|
+
self._reset_after_script()
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
def handle_launch_failure(self) -> None:
|
|
183
|
+
"""Reset state if launching the worker raises."""
|
|
184
|
+
self._script_running = False
|
|
185
|
+
self._active_script_name = None
|
|
186
|
+
self._active_target = None
|
|
187
|
+
self._active_worker = None
|
|
188
|
+
self._abort_worker = None
|
|
189
|
+
self._progress_lines = []
|
|
190
|
+
self._progress_started_at = None
|
|
191
|
+
self._set_controls_disabled(False)
|
|
192
|
+
self._app.state_store.set_status("Ready")
|
|
193
|
+
self._set_script_error(RuntimeError("Script launch failed."))
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Internal helpers
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
def _start_worker(self, runner_fn: Callable[[], ScriptResult]) -> None:
|
|
199
|
+
self._script_running = True
|
|
200
|
+
app = self._app
|
|
201
|
+
app.state_store.set_status("Running")
|
|
202
|
+
self._progress_lines = []
|
|
203
|
+
self._progress_started_at = datetime.now()
|
|
204
|
+
app._stop_file_tree_watch()
|
|
205
|
+
|
|
206
|
+
script_name = self._active_script_name or "Script"
|
|
207
|
+
target = self._active_target or app.current_path
|
|
208
|
+
app.state_store.update_script_run(
|
|
209
|
+
phase="running",
|
|
210
|
+
script_name=script_name,
|
|
211
|
+
target_path=target,
|
|
212
|
+
input_prompt=None,
|
|
213
|
+
progress_message="",
|
|
214
|
+
progress_line="",
|
|
215
|
+
progress_current=None,
|
|
216
|
+
progress_total=None,
|
|
217
|
+
progress_unit="",
|
|
218
|
+
result=None,
|
|
219
|
+
transcript_path=None,
|
|
220
|
+
error=None,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self._set_controls_disabled(True)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
worker = app.run_worker(
|
|
227
|
+
runner_fn,
|
|
228
|
+
group="scripts",
|
|
229
|
+
exclusive=True,
|
|
230
|
+
thread=True,
|
|
231
|
+
)
|
|
232
|
+
self._active_worker = worker
|
|
233
|
+
except Exception:
|
|
234
|
+
self.handle_launch_failure()
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
def _handle_input_request(self, request: ScriptInputRequest) -> None:
|
|
238
|
+
prompt = request.prompt or "Input required"
|
|
239
|
+
self._app.state_store.update_script_run(
|
|
240
|
+
phase="awaiting_input",
|
|
241
|
+
input_prompt=prompt,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
normalized_default = (request.default or "").strip().lower()
|
|
245
|
+
is_confirm = request.mode == "confirm"
|
|
246
|
+
if not is_confirm and normalized_default in {
|
|
247
|
+
"true",
|
|
248
|
+
"1",
|
|
249
|
+
"yes",
|
|
250
|
+
"y",
|
|
251
|
+
"false",
|
|
252
|
+
"0",
|
|
253
|
+
"no",
|
|
254
|
+
"n",
|
|
255
|
+
}:
|
|
256
|
+
is_confirm = True
|
|
257
|
+
|
|
258
|
+
if is_confirm:
|
|
259
|
+
|
|
260
|
+
def handle_confirm(value: bool | None) -> None:
|
|
261
|
+
if value is None:
|
|
262
|
+
self._handle_user_cancelled()
|
|
263
|
+
return
|
|
264
|
+
if not self._accept_input():
|
|
265
|
+
return
|
|
266
|
+
self._input_screen = None
|
|
267
|
+
payload = "true" if value else "false"
|
|
268
|
+
self._start_worker(lambda: self._runner.provide_input(payload))
|
|
269
|
+
|
|
270
|
+
dialog = ConfirmDialog(prompt, id="confirm_dialog")
|
|
271
|
+
self._input_screen = dialog
|
|
272
|
+
self._app.push_screen(dialog, handle_confirm)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
bool_fields = self._boolean_fields_for_request(request)
|
|
276
|
+
selection_fields = self._selection_fields_for_request(request)
|
|
277
|
+
dialog = PromptDialog(
|
|
278
|
+
prompt,
|
|
279
|
+
default=request.default,
|
|
280
|
+
suggestions=request.suggestions,
|
|
281
|
+
boolean_fields=bool_fields,
|
|
282
|
+
selection_fields=selection_fields,
|
|
283
|
+
show_text_input=request.show_text_input,
|
|
284
|
+
id="prompt_dialog",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def on_close(data: dict[str, str | bool | list[str]] | None) -> None:
|
|
288
|
+
if data is None:
|
|
289
|
+
self._handle_user_cancelled()
|
|
290
|
+
return
|
|
291
|
+
if not self._accept_input():
|
|
292
|
+
return
|
|
293
|
+
self._input_screen = None
|
|
294
|
+
value = data.get("value", "")
|
|
295
|
+
payload_value = str(value)
|
|
296
|
+
payload = (
|
|
297
|
+
json.dumps(data) if (bool_fields or selection_fields) else payload_value
|
|
298
|
+
)
|
|
299
|
+
self._start_worker(lambda: self._runner.provide_input(payload))
|
|
300
|
+
|
|
301
|
+
self._input_screen = dialog
|
|
302
|
+
self._app.push_screen(dialog, on_close)
|
|
303
|
+
|
|
304
|
+
def _handle_user_cancelled(self) -> None:
|
|
305
|
+
self.abort_active("Operation canceled by user.")
|
|
306
|
+
|
|
307
|
+
def _accept_input(self) -> bool:
|
|
308
|
+
if not self._script_running:
|
|
309
|
+
return False
|
|
310
|
+
return self._runner.active_process_handle is not None
|
|
311
|
+
|
|
312
|
+
def _dismiss_input_screen(self) -> None:
|
|
313
|
+
screen = self._input_screen
|
|
314
|
+
if screen is None:
|
|
315
|
+
return
|
|
316
|
+
if screen.is_active:
|
|
317
|
+
try:
|
|
318
|
+
screen.dismiss(None)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
else:
|
|
322
|
+
setattr(screen, "_dismiss_on_resume", True)
|
|
323
|
+
self._input_screen = None
|
|
324
|
+
|
|
325
|
+
def _set_script_error(self, error: BaseException) -> None:
|
|
326
|
+
script_name = self._active_script_name or "Script"
|
|
327
|
+
target = self._active_target or self._app.current_path
|
|
328
|
+
self._app.state_store.update_script_run(
|
|
329
|
+
phase="error",
|
|
330
|
+
script_name=script_name,
|
|
331
|
+
target_path=target,
|
|
332
|
+
input_prompt=None,
|
|
333
|
+
progress_message="",
|
|
334
|
+
progress_line="",
|
|
335
|
+
progress_current=None,
|
|
336
|
+
progress_total=None,
|
|
337
|
+
progress_unit="",
|
|
338
|
+
result=None,
|
|
339
|
+
transcript_path=None,
|
|
340
|
+
error=str(error),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def _boolean_fields_for_request(
|
|
344
|
+
self, request: ScriptInputRequest
|
|
345
|
+
) -> list[BooleanField]:
|
|
346
|
+
fields: list[BooleanField] = []
|
|
347
|
+
for field in request.fields:
|
|
348
|
+
if field.get("type") != "bool":
|
|
349
|
+
continue
|
|
350
|
+
field_id = field.get("id")
|
|
351
|
+
label = field.get("label")
|
|
352
|
+
if not field_id or not label:
|
|
353
|
+
continue
|
|
354
|
+
fields.append(
|
|
355
|
+
BooleanField(
|
|
356
|
+
str(field_id),
|
|
357
|
+
str(label),
|
|
358
|
+
bool(field.get("default", False)),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return fields
|
|
362
|
+
|
|
363
|
+
def _selection_fields_for_request(
|
|
364
|
+
self, request: ScriptInputRequest
|
|
365
|
+
) -> list[SelectionField]:
|
|
366
|
+
fields: list[SelectionField] = []
|
|
367
|
+
for field in request.fields:
|
|
368
|
+
if field.get("type") != "multi_select":
|
|
369
|
+
continue
|
|
370
|
+
field_id = field.get("id")
|
|
371
|
+
label = field.get("label")
|
|
372
|
+
options = field.get("options")
|
|
373
|
+
default = field.get("default", [])
|
|
374
|
+
if not field_id or not label:
|
|
375
|
+
continue
|
|
376
|
+
if not isinstance(options, list) or not options:
|
|
377
|
+
continue
|
|
378
|
+
options_clean = [str(item) for item in options if item]
|
|
379
|
+
if not options_clean:
|
|
380
|
+
continue
|
|
381
|
+
values = []
|
|
382
|
+
if isinstance(default, list):
|
|
383
|
+
values = [str(item) for item in default if item]
|
|
384
|
+
fields.append(
|
|
385
|
+
SelectionField(
|
|
386
|
+
str(field_id),
|
|
387
|
+
str(label),
|
|
388
|
+
options_clean,
|
|
389
|
+
values or None,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return fields
|
|
393
|
+
|
|
394
|
+
def _handle_script_progress(self, payload: dict[str, Any]) -> None:
|
|
395
|
+
def update() -> None:
|
|
396
|
+
current = self._coerce_float(payload.get("current"))
|
|
397
|
+
if current is None:
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
total = self._coerce_float(payload.get("total"))
|
|
401
|
+
unit = str(payload.get("unit")).strip() if payload.get("unit") else ""
|
|
402
|
+
message = payload.get("message")
|
|
403
|
+
message_text = str(message) if message is not None else ""
|
|
404
|
+
|
|
405
|
+
line = self._format_progress_line(current, total, unit)
|
|
406
|
+
if not line:
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
self._progress_lines.append(line)
|
|
410
|
+
self._progress_lines = self._progress_lines[-1:]
|
|
411
|
+
self._app.state_store.update_script_run(
|
|
412
|
+
phase="running",
|
|
413
|
+
progress_message=message_text,
|
|
414
|
+
progress_line="\n".join(self._progress_lines),
|
|
415
|
+
progress_current=current,
|
|
416
|
+
progress_total=total,
|
|
417
|
+
progress_unit=unit,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
self._app.call_from_thread(update)
|
|
421
|
+
|
|
422
|
+
def _coerce_float(self, value: Any) -> float | None:
|
|
423
|
+
if value is None:
|
|
424
|
+
return None
|
|
425
|
+
try:
|
|
426
|
+
return float(value)
|
|
427
|
+
except (TypeError, ValueError):
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
def _format_progress_line(
|
|
431
|
+
self,
|
|
432
|
+
current: float,
|
|
433
|
+
total: float | None,
|
|
434
|
+
unit: str,
|
|
435
|
+
) -> str | None:
|
|
436
|
+
def fmt(value: float) -> str:
|
|
437
|
+
if abs(value - int(value)) < 1e-6:
|
|
438
|
+
return str(int(value))
|
|
439
|
+
return f"{value:.2f}".rstrip("0").rstrip(".")
|
|
440
|
+
|
|
441
|
+
if total is not None and total > 0:
|
|
442
|
+
percent = (current / total) * 100
|
|
443
|
+
progress = (
|
|
444
|
+
f"{fmt(current)}/{fmt(total)}"
|
|
445
|
+
+ (f" {unit}" if unit else "")
|
|
446
|
+
+ f" ({percent:.0f}%)"
|
|
447
|
+
)
|
|
448
|
+
else:
|
|
449
|
+
progress = f"{fmt(current)}" + (f" {unit}" if unit else "")
|
|
450
|
+
|
|
451
|
+
elapsed = self._format_elapsed()
|
|
452
|
+
return f"[dim]{elapsed}[/dim] {progress}"
|
|
453
|
+
|
|
454
|
+
def _format_elapsed(self) -> str:
|
|
455
|
+
started_at = self._progress_started_at
|
|
456
|
+
if started_at is None:
|
|
457
|
+
total_seconds = 0
|
|
458
|
+
else:
|
|
459
|
+
total_seconds = int((datetime.now() - started_at).total_seconds())
|
|
460
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
461
|
+
minutes, seconds = divmod(remainder, 60)
|
|
462
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
463
|
+
|
|
464
|
+
def _reset_after_script(self) -> None:
|
|
465
|
+
self._script_running = False
|
|
466
|
+
self._active_script_name = None
|
|
467
|
+
self._active_target = None
|
|
468
|
+
self._progress_lines = []
|
|
469
|
+
self._progress_started_at = None
|
|
470
|
+
self._active_worker = None
|
|
471
|
+
self._abort_worker = None
|
|
472
|
+
self._input_screen = None
|
|
473
|
+
if self._app.is_shutting_down:
|
|
474
|
+
self._app._maybe_exit_after_script()
|
|
475
|
+
return
|
|
476
|
+
self._set_controls_disabled(False)
|
|
477
|
+
self._app.state_store.set_status("Ready")
|
|
478
|
+
self._app.state_store.update_script_run(
|
|
479
|
+
phase="idle",
|
|
480
|
+
script_name=None,
|
|
481
|
+
target_path=None,
|
|
482
|
+
input_prompt=None,
|
|
483
|
+
progress_message="",
|
|
484
|
+
progress_line="",
|
|
485
|
+
progress_current=None,
|
|
486
|
+
progress_total=None,
|
|
487
|
+
progress_unit="",
|
|
488
|
+
result=None,
|
|
489
|
+
transcript_path=None,
|
|
490
|
+
error=None,
|
|
491
|
+
)
|
|
492
|
+
self._app._start_file_tree_watch()
|
|
493
|
+
self._app._maybe_exit_after_script()
|
|
494
|
+
|
|
495
|
+
def _set_controls_disabled(self, disabled: bool) -> None:
|
|
496
|
+
script_manager = self._app.query_one(ScriptManager)
|
|
497
|
+
script_manager.disabled = disabled
|
|
498
|
+
if disabled:
|
|
499
|
+
script_manager.add_class("dimmed")
|
|
500
|
+
else:
|
|
501
|
+
script_manager.remove_class("dimmed")
|
|
502
|
+
|
|
503
|
+
file_tree = self._app.query_one(FileTree)
|
|
504
|
+
file_tree_container = self._app.query_one("#file_list_container")
|
|
505
|
+
file_tree.disabled = disabled
|
|
506
|
+
if disabled:
|
|
507
|
+
file_tree_container.add_class("dimmed")
|
|
508
|
+
else:
|
|
509
|
+
file_tree_container.remove_class("dimmed")
|
|
510
|
+
|
|
511
|
+
def _schedule_post_script_refresh(self) -> None:
|
|
512
|
+
self._app.schedule_refresh_listing(
|
|
513
|
+
delay=self._POST_SCRIPT_REFRESH_DELAY_S,
|
|
514
|
+
suppress_focus=True,
|
|
515
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScriptExitCode(int, Enum):
|
|
9
|
+
SUCCESS = 0
|
|
10
|
+
ERROR = 1
|
|
11
|
+
CONFIRM = 10
|
|
12
|
+
INPUT = 11
|
|
13
|
+
SELECT = 12
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScriptRequestType(str, Enum):
|
|
17
|
+
CONFIRM = "confirm"
|
|
18
|
+
INPUT = "input"
|
|
19
|
+
SELECT = "select"
|
|
20
|
+
PROGRESS = "progress"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ScriptRequest:
|
|
25
|
+
type: ScriptRequestType
|
|
26
|
+
message: str
|
|
27
|
+
payload: Dict[str, Any]
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def from_json(data: Dict[str, Any]) -> "ScriptRequest":
|
|
31
|
+
return ScriptRequest(
|
|
32
|
+
type=ScriptRequestType(data["type"]),
|
|
33
|
+
message=str(data.get("message", "")),
|
|
34
|
+
payload=dict(data.get("payload", {})),
|
|
35
|
+
)
|