ferp 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. ferp/__init__.py +3 -0
  2. ferp/__main__.py +4 -0
  3. ferp/__version__.py +1 -0
  4. ferp/app.py +9 -0
  5. ferp/cli.py +160 -0
  6. ferp/core/__init__.py +0 -0
  7. ferp/core/app.py +1312 -0
  8. ferp/core/bundle_installer.py +245 -0
  9. ferp/core/command_provider.py +77 -0
  10. ferp/core/dependency_manager.py +59 -0
  11. ferp/core/fs_controller.py +70 -0
  12. ferp/core/fs_watcher.py +144 -0
  13. ferp/core/messages.py +49 -0
  14. ferp/core/path_actions.py +124 -0
  15. ferp/core/paths.py +3 -0
  16. ferp/core/protocols.py +8 -0
  17. ferp/core/script_controller.py +515 -0
  18. ferp/core/script_protocol.py +35 -0
  19. ferp/core/script_runner.py +421 -0
  20. ferp/core/settings.py +16 -0
  21. ferp/core/settings_store.py +69 -0
  22. ferp/core/state.py +156 -0
  23. ferp/core/task_store.py +164 -0
  24. ferp/core/transcript_logger.py +95 -0
  25. ferp/domain/__init__.py +0 -0
  26. ferp/domain/scripts.py +29 -0
  27. ferp/fscp/host/__init__.py +11 -0
  28. ferp/fscp/host/host.py +439 -0
  29. ferp/fscp/host/managed_process.py +113 -0
  30. ferp/fscp/host/process_registry.py +124 -0
  31. ferp/fscp/protocol/__init__.py +13 -0
  32. ferp/fscp/protocol/errors.py +2 -0
  33. ferp/fscp/protocol/messages.py +55 -0
  34. ferp/fscp/protocol/schemas/__init__.py +0 -0
  35. ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
  36. ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
  37. ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
  38. ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
  39. ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
  40. ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
  41. ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
  42. ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
  43. ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
  44. ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
  45. ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
  46. ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
  47. ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
  48. ferp/fscp/protocol/state.py +16 -0
  49. ferp/fscp/protocol/validator.py +123 -0
  50. ferp/fscp/scripts/__init__.py +0 -0
  51. ferp/fscp/scripts/runtime/__init__.py +4 -0
  52. ferp/fscp/scripts/runtime/__main__.py +40 -0
  53. ferp/fscp/scripts/runtime/errors.py +14 -0
  54. ferp/fscp/scripts/runtime/io.py +64 -0
  55. ferp/fscp/scripts/runtime/script.py +149 -0
  56. ferp/fscp/scripts/runtime/state.py +17 -0
  57. ferp/fscp/scripts/runtime/worker.py +13 -0
  58. ferp/fscp/scripts/sdk.py +548 -0
  59. ferp/fscp/transcript/__init__.py +3 -0
  60. ferp/fscp/transcript/events.py +14 -0
  61. ferp/resources/__init__.py +0 -0
  62. ferp/services/__init__.py +3 -0
  63. ferp/services/file_listing.py +120 -0
  64. ferp/services/monday_sync.py +155 -0
  65. ferp/services/releases.py +214 -0
  66. ferp/services/scripts.py +90 -0
  67. ferp/services/update_check.py +130 -0
  68. ferp/styles/index.tcss +638 -0
  69. ferp/themes/themes.py +238 -0
  70. ferp/widgets/__init__.py +17 -0
  71. ferp/widgets/dialogs.py +167 -0
  72. ferp/widgets/file_tree.py +991 -0
  73. ferp/widgets/forms.py +146 -0
  74. ferp/widgets/output_panel.py +244 -0
  75. ferp/widgets/panels.py +13 -0
  76. ferp/widgets/process_list.py +158 -0
  77. ferp/widgets/readme_modal.py +59 -0
  78. ferp/widgets/scripts.py +192 -0
  79. ferp/widgets/task_capture.py +74 -0
  80. ferp/widgets/task_list.py +493 -0
  81. ferp/widgets/top_bar.py +110 -0
  82. ferp-0.7.1.dist-info/METADATA +128 -0
  83. ferp-0.7.1.dist-info/RECORD +87 -0
  84. ferp-0.7.1.dist-info/WHEEL +5 -0
  85. ferp-0.7.1.dist-info/entry_points.txt +2 -0
  86. ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
  87. ferp-0.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import sys
