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/widgets/forms.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Horizontal, Vertical
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.suggester import SuggestFromList
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
from textual.widgets import Button, Checkbox, Input, Label, SelectionList
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class BooleanField:
|
|
16
|
+
id: str
|
|
17
|
+
label: str
|
|
18
|
+
value: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SelectionField:
|
|
23
|
+
id: str
|
|
24
|
+
label: str
|
|
25
|
+
options: list[str]
|
|
26
|
+
values: list[str] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _selection_list_values(selection_list: SelectionList) -> list[str]:
|
|
30
|
+
selected = selection_list.selected
|
|
31
|
+
if isinstance(selected, list):
|
|
32
|
+
return [str(item) for item in selected]
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PromptDialog(ModalScreen[dict[str, str | bool | list[str]] | None]):
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
message: str,
|
|
40
|
+
id: str,
|
|
41
|
+
*,
|
|
42
|
+
default: str | None = None,
|
|
43
|
+
suggestions: Iterable[str] | None = None,
|
|
44
|
+
boolean_fields: Iterable[BooleanField] | None = None,
|
|
45
|
+
selection_fields: Iterable[SelectionField] | None = None,
|
|
46
|
+
show_text_input: bool = True,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(id=id)
|
|
49
|
+
self._message = message
|
|
50
|
+
self._default = default or ""
|
|
51
|
+
self._suggestions = list(suggestions or [])
|
|
52
|
+
self._bool_fields = list(boolean_fields or [])
|
|
53
|
+
self._selection_fields = list(selection_fields or [])
|
|
54
|
+
self._show_text_input = show_text_input
|
|
55
|
+
|
|
56
|
+
def compose(self) -> ComposeResult:
|
|
57
|
+
contents: list[Widget] = [Label(self._message, id="dialog_message")]
|
|
58
|
+
if self._show_text_input:
|
|
59
|
+
suggester = None
|
|
60
|
+
if self._suggestions:
|
|
61
|
+
suggester = SuggestFromList(
|
|
62
|
+
self._suggestions,
|
|
63
|
+
case_sensitive=False,
|
|
64
|
+
)
|
|
65
|
+
contents.append(
|
|
66
|
+
Input(value=self._default, id="prompt_input", suggester=suggester)
|
|
67
|
+
)
|
|
68
|
+
for field in self._selection_fields:
|
|
69
|
+
contents.append(
|
|
70
|
+
Label(
|
|
71
|
+
field.label,
|
|
72
|
+
id=f"{field.id}_label",
|
|
73
|
+
classes="selection_list_subtitle",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
contents.append(
|
|
77
|
+
SelectionList(
|
|
78
|
+
*[(option, option) for option in field.options],
|
|
79
|
+
id=field.id,
|
|
80
|
+
classes="prompt_selection_list",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
contents.append(
|
|
84
|
+
Horizontal(
|
|
85
|
+
*(
|
|
86
|
+
Checkbox(label=field.label, value=field.value, id=field.id)
|
|
87
|
+
for field in self._bool_fields
|
|
88
|
+
),
|
|
89
|
+
id="prompt_flags",
|
|
90
|
+
classes="hidden" if not self._bool_fields else "",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
contents.append(
|
|
94
|
+
Horizontal(
|
|
95
|
+
Button("OK", id="ok", variant="primary", flat=True),
|
|
96
|
+
Button("Cancel", id="cancel", flat=True),
|
|
97
|
+
classes="dialog_buttons",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
yield Vertical(*contents, id="dialog_container")
|
|
101
|
+
|
|
102
|
+
def on_mount(self) -> None:
|
|
103
|
+
if self._show_text_input:
|
|
104
|
+
self.query_one(Input).focus()
|
|
105
|
+
for field in self._selection_fields:
|
|
106
|
+
selection_list = self.query_one(f"#{field.id}", SelectionList)
|
|
107
|
+
if field.values:
|
|
108
|
+
for value in field.values:
|
|
109
|
+
selector = getattr(selection_list, "select", None)
|
|
110
|
+
if callable(selector):
|
|
111
|
+
try:
|
|
112
|
+
selector(value)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
if not self._show_text_input:
|
|
116
|
+
if self._selection_fields:
|
|
117
|
+
self.query_one(SelectionList).focus()
|
|
118
|
+
return
|
|
119
|
+
if self._bool_fields:
|
|
120
|
+
self.query_one(Checkbox).focus()
|
|
121
|
+
return
|
|
122
|
+
self.query_one("#ok", Button).focus()
|
|
123
|
+
|
|
124
|
+
def on_screen_resume(self) -> None:
|
|
125
|
+
if getattr(self, "_dismiss_on_resume", False):
|
|
126
|
+
self.dismiss(None)
|
|
127
|
+
|
|
128
|
+
def _collect_state(self) -> dict[str, str | bool | list[str]]:
|
|
129
|
+
state: dict[str, str | bool | list[str]] = {}
|
|
130
|
+
if self._show_text_input:
|
|
131
|
+
state["value"] = self.query_one(Input).value.strip()
|
|
132
|
+
else:
|
|
133
|
+
state["value"] = ""
|
|
134
|
+
for field in self._bool_fields:
|
|
135
|
+
checkbox = self.query_one(f"#{field.id}", Checkbox)
|
|
136
|
+
state[field.id] = bool(checkbox.value)
|
|
137
|
+
for field in self._selection_fields:
|
|
138
|
+
selection_list = self.query_one(f"#{field.id}", SelectionList)
|
|
139
|
+
state[field.id] = _selection_list_values(selection_list)
|
|
140
|
+
return state
|
|
141
|
+
|
|
142
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
143
|
+
if event.button.id == "ok":
|
|
144
|
+
self.dismiss(self._collect_state())
|
|
145
|
+
else:
|
|
146
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.widgets import ProgressBar, Static
|
|
10
|
+
|
|
11
|
+
from ferp.core.script_runner import ScriptResult
|
|
12
|
+
from ferp.core.state import AppState, AppStateStore, ScriptRunState
|
|
13
|
+
from ferp.widgets.panels import ContentPanel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScriptOutputPanel(ContentPanel):
|
|
17
|
+
"""Specialized panel responsible for rendering script status and errors."""
|
|
18
|
+
|
|
19
|
+
_MAX_VALUE_CHARS = 2500
|
|
20
|
+
_TRUNCATION_SUFFIX = "\n... (truncated)"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
title: str = "Script Output",
|
|
26
|
+
panel_id: str = "output_panel",
|
|
27
|
+
initial_message: str = "No script output.",
|
|
28
|
+
state_store: AppStateStore,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(initial_message, id=panel_id, title=title)
|
|
31
|
+
self._state_store = state_store
|
|
32
|
+
self._state_subscription = self._handle_state_update
|
|
33
|
+
self._last_script_run: ScriptRunState | None = None
|
|
34
|
+
self._progress_header: Static | None = None
|
|
35
|
+
self._progress_message: Static | None = None
|
|
36
|
+
self._progress_bar: ProgressBar | None = None
|
|
37
|
+
self._progress_status: Static | None = None
|
|
38
|
+
|
|
39
|
+
def on_mount(self) -> None:
|
|
40
|
+
super().on_mount()
|
|
41
|
+
self._state_store.subscribe(self._state_subscription)
|
|
42
|
+
|
|
43
|
+
def on_unmount(self) -> None:
|
|
44
|
+
self._state_store.unsubscribe(self._state_subscription)
|
|
45
|
+
|
|
46
|
+
def show_error(self, error: BaseException) -> None:
|
|
47
|
+
self.remove_children()
|
|
48
|
+
self.update_content("[bold $error]Error:[/bold $error]\n" + escape(str(error)))
|
|
49
|
+
|
|
50
|
+
def show_result(
|
|
51
|
+
self,
|
|
52
|
+
script_name: str,
|
|
53
|
+
target: Path,
|
|
54
|
+
result: ScriptResult,
|
|
55
|
+
transcript_path: Path | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
status = result.status.value.replace("_", " ").title()
|
|
58
|
+
lines: list[str] = [
|
|
59
|
+
f"[bold $text-primary]Script:[/bold $text-primary] {escape(script_name)}",
|
|
60
|
+
f"[bold $text-primary]Target:[/bold $text-primary] {escape(str(target.name))}",
|
|
61
|
+
f"[bold $text-primary]Status:[/bold $text-primary] {status}",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
if result.exit_code is not None:
|
|
65
|
+
lines.append(
|
|
66
|
+
f"[bold $text-primary]Exit Code:[/bold $text-primary] {result.exit_code}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if result.results:
|
|
70
|
+
total = len(result.results)
|
|
71
|
+
for index, payload in enumerate(result.results, start=1):
|
|
72
|
+
header_text, header_style = self._result_header(payload, index, total)
|
|
73
|
+
lines.append(
|
|
74
|
+
f"\n[bold {header_style}]{escape(header_text)}[/bold {header_style}]\n"
|
|
75
|
+
)
|
|
76
|
+
format_hint = payload.get("_format")
|
|
77
|
+
if (
|
|
78
|
+
isinstance(format_hint, str)
|
|
79
|
+
and format_hint.strip().lower() == "json"
|
|
80
|
+
):
|
|
81
|
+
cleaned = {
|
|
82
|
+
key: value
|
|
83
|
+
for key, value in payload.items()
|
|
84
|
+
if not (isinstance(key, str) and key.startswith("_"))
|
|
85
|
+
}
|
|
86
|
+
lines.append(self._format_pair("json", cleaned))
|
|
87
|
+
continue
|
|
88
|
+
for key, value in payload.items():
|
|
89
|
+
if isinstance(key, str) and key.startswith("_"):
|
|
90
|
+
continue
|
|
91
|
+
lines.append(self._format_pair(key, value))
|
|
92
|
+
|
|
93
|
+
if result.error:
|
|
94
|
+
lines.append("\n[bold $error]Error:[/bold $error]\n" + escape(result.error))
|
|
95
|
+
|
|
96
|
+
if transcript_path:
|
|
97
|
+
lines.append(
|
|
98
|
+
f"\n[bold $secondary]Transcript:[/bold $secondary] [$text-secondary]{escape(str(transcript_path.name))}[/$text-secondary]"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self.remove_children()
|
|
102
|
+
self.update_content("\n".join(lines))
|
|
103
|
+
|
|
104
|
+
def _handle_state_update(self, state: AppState) -> None:
|
|
105
|
+
script_run = state.script_run
|
|
106
|
+
if self._last_script_run == script_run:
|
|
107
|
+
return
|
|
108
|
+
self._last_script_run = script_run
|
|
109
|
+
self._render_script_state(script_run)
|
|
110
|
+
|
|
111
|
+
def _render_script_state(self, script_run: ScriptRunState) -> None:
|
|
112
|
+
phase = script_run.phase
|
|
113
|
+
if phase == "running":
|
|
114
|
+
self._render_progress(script_run)
|
|
115
|
+
return
|
|
116
|
+
if phase == "awaiting_input":
|
|
117
|
+
self._clear_progress()
|
|
118
|
+
self.remove_children()
|
|
119
|
+
prompt = script_run.input_prompt or "Input required"
|
|
120
|
+
self.update_content(
|
|
121
|
+
"[bold $primary]Input requested:[/bold $primary] " + escape(prompt)
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
if phase == "result" and script_run.result is not None:
|
|
125
|
+
self._clear_progress()
|
|
126
|
+
script_name = script_run.script_name or "Script"
|
|
127
|
+
target = script_run.target_path or Path(".")
|
|
128
|
+
self.show_result(
|
|
129
|
+
script_name,
|
|
130
|
+
target,
|
|
131
|
+
script_run.result,
|
|
132
|
+
script_run.transcript_path,
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
if phase == "error" and script_run.error:
|
|
136
|
+
self._clear_progress()
|
|
137
|
+
self.remove_children()
|
|
138
|
+
self.update_content(
|
|
139
|
+
"[bold $error]Error:[/bold $error]\n" + escape(script_run.error)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _render_progress(self, script_run: ScriptRunState) -> None:
|
|
143
|
+
script_name = script_run.script_name or "Script"
|
|
144
|
+
target = script_run.target_path
|
|
145
|
+
target_label = escape(str(target)) if target else "Unknown"
|
|
146
|
+
header_text = (
|
|
147
|
+
f"[bold $primary]Script:[/bold $primary] {escape(script_name)}\n"
|
|
148
|
+
f"[bold $primary]Target:[/bold $primary] {target_label}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if self._progress_bar is None or self._progress_header is None:
|
|
152
|
+
self.remove_children()
|
|
153
|
+
self._progress_header = Static(header_text, id="progress_header")
|
|
154
|
+
self._progress_message = Static("", id="progress_message")
|
|
155
|
+
self._progress_bar = ProgressBar(
|
|
156
|
+
total=None,
|
|
157
|
+
show_eta=False,
|
|
158
|
+
id="script_progress_bar",
|
|
159
|
+
show_percentage=False,
|
|
160
|
+
)
|
|
161
|
+
self._progress_status = Static(
|
|
162
|
+
"[dim]Working, please wait...[/dim]",
|
|
163
|
+
id="progress_status",
|
|
164
|
+
)
|
|
165
|
+
self.mount(
|
|
166
|
+
self._progress_header,
|
|
167
|
+
Vertical(
|
|
168
|
+
self._progress_message,
|
|
169
|
+
self._progress_bar,
|
|
170
|
+
self._progress_status,
|
|
171
|
+
id="progress-container",
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
self._progress_header.update(header_text)
|
|
176
|
+
|
|
177
|
+
if self._progress_message is not None:
|
|
178
|
+
self._progress_message.update(escape(script_run.progress_message or ""))
|
|
179
|
+
if self._progress_bar is not None:
|
|
180
|
+
total = script_run.progress_total
|
|
181
|
+
current = script_run.progress_current
|
|
182
|
+
if total is not None and total >= 0 and current is not None:
|
|
183
|
+
self._progress_bar.update(
|
|
184
|
+
total=total, progress=max(0.0, min(current, total))
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
self._progress_bar.update(total=None)
|
|
188
|
+
if self._progress_status is not None and script_run.progress_line:
|
|
189
|
+
self._progress_status.update(script_run.progress_line)
|
|
190
|
+
|
|
191
|
+
def _clear_progress(self) -> None:
|
|
192
|
+
self._progress_header = None
|
|
193
|
+
self._progress_message = None
|
|
194
|
+
self._progress_bar = None
|
|
195
|
+
self._progress_status = None
|
|
196
|
+
|
|
197
|
+
def _format_pair(self, key: Any, value: Any) -> str:
|
|
198
|
+
label = f"[bold $text-primary]{escape(str(key))}:[/bold $text-primary]"
|
|
199
|
+
body = escape(self._stringify_value(value))
|
|
200
|
+
return f"{label} {body}"
|
|
201
|
+
|
|
202
|
+
def _result_header(
|
|
203
|
+
self,
|
|
204
|
+
payload: dict[str, Any],
|
|
205
|
+
index: int,
|
|
206
|
+
total: int,
|
|
207
|
+
) -> tuple[str, str]:
|
|
208
|
+
header_text = f"Result {index} of {total}"
|
|
209
|
+
status = "success"
|
|
210
|
+
custom_title = payload.get("_title")
|
|
211
|
+
if isinstance(custom_title, str) and custom_title.strip():
|
|
212
|
+
header_text = custom_title.strip()
|
|
213
|
+
custom_status = payload.get("_status")
|
|
214
|
+
if isinstance(custom_status, str):
|
|
215
|
+
status = custom_status.strip().lower()
|
|
216
|
+
status_to_style = {
|
|
217
|
+
"success": "$success",
|
|
218
|
+
"ok": "$success",
|
|
219
|
+
"warn": "$warning",
|
|
220
|
+
"warning": "$warning",
|
|
221
|
+
"error": "$error",
|
|
222
|
+
"fail": "$error",
|
|
223
|
+
"failed": "$error",
|
|
224
|
+
}
|
|
225
|
+
return header_text, status_to_style.get(status, "$success")
|
|
226
|
+
|
|
227
|
+
def _stringify_value(self, value: Any) -> str:
|
|
228
|
+
if value is None:
|
|
229
|
+
return "null"
|
|
230
|
+
if isinstance(value, (str, int, float, bool)):
|
|
231
|
+
return self._truncate_value(str(value))
|
|
232
|
+
if isinstance(value, (dict, list)):
|
|
233
|
+
try:
|
|
234
|
+
return self._truncate_value(
|
|
235
|
+
json.dumps(value, indent=2, ensure_ascii=True)
|
|
236
|
+
)
|
|
237
|
+
except (TypeError, ValueError):
|
|
238
|
+
return self._truncate_value(str(value))
|
|
239
|
+
return self._truncate_value(str(value))
|
|
240
|
+
|
|
241
|
+
def _truncate_value(self, value: str) -> str:
|
|
242
|
+
if len(value) <= self._MAX_VALUE_CHARS:
|
|
243
|
+
return value
|
|
244
|
+
return value[: self._MAX_VALUE_CHARS] + self._TRUNCATION_SUFFIX
|
ferp/widgets/panels.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from textual.widgets import Static
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ContentPanel(Static):
|
|
5
|
+
def __init__(self, content: str, id: str, title: str) -> None:
|
|
6
|
+
super().__init__(content, id=id)
|
|
7
|
+
self.title = title
|
|
8
|
+
|
|
9
|
+
def on_mount(self) -> None:
|
|
10
|
+
self.border_title = self.title
|
|
11
|
+
|
|
12
|
+
def update_content(self, content: str) -> None:
|
|
13
|
+
self.update(content)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Container, Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Footer, ListItem, ListView, Static
|
|
11
|
+
|
|
12
|
+
from ferp.fscp.host.process_registry import ProcessRecord, ProcessRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProcessListItem(ListItem):
|
|
16
|
+
"""Row showing a single tracked process."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, record: ProcessRecord) -> None:
|
|
19
|
+
self.record = record
|
|
20
|
+
body = Vertical(
|
|
21
|
+
Static(self._render_title(record), classes="process_row_title"),
|
|
22
|
+
Static(self._render_meta(record), classes="process_row_meta"),
|
|
23
|
+
)
|
|
24
|
+
super().__init__(body, classes="process_row")
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _render_title(record: ProcessRecord) -> str:
|
|
28
|
+
pid = record.pid if record.pid is not None else "?"
|
|
29
|
+
state = record.state.name.replace("_", " ").title()
|
|
30
|
+
return f"{record.metadata.script_name} · pid {pid} · {state}"
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _render_meta(record: ProcessRecord) -> str:
|
|
34
|
+
started = datetime.fromtimestamp(record.start_time).strftime(
|
|
35
|
+
"%Y-%m-%d %H:%M:%S"
|
|
36
|
+
)
|
|
37
|
+
target = record.metadata.target_path
|
|
38
|
+
exit_code = (
|
|
39
|
+
f" · exit {record.exit_code}" if record.exit_code is not None else ""
|
|
40
|
+
)
|
|
41
|
+
mode = f" · {record.termination_mode}" if record.termination_mode else ""
|
|
42
|
+
return f"{target} · started {started}{exit_code}{mode}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProcessListScreen(ModalScreen[None]):
|
|
46
|
+
"""Modal view over currently tracked processes."""
|
|
47
|
+
|
|
48
|
+
BINDINGS = [
|
|
49
|
+
Binding("escape", "close", "Close", show=True),
|
|
50
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
51
|
+
Binding("p", "prune_finished", "Prune finished", show=True),
|
|
52
|
+
Binding("k", "kill_selected", "Kill selected", show=True),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
registry: ProcessRegistry,
|
|
58
|
+
request_abort: Callable[[ProcessRecord], bool],
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self._registry = registry
|
|
62
|
+
self._request_abort = request_abort
|
|
63
|
+
self._status: Static | None = None
|
|
64
|
+
self._status_locked = False
|
|
65
|
+
|
|
66
|
+
def compose(self):
|
|
67
|
+
list_view = ListView(id="process_list_view")
|
|
68
|
+
list_view.border_title = "Processes"
|
|
69
|
+
status = Static("", id="process_list_status")
|
|
70
|
+
self._status = status
|
|
71
|
+
yield Container(
|
|
72
|
+
Vertical(
|
|
73
|
+
list_view,
|
|
74
|
+
status,
|
|
75
|
+
Footer(),
|
|
76
|
+
),
|
|
77
|
+
id="process_list_modal",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
self._refresh()
|
|
82
|
+
list_view = self.query_one(ListView)
|
|
83
|
+
list_view.focus()
|
|
84
|
+
|
|
85
|
+
def on_screen_resume(self) -> None:
|
|
86
|
+
self._refresh()
|
|
87
|
+
|
|
88
|
+
@on(ListView.Highlighted, "#process_list_view")
|
|
89
|
+
def _clear_status_on_move(self, _: ListView.Highlighted) -> None:
|
|
90
|
+
if self._status_locked:
|
|
91
|
+
return
|
|
92
|
+
self._set_status("")
|
|
93
|
+
|
|
94
|
+
def action_close(self) -> None:
|
|
95
|
+
self.dismiss(None)
|
|
96
|
+
|
|
97
|
+
def action_refresh(self) -> None:
|
|
98
|
+
self._refresh()
|
|
99
|
+
|
|
100
|
+
def action_prune_finished(self) -> None:
|
|
101
|
+
removed = self._registry.prune_finished()
|
|
102
|
+
self._set_status(f"Pruned {len(removed)} finished process(es).")
|
|
103
|
+
self._refresh()
|
|
104
|
+
|
|
105
|
+
def action_kill_selected(self) -> None:
|
|
106
|
+
list_view = self.query_one(ListView)
|
|
107
|
+
if list_view.index is None:
|
|
108
|
+
self._set_status("Select a process first.")
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
item = list_view.children[list_view.index]
|
|
112
|
+
except IndexError:
|
|
113
|
+
self._set_status("No process selected.")
|
|
114
|
+
return
|
|
115
|
+
if not isinstance(item, ProcessListItem):
|
|
116
|
+
self._set_status("Invalid selection.")
|
|
117
|
+
return
|
|
118
|
+
record = item.record
|
|
119
|
+
if record.is_terminal:
|
|
120
|
+
self._set_status("Process already finished.")
|
|
121
|
+
return
|
|
122
|
+
killed = self._request_abort(record)
|
|
123
|
+
if killed:
|
|
124
|
+
self._status_locked = True
|
|
125
|
+
self._refresh()
|
|
126
|
+
self._set_status("Termination requested.")
|
|
127
|
+
self.set_timer(0.2, self._refresh)
|
|
128
|
+
self.set_timer(0.8, self._refresh)
|
|
129
|
+
self.set_timer(1.2, self._unlock_status)
|
|
130
|
+
else:
|
|
131
|
+
self._set_status("Unable to terminate this process.")
|
|
132
|
+
|
|
133
|
+
def _refresh(self) -> None:
|
|
134
|
+
list_view = self.query_one(ListView)
|
|
135
|
+
list_view.clear()
|
|
136
|
+
records = sorted(
|
|
137
|
+
self._registry.list_all(), key=lambda rec: rec.start_time, reverse=True
|
|
138
|
+
)
|
|
139
|
+
if not records:
|
|
140
|
+
placeholder = ListItem(
|
|
141
|
+
Static("No tracked processes."),
|
|
142
|
+
classes="process_row process_row--empty",
|
|
143
|
+
)
|
|
144
|
+
placeholder.disabled = True
|
|
145
|
+
list_view.append(placeholder)
|
|
146
|
+
return
|
|
147
|
+
for record in records:
|
|
148
|
+
list_view.append(ProcessListItem(record))
|
|
149
|
+
|
|
150
|
+
def _set_status(self, message: str) -> None:
|
|
151
|
+
status = self._status
|
|
152
|
+
if status is None:
|
|
153
|
+
return
|
|
154
|
+
status.update(message)
|
|
155
|
+
|
|
156
|
+
def _unlock_status(self) -> None:
|
|
157
|
+
self._status_locked = False
|
|
158
|
+
self._set_status("")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from textual.binding import Binding
|
|
2
|
+
from textual.containers import Vertical, VerticalScroll
|
|
3
|
+
from textual.screen import ModalScreen
|
|
4
|
+
from textual.widgets import Label, MarkdownViewer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReadmeScreen(ModalScreen):
|
|
8
|
+
BINDINGS = [
|
|
9
|
+
Binding("j", "scroll_down", "Scroll down", show=False),
|
|
10
|
+
Binding("down", "scroll_down", "Scroll down", show=False),
|
|
11
|
+
Binding("k", "scroll_up", "Scroll up", show=False),
|
|
12
|
+
Binding("up", "scroll_up", "Scroll up", show=False),
|
|
13
|
+
Binding("escape", "close", "Close README", show=True),
|
|
14
|
+
Binding("q", "close", "Close README", show=False),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
def __init__(self, title: str, content: str, id: str) -> None:
|
|
18
|
+
super().__init__(id=id)
|
|
19
|
+
self.heading = title
|
|
20
|
+
self._content = content or "*No README available for this script.*"
|
|
21
|
+
self._markdown: MarkdownViewer | None = None
|
|
22
|
+
|
|
23
|
+
def compose(self):
|
|
24
|
+
markdown = MarkdownViewer(self._content, id="readme_content")
|
|
25
|
+
self._markdown = markdown
|
|
26
|
+
yield Vertical(
|
|
27
|
+
VerticalScroll(markdown, id="readme_scroll"),
|
|
28
|
+
id="readme_modal",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def on_mount(self) -> None:
|
|
32
|
+
self.query_one("#readme_scroll", VerticalScroll).focus()
|
|
33
|
+
|
|
34
|
+
def action_close(self) -> None:
|
|
35
|
+
self.app.pop_screen()
|
|
36
|
+
|
|
37
|
+
def action_scroll_down(self) -> None:
|
|
38
|
+
scroll = self.query_one("#readme_scroll", VerticalScroll)
|
|
39
|
+
scroll.scroll_down()
|
|
40
|
+
|
|
41
|
+
def action_scroll_up(self) -> None:
|
|
42
|
+
scroll = self.query_one("#readme_scroll", VerticalScroll)
|
|
43
|
+
scroll.scroll_up()
|
|
44
|
+
|
|
45
|
+
def update_content(self, title: str, content: str) -> None:
|
|
46
|
+
self.heading = title
|
|
47
|
+
self._content = content or "*No README available for this script.*"
|
|
48
|
+
if self._markdown is not None:
|
|
49
|
+
setter = getattr(self._markdown, "set_markdown", None)
|
|
50
|
+
if callable(setter):
|
|
51
|
+
setter(self._content)
|
|
52
|
+
else:
|
|
53
|
+
updater = getattr(self._markdown, "update", None)
|
|
54
|
+
if callable(updater):
|
|
55
|
+
updater(self._content)
|
|
56
|
+
try:
|
|
57
|
+
self.query_one("#readme_title", Label).update(self.heading)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|