6
+ import zipfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from textual.worker import Worker, WorkerState
12
+
13
+ from ferp.core.dependency_manager import ScriptDependencyManager
14
+ from ferp.widgets.scripts import ScriptManager
15
+
16
+ if TYPE_CHECKING:
17
+ from ferp.core.app import Ferp
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ScriptBundleManifest:
22
+ id: str
23
+ name: str
24
+ version: str
25
+ target: str
26
+ entrypoint: str
27
+ readme: str | None
28
+ dependencies: list[str]
29
+ file_extensions: list[str]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class InstalledBundleResult:
34
+ manifest: ScriptBundleManifest
35
+ script_path: Path
36
+ readme_path: Path | None
37
+
38
+
39
+ class ScriptBundleInstaller:
40
+ """Handles installing zipped FSCP script bundles."""
41
+
42
+ def __init__(self, app: "Ferp") -> None:
43
+ self._app = app
44
+ self._app_root = app.app_root
45
+ self._scripts_dir = app.scripts_dir
46
+ self._config_file = app._paths.config_file
47
+
48
+ def start_install(self, bundle_path: Path) -> None:
49
+ self._app.notify(f"Installing bundle: {bundle_path}", timeout=3)
50
+ self._app.run_worker(
51
+ lambda: self._process_script_bundle(bundle_path),
52
+ group="bundle_install",
53
+ exclusive=True,
54
+ thread=True,
55
+ )
56
+
57
+ def handle_worker_state(self, event: Worker.StateChanged) -> bool:
58
+ worker = event.worker
59
+ if worker.group != "bundle_install":
60
+ return False
61
+
62
+ if event.state is WorkerState.SUCCESS:
63
+ result = worker.result
64
+ if isinstance(result, InstalledBundleResult):
65
+ self._handle_bundle_install_result(result)
66
+ return True
67
+
68
+ if event.state is WorkerState.ERROR:
69
+ error = worker.error or RuntimeError("Bundle installation failed.")
70
+ self._app.show_error(error)
71
+ return True
72
+
73
+ return True
74
+
75
+ # ------------------------------------------------------------------
76
+ # Internal helpers
77
+ # ------------------------------------------------------------------
78
+ def _process_script_bundle(self, bundle_path: Path) -> InstalledBundleResult:
79
+ path = bundle_path.expanduser()
80
+ if not path.exists():
81
+ raise FileNotFoundError(f"No bundle found at {path}")
82
+ if not path.is_file():
83
+ raise ValueError(f"Bundle path must point to a file: {path}")
84
+ if path.suffix.lower() != ".ferp":
85
+ raise ValueError("Bundles must be supplied as .ferp archives.")
86
+
87
+ with zipfile.ZipFile(path) as archive:
88
+ manifest_member = self._find_manifest_member(archive)
89
+ raw_manifest = archive.read(manifest_member).decode("utf-8")
90
+ manifest = self._parse_bundle_manifest(json.loads(raw_manifest))
91
+
92
+ script_member = self._resolve_archive_member(archive, manifest.entrypoint)
93
+ script_bytes = archive.read(script_member)
94
+
95
+ script_dir = self._scripts_dir / manifest.id
96
+ if script_dir.exists():
97
+ shutil.rmtree(script_dir)
98
+ script_dir.mkdir(parents=True, exist_ok=True)
99
+ script_target = script_dir / "script.py"
100
+ script_target.write_bytes(script_bytes)
101
+
102
+ readme_path: Path | None = None
103
+ if manifest.readme:
104
+ readme_member = self._resolve_archive_member(archive, manifest.readme)
105
+ readme_text = archive.read(readme_member).decode("utf-8")
106
+ readme_path = script_dir / "readme.md"
107
+ readme_path.write_text(readme_text, encoding="utf-8")
108
+
109
+ self._update_scripts_config(manifest, script_target)
110
+ dependency_manager = ScriptDependencyManager(
111
+ self._config_file, python_executable=sys.executable
112
+ )
113
+ dependency_manager.install_for_scripts([manifest.id])
114
+
115
+ return InstalledBundleResult(
116
+ manifest=manifest,
117
+ script_path=script_target,
118
+ readme_path=readme_path,
119
+ )
120
+
121
+ def _handle_bundle_install_result(self, result: InstalledBundleResult) -> None:
122
+ message = (
123
+ f"Bundle installed: {result.manifest.name} v{result.manifest.version} "
124
+ f"({result.manifest.id})"
125
+ )
126
+ self._app.notify(message, timeout=4)
127
+ scripts_panel = self._app.query_one(ScriptManager)
128
+ scripts_panel.load_scripts()
129
+ scripts_panel.focus()
130
+
131
+ def _find_manifest_member(self, archive: zipfile.ZipFile) -> str:
132
+ for name in archive.namelist():
133
+ normalized = name.rstrip("/")
134
+ if normalized.endswith("manifest.json"):
135
+ return name
136
+ raise FileNotFoundError("Bundle is missing manifest.json")
137
+
138
+ def _resolve_archive_member(self, archive: zipfile.ZipFile, reference: str) -> str:
139
+ normalized = reference.replace("\\", "/").strip("/")
140
+ if not normalized:
141
+ raise ValueError("Invalid reference inside bundle manifest.")
142
+
143
+ files = [
144
+ info.filename.rstrip("/")
145
+ for info in archive.infolist()
146
+ if not info.is_dir()
147
+ ]
148
+
149
+ for name in files:
150
+ if name == normalized:
151
+ return name
152
+
153
+ matches = [name for name in files if name.endswith(normalized)]
154
+ if not matches:
155
+ raise FileNotFoundError(f"Unable to locate '{reference}' in bundle.")
156
+ if len(matches) > 1:
157
+ raise ValueError(f"Reference '{reference}' is ambiguous in bundle.")
158
+ return matches[0]
159
+
160
+ def _parse_bundle_manifest(self, payload: dict[str, Any]) -> ScriptBundleManifest:
161
+ for key in ("id", "name", "version", "entrypoint", "target"):
162
+ if key not in payload:
163
+ raise ValueError(f"Manifest missing required field '{key}'.")
164
+
165
+ target = str(payload["target"])
166
+ if target not in {
167
+ "current_directory",
168
+ "highlighted_file",
169
+ "highlighted_directory",
170
+ }:
171
+ raise ValueError(
172
+ "Manifest 'target' must be 'current_directory', "
173
+ "'highlighted_file', or 'highlighted_directory'."
174
+ )
175
+
176
+ readme = payload.get("readme")
177
+ if readme is not None:
178
+ readme = str(readme).strip()
179
+ if not readme:
180
+ readme = None
181
+
182
+ deps_raw = payload.get("dependencies", [])
183
+ if deps_raw is None:
184
+ dependencies: list[str] = []
185
+ elif isinstance(deps_raw, list):
186
+ dependencies = [str(dep).strip() for dep in deps_raw if str(dep).strip()]
187
+ else:
188
+ raise ValueError(
189
+ "Manifest 'dependencies' must be an array of requirement strings."
190
+ )
191
+
192
+ file_ext_raw = payload.get("file_extensions", [])
193
+ if file_ext_raw is None:
194
+ file_extensions: list[str] = []
195
+ elif isinstance(file_ext_raw, list):
196
+ file_extensions = [
197
+ str(ext).strip() for ext in file_ext_raw if str(ext).strip()
198
+ ]
199
+ else:
200
+ raise ValueError("Manifest 'file_extensions' must be an array of strings.")
201
+
202
+ return ScriptBundleManifest(
203
+ id=str(payload["id"]),
204
+ name=str(payload["name"]),
205
+ version=str(payload["version"]),
206
+ target=target,
207
+ entrypoint=str(payload["entrypoint"]),
208
+ readme=readme,
209
+ dependencies=dependencies,
210
+ file_extensions=file_extensions,
211
+ )
212
+
213
+ def _update_scripts_config(
214
+ self,
215
+ manifest: ScriptBundleManifest,
216
+ script_path: Path,
217
+ ) -> None:
218
+ config_path = self._config_file
219
+ if not config_path.exists():
220
+ raise FileNotFoundError(f"Unable to locate config at {config_path}")
221
+
222
+ data = json.loads(config_path.read_text())
223
+ scripts = data.setdefault("scripts", [])
224
+
225
+ rel_path = script_path.relative_to(self._app_root).as_posix()
226
+ entry: dict[str, Any] = {
227
+ "id": manifest.id,
228
+ "name": manifest.name,
229
+ "version": manifest.version,
230
+ "script": rel_path,
231
+ "target": manifest.target,
232
+ }
233
+ if manifest.file_extensions:
234
+ entry["file_extensions"] = manifest.file_extensions
235
+ if manifest.dependencies:
236
+ entry["dependencies"] = manifest.dependencies
237
+
238
+ for index, existing in enumerate(scripts):
239
+ if existing.get("id") == manifest.id:
240
+ scripts[index] = entry
241
+ break
242
+ else:
243
+ scripts.append(entry)
244
+
245
+ config_path.write_text(json.dumps(data, indent=2) + "\n")
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, cast
4
+
5
+ from textual.command import CommandListItem, SimpleCommand, SimpleProvider
6
+ from textual.screen import Screen
7
+ from textual.style import Style
8
+
9
+ if TYPE_CHECKING:
10
+ from ferp.core.app import Ferp
11
+
12
+
13
+ class FerpCommandProvider(SimpleProvider):
14
+ """Command palette provider for FERP-specific actions."""
15
+
16
+ _COMMAND_DEFS: tuple[tuple[str, str, str], ...] = (
17
+ (
18
+ "Install Script Bundle",
19
+ "Install a zipped FSCP script bundle into FERP.",
20
+ "_command_install_script_bundle",
21
+ ),
22
+ (
23
+ "Install/Update Default Scripts",
24
+ "Update scripts from the bundled defaults and overwrite config.json.",
25
+ "_command_install_default_scripts",
26
+ ),
27
+ (
28
+ "Refresh File Tree",
29
+ "Reload the current directory listing.",
30
+ "_command_refresh_file_tree",
31
+ ),
32
+ (
33
+ "Reload Script Catalog",
34
+ "Re-read script metadata from config/config.json.",
35
+ "_command_reload_scripts",
36
+ ),
37
+ (
38
+ "Open Latest Log",
39
+ "Open the most recent transcript log file.",
40
+ "_command_open_latest_log",
41
+ ),
42
+ (
43
+ "Open User Guide",
44
+ "Open the bundled FERP user guide.",
45
+ "_command_open_user_guide",
46
+ ),
47
+ (
48
+ "Show Processes",
49
+ "View and manage tracked script processes.",
50
+ "_command_show_processes",
51
+ ),
52
+ (
53
+ "Set Startup Directory",
54
+ "Update the startup directory stored in settings.json.",
55
+ "_command_set_startup_directory",
56
+ ),
57
+ (
58
+ "Sync Monday Board Cache",
59
+ "Fetch the Monday board and refresh publishers_cache.json.",
60
+ "_command_sync_monday_board",
61
+ ),
62
+ (
63
+ "Upgrade FERP",
64
+ "Run pipx upgrade.",
65
+ "_command_upgrade_app",
66
+ ),
67
+ )
68
+
69
+ def __init__(self, screen: Screen[Any], match_style: Style | None = None) -> None:
70
+ app = cast("Ferp", screen.app)
71
+ commands: list[CommandListItem] = [
72
+ SimpleCommand(label, getattr(app, handler_name), description)
73
+ for label, description, handler_name in self._COMMAND_DEFS
74
+ ]
75
+ super().__init__(screen, commands)
76
+ if match_style is not None:
77
+ self._SimpleProvider__match_style = match_style # type: ignore[attr-defined]
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Iterable, Sequence
8
+
9
+
10
+ class ScriptDependencyManager:
11
+ """Install script dependencies from the user config."""
12
+
13
+ def __init__(self, config_file: Path, python_executable: str | None = None) -> None:
14
+ self._config_file = config_file
15
+ self._python_executable = python_executable or sys.executable
16
+
17
+ def install_for_scripts(self, script_ids: Iterable[str] | None = None) -> None:
18
+ dependencies = self._collect_dependencies(script_ids)
19
+ if not dependencies:
20
+ return
21
+ self._install_dependencies(dependencies)
22
+
23
+ def _collect_dependencies(self, script_ids: Iterable[str] | None) -> list[str]:
24
+ if not self._config_file.exists():
25
+ raise FileNotFoundError(f"Unable to locate config at {self._config_file}")
26
+
27
+ data = json.loads(self._config_file.read_text())
28
+ scripts = data.get("scripts", [])
29
+ selected_ids = {script_id for script_id in script_ids} if script_ids else None
30
+ seen: set[str] = set()
31
+ deps: list[str] = []
32
+
33
+ for script in scripts:
34
+ script_id = str(script.get("id", ""))
35
+ if selected_ids is not None and script_id not in selected_ids:
36
+ continue
37
+ for dep in script.get("dependencies", []) or []:
38
+ dep_text = str(dep).strip()
39
+ if not dep_text or dep_text in seen:
40
+ continue
41
+ seen.add(dep_text)
42
+ deps.append(dep_text)
43
+
44
+ return deps
45
+
46
+ def _install_dependencies(self, dependencies: Sequence[str]) -> None:
47
+ pip_cmd = [self._python_executable, "-m", "pip", "install", *dependencies]
48
+ try:
49
+ subprocess.run(
50
+ pip_cmd,
51
+ check=True,
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+ except subprocess.CalledProcessError as exc:
56
+ stderr = exc.stderr.strip() if exc.stderr else ""
57
+ raise RuntimeError(
58
+ f"Failed to install dependencies ({', '.join(dependencies)}).\n{stderr}"
59
+ ) from exc
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import stat
6
+ from pathlib import Path
7
+
8
+
9
+ class FileSystemController:
10
+ """Encapsulates file-system mutations so the UI stays lean."""
11
+
12
+ def create_path(
13
+ self,
14
+ target: Path,
15
+ *,
16
+ is_directory: bool,
17
+ overwrite: bool = False,
18
+ ) -> Path:
19
+ if target.exists():
20
+ if not overwrite:
21
+ raise FileExistsError(f"{target} already exists")
22
+ if target.is_dir():
23
+ shutil.rmtree(target)
24
+ else:
25
+ target.unlink()
26
+
27
+ if is_directory:
28
+ target.mkdir(parents=True, exist_ok=True)
29
+ else:
30
+ target.parent.mkdir(parents=True, exist_ok=True)
31
+ target.touch()
32
+
33
+ return target
34
+
35
+ def delete_path(self, target: Path) -> None:
36
+ if not target.exists():
37
+ return
38
+
39
+ if target.is_dir():
40
+ def _handle_remove_error(func, path, exc):
41
+ if isinstance(exc, PermissionError):
42
+ os.chmod(path, stat.S_IWRITE)
43
+ func(path)
44
+ return
45
+ raise exc
46
+
47
+ shutil.rmtree(target, onexc=_handle_remove_error)
48
+ else:
49
+ target.unlink()
50
+
51
+ def rename_path(
52
+ self, source: Path, destination: Path, *, overwrite: bool = False
53
+ ) -> Path:
54
+ if not source.exists():
55
+ raise FileNotFoundError(f"{source} does not exist")
56
+
57
+ if destination.exists() and destination != source:
58
+ if not overwrite:
59
+ raise FileExistsError(f"{destination} already exists")
60
+ if destination.is_dir():
61
+ shutil.rmtree(destination)
62
+ else:
63
+ destination.unlink()
64
+
65
+ if source == destination:
66
+ return destination
67
+
68
+ destination.parent.mkdir(parents=True, exist_ok=True)
69
+ source.rename(destination)
70
+ return destination
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Callable
5
+
6
+ from textual.timer import Timer
7
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
8
+ from watchdog.observers.api import ObservedWatch
9
+
10
+ if TYPE_CHECKING:
11
+ from watchdog.observers import Observer as WatchdogObserver
12
+ else:
13
+ from watchdog.observers import Observer as WatchdogObserver
14
+
15
+
16
+ class DirectoryChangeHandler(FileSystemEventHandler):
17
+ """Watchdog handler that forwards filesystem activity to the UI thread."""
18
+
19
+ def __init__(self, notify_change: Callable[[], None]) -> None:
20
+ self._notify_change = notify_change
21
+
22
+ def on_any_event(self, event: FileSystemEvent | None = None) -> None: # type: ignore[override]
23
+ self._notify_change()
24
+
25
+
26
+ class FileTreeWatcher:
27
+ """Manages filesystem watching and debounced refreshes for the FileTree."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ call_from_thread: Callable[[Callable[[], None]], object | None],
33
+ refresh_callback: Callable[[], None],
34
+ missing_callback: Callable[[Path], None] | None = None,
35
+ snapshot_func: Callable[[Path], tuple[str, ...]],
36
+ timer_factory: Callable[[float, Callable[[], None]], Timer],
37
+ debounce_seconds: float = 2.0,
38
+ ) -> None:
39
+ self._call_from_thread = call_from_thread
40
+ self._refresh_callback = refresh_callback
41
+ self._missing_callback = missing_callback
42
+ self._snapshot_func = snapshot_func
43
+ self._timer_factory = timer_factory
44
+ self._debounce_seconds = debounce_seconds
45
+
46
+ self._observer: WatchdogObserver | None = None # type: ignore
47
+ self._watch: ObservedWatch | None = None
48
+ self._handler: DirectoryChangeHandler | None = None
49
+ self._current_directory: Path | None = None
50
+ self._refresh_timer: Timer | None = None
51
+ self._last_snapshot: tuple[str, ...] | None = None
52
+
53
+ def start(self, directory: Path) -> None:
54
+ """Ensure the watcher is running and observing the provided directory."""
55
+ self._current_directory = directory
56
+ self._ensure_observer()
57
+ self._restart_watch(directory)
58
+
59
+ def stop(self) -> None:
60
+ """Tear down the watch and stop the observer."""
61
+ if self._refresh_timer is not None:
62
+ self._refresh_timer.stop()
63
+ self._refresh_timer = None
64
+
65
+ observer = self._observer
66
+ if observer is not None:
67
+ if self._watch is not None:
68
+ try:
69
+ observer.unschedule(self._watch)
70
+ except Exception:
71
+ pass
72
+ self._watch = None
73
+ observer.stop()
74
+ observer.join(timeout=1)
75
+
76
+ self._observer = None
77
+ self._handler = None
78
+
79
+ def update_snapshot(self, directory: Path) -> None:
80
+ """Record the latest directory signature to prevent redundant refreshes."""
81
+ if not directory.exists():
82
+ self._last_snapshot = None
83
+ return
84
+ self._last_snapshot = self._snapshot_func(directory)
85
+ self._current_directory = directory
86
+
87
+ def _ensure_observer(self) -> None:
88
+ if self._observer is not None:
89
+ return
90
+
91
+ handler = DirectoryChangeHandler(
92
+ notify_change=self._notify_from_thread,
93
+ )
94
+ observer = WatchdogObserver()
95
+ observer.daemon = True
96
+ observer.start()
97
+
98
+ self._handler = handler
99
+ self._observer = observer
100
+
101
+ def _restart_watch(self, directory: Path) -> None:
102
+ observer = self._observer
103
+ handler = self._handler
104
+ if observer is None or handler is None:
105
+ return
106
+
107
+ if self._watch is not None:
108
+ try:
109
+ observer.unschedule(self._watch)
110
+ except Exception:
111
+ pass
112
+ self._watch = None
113
+
114
+ if not directory.exists():
115
+ return
116
+
117
+ try:
118
+ self._watch = observer.schedule(handler, str(directory), recursive=False)
119
+ except Exception:
120
+ self._watch = None
121
+
122
+ def _queue_refresh(self) -> None:
123
+ if self._refresh_timer is not None:
124
+ return
125
+ self._refresh_timer = self._timer_factory(
126
+ self._debounce_seconds, self._complete_refresh
127
+ )
128
+
129
+ def _complete_refresh(self) -> None:
130
+ self._refresh_timer = None
131
+ directory = self._current_directory
132
+ if directory is None or not directory.exists():
133
+ if directory is not None and self._missing_callback is not None:
134
+ self._missing_callback(directory)
135
+ return
136
+
137
+ signature = self._snapshot_func(directory)
138
+ if signature == self._last_snapshot:
139
+ return
140
+
141
+ self._refresh_callback()
142
+
143
+ def _notify_from_thread(self) -> None:
144
+ self._call_from_thread(self._queue_refresh)
ferp/core/messages.py ADDED
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+
3
+ from textual.message import Message
4
+
5
+ from ferp.domain.scripts import Script
6
+
7
+
8
+ class NavigateRequest(Message):
9
+ def __init__(self, path: Path) -> None:
10
+ self.path = path
11
+ super().__init__()
12
+
13
+
14
+ class RunScriptRequest(Message):
15
+ def __init__(self, script: Script) -> None:
16
+ self.script = script
17
+ super().__init__()
18
+
19
+
20
+ class DirectorySelectRequest(Message):
21
+ def __init__(self, path: Path) -> None:
22
+ self.path = path
23
+ super().__init__()
24
+
25
+
26
+ class ShowReadmeRequest(Message):
27
+ def __init__(self, script: Script, readme_path: Path | None) -> None:
28
+ super().__init__()
29
+ self.script = script
30
+ self.readme_path = readme_path
31
+
32
+
33
+ class CreatePathRequest(Message):
34
+ def __init__(self, base: Path, *, is_directory: bool) -> None:
35
+ super().__init__()
36
+ self.base = base
37
+ self.is_directory = is_directory
38
+
39
+
40
+ class DeletePathRequest(Message):
41
+ def __init__(self, target: Path) -> None:
42
+ super().__init__()
43
+ self.target = target
44
+
45
+
46
+ class RenamePathRequest(Message):
47
+ def __init__(self, target: Path) -> None:
48
+ super().__init__()
49
+ self.target = target