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,421 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import sys
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from functools import partial
10
+ from multiprocessing import util as mp_util
11
+ from multiprocessing.connection import Connection
12
+ from pathlib import Path
13
+ from runpy import run_path
14
+ from threading import Lock
15
+ from typing import Any, Callable, Literal
16
+
17
+ from ferp.fscp.host import Host
18
+ from ferp.fscp.host.managed_process import WorkerFn
19
+ from ferp.fscp.host.process_registry import (
20
+ ProcessMetadata,
21
+ ProcessRegistry,
22
+ )
23
+ from ferp.fscp.protocol.messages import Message, MessageDirection, MessageType
24
+ from ferp.fscp.protocol.state import HostState
25
+ from ferp.fscp.scripts.runtime.io import configure_connection
26
+ from ferp.fscp.transcript.events import TranscriptEvent
27
+ from ferp.services.scripts import ScriptExecutionContext
28
+
29
+
30
+ def _patch_spawnv_passfds() -> None:
31
+ """Work around macOS passing invalid fds to spawnv."""
32
+ original_spawn = mp_util.spawnv_passfds
33
+
34
+ def safe_spawn(exe, args, passfds): # type: ignore[override]
35
+ filtered = tuple(fd for fd in passfds if isinstance(fd, int) and fd >= 0)
36
+ return original_spawn(exe, args, filtered)
37
+
38
+ mp_util.spawnv_passfds = safe_spawn # type: ignore[assignment]
39
+
40
+
41
+ _patch_spawnv_passfds()
42
+
43
+
44
+ def _read_app_version() -> str:
45
+ try:
46
+ from ferp.__version__ import __version__
47
+ except Exception:
48
+ return "unknown"
49
+ return __version__
50
+
51
+
52
+ def _read_build_label() -> str:
53
+ if os.environ.get("FERP_DEV_CONFIG") == "1":
54
+ return "dev"
55
+ return "release"
56
+
57
+
58
+ def _read_os_version() -> str:
59
+ if sys.platform == "darwin":
60
+ mac_version = platform.mac_ver()[0]
61
+ return mac_version or platform.release()
62
+ if sys.platform.startswith("win"):
63
+ return platform.version()
64
+ return platform.release()
65
+
66
+
67
+ def _build_environment(app_root: Path, cache_dir: Path) -> dict[str, Any]:
68
+ """Build the SDK environment payload for script initialization."""
69
+ return {
70
+ "app": {
71
+ "name": "ferp",
72
+ "version": _read_app_version(),
73
+ "build": _read_build_label(),
74
+ },
75
+ "host": {
76
+ "platform": sys.platform,
77
+ "os": platform.system(),
78
+ "os_version": _read_os_version(),
79
+ "arch": platform.machine(),
80
+ "python": platform.python_version(),
81
+ },
82
+ "paths": {
83
+ "app_root": str(app_root),
84
+ "cwd": str(Path.cwd()),
85
+ "cache_dir": str(cache_dir),
86
+ },
87
+ }
88
+
89
+
90
+ class ScriptStatus(Enum):
91
+ COMPLETED = "completed"
92
+ FAILED = "failed"
93
+ WAITING_INPUT = "waiting_input"
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ScriptInputRequest:
98
+ id: str
99
+ prompt: str
100
+ default: str | None = None
101
+ secret: bool = False
102
+ mode: Literal["input", "confirm"] = "input"
103
+ fields: list[dict[str, Any]] = field(default_factory=list)
104
+ suggestions: list[str] = field(default_factory=list)
105
+ show_text_input: bool = True
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ScriptResult:
110
+ status: ScriptStatus
111
+ transcript: list[TranscriptEvent] = field(default_factory=list)
112
+ results: list[dict[str, Any]] = field(default_factory=list)
113
+ exit_code: int | None = None
114
+ error: str | None = None
115
+ input_request: ScriptInputRequest | None = None
116
+
117
+
118
+ @dataclass
119
+ class HostSession:
120
+ context: ScriptExecutionContext
121
+ host: Host
122
+ pending_request: ScriptInputRequest | None = None
123
+
124
+
125
+ def _script_worker_entry(script_path: str, app_root: str, conn: Connection) -> None:
126
+ os.chdir(app_root)
127
+ configure_connection(conn)
128
+ run_path(script_path, run_name="__main__")
129
+
130
+
131
+ class ScriptRunner:
132
+ """Run FSCP-compatible scripts inside a managed Host."""
133
+
134
+ _TERMINAL_STATES = {
135
+ HostState.TERMINATED,
136
+ HostState.ERR_PROTOCOL,
137
+ HostState.ERR_TRANSPORT,
138
+ }
139
+
140
+ def __init__(
141
+ self,
142
+ app_root: Path,
143
+ cache_dir: Path,
144
+ progress_handler: Callable[[dict[str, Any]], None] | None = None,
145
+ process_registry: ProcessRegistry | None = None,
146
+ ) -> None:
147
+ self.app_root = app_root
148
+ self.cache_dir = cache_dir
149
+ self._session: HostSession | None = None
150
+ self._lock = Lock()
151
+ self._progress_handler = progress_handler
152
+ self.process_registry = process_registry or ProcessRegistry()
153
+
154
+ @property
155
+ def active_script_name(self) -> str | None:
156
+ session = self._session
157
+ if session is None:
158
+ return None
159
+ return session.context.script.name
160
+
161
+ @property
162
+ def active_target(self) -> Path | None:
163
+ session = self._session
164
+ if session is None:
165
+ return None
166
+ return session.context.target_path
167
+
168
+ @property
169
+ def active_process_handle(self) -> str | None:
170
+ session = self._session
171
+ if session is None:
172
+ return None
173
+ return session.host.process_handle
174
+
175
+ def start(self, context: ScriptExecutionContext) -> ScriptResult:
176
+ worker = self._create_worker(context.script_path)
177
+ metadata = ProcessMetadata(
178
+ script_name=context.script.name,
179
+ script_id=context.script.id,
180
+ target_path=context.target_path,
181
+ )
182
+ host = Host(
183
+ worker=worker,
184
+ process_registry=self.process_registry,
185
+ process_metadata=metadata,
186
+ )
187
+ session = HostSession(context=context, host=host)
188
+
189
+ with self._lock:
190
+ if self._session is not None:
191
+ raise RuntimeError("A script is already running.")
192
+ self._session = session
193
+
194
+ try:
195
+ host.start()
196
+ environment = _build_environment(self.app_root, self.cache_dir)
197
+ init_payload = {
198
+ "target": {
199
+ "path": str(context.target_path),
200
+ "kind": context.target_kind,
201
+ },
202
+ "params": {
203
+ "script": {
204
+ "id": context.script.id,
205
+ "name": context.script.name,
206
+ "version": context.script.version,
207
+ "path": str(context.script_path),
208
+ },
209
+ },
210
+ "environment": environment,
211
+ }
212
+ host.send(Message(type=MessageType.INIT, payload=init_payload))
213
+ return self._drive_host(session)
214
+ except Exception:
215
+ with self._lock:
216
+ self._session = None
217
+ host.shutdown(force=True)
218
+ raise
219
+
220
+ def provide_input(self, value: str) -> ScriptResult:
221
+ with self._lock:
222
+ session = self._require_session()
223
+ request = session.pending_request
224
+ if request is None:
225
+ raise RuntimeError("No pending input request.")
226
+ payload = {"id": request.id, "value": value}
227
+ session.pending_request = None
228
+ host = session.host
229
+
230
+ host.provide_input(payload)
231
+ return self._drive_host(session)
232
+
233
+ def abort(
234
+ self,
235
+ reason: str | None = None,
236
+ *,
237
+ graceful_timeout: float = 2.0,
238
+ ) -> ScriptResult | None:
239
+ with self._lock:
240
+ session = self._session
241
+ if session is None:
242
+ return None
243
+ self._session = None
244
+
245
+ host = session.host
246
+ try:
247
+ if graceful_timeout > 0:
248
+ try:
249
+ host.request_cancel()
250
+ except Exception:
251
+ pass
252
+
253
+ deadline = time.time() + graceful_timeout
254
+ while time.time() < deadline:
255
+ host.poll()
256
+ updates = host.drain_progress_updates()
257
+ if updates:
258
+ for payload in updates:
259
+ self._publish_progress(payload)
260
+ if host.state in self._TERMINAL_STATES:
261
+ break
262
+ time.sleep(0.05)
263
+
264
+ if host.state not in self._TERMINAL_STATES:
265
+ host.shutdown(force=True)
266
+ finally:
267
+ return ScriptResult(
268
+ status=ScriptStatus.FAILED,
269
+ transcript=list(host.transcript),
270
+ results=list(host.results),
271
+ error=reason or "Script canceled.",
272
+ )
273
+
274
+ def _publish_progress(self, payload: dict[str, Any]) -> None:
275
+ handler = self._progress_handler
276
+ if handler:
277
+ handler(payload)
278
+
279
+ # ------------------------------------------------------------------
280
+ # Internal helpers
281
+ # ------------------------------------------------------------------
282
+
283
+ def _drive_host(self, session: HostSession) -> ScriptResult:
284
+ host = session.host
285
+
286
+ while True:
287
+ host.poll()
288
+ updates = host.drain_progress_updates()
289
+ if updates:
290
+ for payload in updates:
291
+ self._publish_progress(payload)
292
+
293
+ if host.state is HostState.AWAITING_INPUT:
294
+ request = self._extract_input_request(host)
295
+ session.pending_request = request
296
+ return ScriptResult(
297
+ status=ScriptStatus.WAITING_INPUT,
298
+ transcript=list(host.transcript),
299
+ results=list(host.results),
300
+ input_request=request,
301
+ )
302
+
303
+ if host.state in self._TERMINAL_STATES:
304
+ return self._finalize(session)
305
+
306
+ time.sleep(0.05)
307
+
308
+ def _finalize(self, session: HostSession) -> ScriptResult:
309
+ host = session.host
310
+ transcript = list(host.transcript)
311
+ results = list(host.results)
312
+ exit_code = self._extract_exit_code(transcript)
313
+ state = host.state
314
+
315
+ success = state is HostState.TERMINATED and (exit_code in (None, 0))
316
+ status = ScriptStatus.COMPLETED if success else ScriptStatus.FAILED
317
+ error = None if success else self._derive_error_message(host, exit_code)
318
+
319
+ self._cleanup_session()
320
+
321
+ return ScriptResult(
322
+ status=status,
323
+ transcript=transcript,
324
+ results=results,
325
+ exit_code=exit_code,
326
+ error=error,
327
+ )
328
+
329
+ def _cleanup_session(self) -> None:
330
+ with self._lock:
331
+ self._session = None
332
+
333
+ def _require_session(self) -> HostSession:
334
+ session = self._session
335
+ if session is None:
336
+ raise RuntimeError("No active FSCP session.")
337
+ return session
338
+
339
+ def _create_worker(self, script_path: Path) -> WorkerFn:
340
+ return partial(
341
+ _script_worker_entry,
342
+ str(script_path),
343
+ str(self.app_root),
344
+ )
345
+
346
+ def _extract_input_request(self, host: Host) -> ScriptInputRequest:
347
+ for event in reversed(host.transcript):
348
+ msg = event.message
349
+ if msg and msg.type is MessageType.REQUEST_INPUT:
350
+ payload = msg.payload or {}
351
+ raw_id = payload.get("id")
352
+ if raw_id is None:
353
+ break
354
+ mode = str(payload.get("mode", "input"))
355
+ if mode not in {"input", "confirm"}:
356
+ mode = "input"
357
+ raw_fields = payload.get("fields")
358
+ fields: list[dict[str, Any]] = []
359
+ if isinstance(raw_fields, list):
360
+ for item in raw_fields:
361
+ if isinstance(item, dict):
362
+ fields.append(dict(item))
363
+ raw_suggestions = payload.get("suggestions")
364
+ suggestions: list[str] = []
365
+ if isinstance(raw_suggestions, list):
366
+ for value in raw_suggestions:
367
+ if isinstance(value, str) and value:
368
+ suggestions.append(value)
369
+ show_text_input = payload.get("show_text_input", True)
370
+ if not isinstance(show_text_input, bool):
371
+ show_text_input = True
372
+ return ScriptInputRequest(
373
+ id=str(raw_id),
374
+ prompt=str(payload.get("prompt", "")),
375
+ default=payload.get("default"),
376
+ secret=bool(payload.get("secret", False)),
377
+ mode=mode, # type: ignore
378
+ fields=fields,
379
+ suggestions=suggestions,
380
+ show_text_input=show_text_input,
381
+ )
382
+
383
+ raise RuntimeError("FSCP host entered input state without payload.")
384
+
385
+ def _extract_exit_code(
386
+ self,
387
+ transcript: list[TranscriptEvent],
388
+ ) -> int | None:
389
+ for event in reversed(transcript):
390
+ msg = event.message
391
+ if msg and msg.type is MessageType.EXIT:
392
+ payload = msg.payload or {}
393
+ code = payload.get("code")
394
+ if isinstance(code, int):
395
+ return code
396
+ return None
397
+
398
+ def _derive_error_message(
399
+ self,
400
+ host: Host,
401
+ exit_code: int | None,
402
+ ) -> str:
403
+ detail = self._latest_system_note(host)
404
+
405
+ if host.state is HostState.ERR_PROTOCOL:
406
+ base = "Script failed due to an FSCP protocol violation."
407
+ elif host.state is HostState.ERR_TRANSPORT:
408
+ base = "Script failed due to a transport error."
409
+ else:
410
+ base = "Script exited with errors."
411
+
412
+ if exit_code not in (None, 0):
413
+ base = f"{base} (exit code {exit_code})"
414
+
415
+ return f"{base} {detail}".strip()
416
+
417
+ def _latest_system_note(self, host: Host) -> str:
418
+ for event in reversed(host.transcript):
419
+ if event.direction is MessageDirection.INTERNAL and event.raw:
420
+ return event.raw
421
+ return ""
ferp/core/settings.py ADDED
@@ -0,0 +1,16 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from platformdirs import user_config_path
5
+
6
+ from ferp.core.paths import APP_AUTHOR, APP_NAME
7
+
8
+
9
+ def load_settings(app_root: Path) -> dict:
10
+ path = Path(user_config_path(APP_NAME, APP_AUTHOR)) / "settings.json"
11
+ return json.loads(path.read_text()) if path.exists() else {}
12
+
13
+
14
+ def save_settings(app_root: Path, settings: dict) -> None:
15
+ path = Path(user_config_path(APP_NAME, APP_AUTHOR)) / "settings.json"
16
+ path.write_text(json.dumps(settings, indent=4))
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ class SettingsStore:
9
+ """Load and persist FERP user settings."""
10
+
11
+ def __init__(self, path: Path) -> None:
12
+ self._path = path
13
+
14
+ def load(self) -> dict[str, Any]:
15
+ """Read settings from disk, injecting expected sections."""
16
+ if self._path.exists():
17
+ try:
18
+ data = json.loads(self._path.read_text())
19
+ except (OSError, json.JSONDecodeError):
20
+ data = {}
21
+ else:
22
+ data = {}
23
+ return self._with_defaults(data)
24
+
25
+ def save(self, settings: dict[str, Any]) -> None:
26
+ """Persist settings to disk."""
27
+ self._path.parent.mkdir(parents=True, exist_ok=True)
28
+ self._path.write_text(json.dumps(settings, indent=4))
29
+
30
+ def update_theme(self, settings: dict[str, Any], theme_name: str) -> None:
31
+ """Store the active theme."""
32
+ settings.setdefault("userPreferences", {})["theme"] = theme_name
33
+ self.save(settings)
34
+
35
+ def update_startup_path(self, settings: dict[str, Any], path: Path | str) -> None:
36
+ """Store the startup directory."""
37
+ settings.setdefault("userPreferences", {})["startupPath"] = str(path)
38
+ self.save(settings)
39
+
40
+ def log_preferences(self, settings: dict[str, Any]) -> tuple[int, int]:
41
+ """Return (max_files, max_age_days) for transcript pruning."""
42
+ logs = settings.setdefault("logs", {})
43
+ max_files = self._coerce_positive_int(
44
+ logs.get("maxFiles"), default=50, min_value=1
45
+ )
46
+ max_age_days = self._coerce_positive_int(
47
+ logs.get("maxAgeDays"), default=14, min_value=0
48
+ )
49
+ return max_files, max_age_days
50
+
51
+ def _with_defaults(self, data: dict[str, Any]) -> dict[str, Any]:
52
+ data.setdefault("userPreferences", {})
53
+ data.setdefault("logs", {})
54
+ integrations = data.setdefault("integrations", {})
55
+ integrations.setdefault("monday", {})
56
+ return data
57
+
58
+ def _coerce_positive_int(
59
+ self,
60
+ value: Any,
61
+ *,
62
+ default: int,
63
+ min_value: int,
64
+ ) -> int:
65
+ try:
66
+ number = int(value)
67
+ except (TypeError, ValueError):
68
+ number = default
69
+ return max(min_value, number)
ferp/core/state.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, replace
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from ferp.core.script_runner import ScriptResult
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ScriptRunState:
13
+ phase: str = "idle"
14
+ script_name: str | None = None
15
+ target_path: Path | None = None
16
+ input_prompt: str | None = None
17
+ progress_message: str = ""
18
+ progress_line: str = ""
19
+ progress_current: float | None = None
20
+ progress_total: float | None = None
21
+ progress_unit: str = ""
22
+ result: ScriptResult | None = None
23
+ transcript_path: Path | None = None
24
+ error: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class FileTreeState:
29
+ filter_query: str = ""
30
+ current_listing_path: Path | None = None
31
+ last_selected_path: Path | None = None
32
+ selection_history: dict[Path, Path] = field(default_factory=dict)
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class TaskListState:
37
+ active_tag_filter: frozenset[str] = frozenset()
38
+ highlighted_task_id: str | None = None
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class AppState:
43
+ current_path: str = ""
44
+ status: str = "Ready"
45
+ cache_updated_at: datetime = datetime(1970, 1, 1, tzinfo=timezone.utc)
46
+ script_run: ScriptRunState = field(default_factory=ScriptRunState)
47
+
48
+
49
+ class AppStateStore:
50
+ def __init__(self, initial: AppState | None = None) -> None:
51
+ self._state = initial or AppState()
52
+ self._listeners: set[Callable[[AppState], None]] = set()
53
+
54
+ @property
55
+ def state(self) -> AppState:
56
+ return self._state
57
+
58
+ def subscribe(self, callback: Callable[[AppState], None]) -> None:
59
+ self._listeners.add(callback)
60
+ callback(self._state)
61
+
62
+ def unsubscribe(self, callback: Callable[[AppState], None]) -> None:
63
+ self._listeners.discard(callback)
64
+
65
+ def set_current_path(self, value: str) -> None:
66
+ self._update_state(current_path=value)
67
+
68
+ def set_status(self, value: str) -> None:
69
+ self._update_state(status=value)
70
+
71
+ def set_cache_updated_at(self, value: datetime) -> None:
72
+ self._update_state(cache_updated_at=value)
73
+
74
+ def update_script_run(self, **changes: object) -> None:
75
+ self._update_state(script_run=replace(self._state.script_run, **changes))
76
+
77
+ def _update_state(self, **changes: object) -> None:
78
+ new_state = replace(self._state, **changes)
79
+ if new_state == self._state:
80
+ return
81
+ self._state = new_state
82
+ for callback in list(self._listeners):
83
+ callback(self._state)
84
+
85
+
86
+ class FileTreeStateStore:
87
+ def __init__(self, initial: FileTreeState | None = None) -> None:
88
+ self._state = initial or FileTreeState()
89
+ self._listeners: set[Callable[[FileTreeState], None]] = set()
90
+
91
+ @property
92
+ def state(self) -> FileTreeState:
93
+ return self._state
94
+
95
+ def subscribe(self, callback: Callable[[FileTreeState], None]) -> None:
96
+ self._listeners.add(callback)
97
+ callback(self._state)
98
+
99
+ def unsubscribe(self, callback: Callable[[FileTreeState], None]) -> None:
100
+ self._listeners.discard(callback)
101
+
102
+ def set_filter_query(self, value: str) -> None:
103
+ self._update_state(filter_query=value)
104
+
105
+ def set_current_listing_path(self, value: Path | None) -> None:
106
+ self._update_state(current_listing_path=value)
107
+
108
+ def set_last_selected_path(self, value: Path | None) -> None:
109
+ self._update_state(last_selected_path=value)
110
+
111
+ def clear_selection_history(self) -> None:
112
+ self._update_state(selection_history={})
113
+
114
+ def update_selection_history(self, directory: Path, selected: Path) -> None:
115
+ history = dict(self._state.selection_history)
116
+ history[directory] = selected
117
+ self._update_state(selection_history=history)
118
+
119
+ def _update_state(self, **changes: object) -> None:
120
+ new_state = replace(self._state, **changes)
121
+ if new_state == self._state:
122
+ return
123
+ self._state = new_state
124
+ for callback in list(self._listeners):
125
+ callback(self._state)
126
+
127
+
128
+ class TaskListStateStore:
129
+ def __init__(self, initial: TaskListState | None = None) -> None:
130
+ self._state = initial or TaskListState()
131
+ self._listeners: set[Callable[[TaskListState], None]] = set()
132
+
133
+ @property
134
+ def state(self) -> TaskListState:
135
+ return self._state
136
+
137
+ def subscribe(self, callback: Callable[[TaskListState], None]) -> None:
138
+ self._listeners.add(callback)
139
+ callback(self._state)
140
+
141
+ def unsubscribe(self, callback: Callable[[TaskListState], None]) -> None:
142
+ self._listeners.discard(callback)
143
+
144
+ def set_active_tag_filter(self, value: set[str]) -> None:
145
+ self._update_state(active_tag_filter=frozenset(value))
146
+
147
+ def set_highlighted_task_id(self, value: str | None) -> None:
148
+ self._update_state(highlighted_task_id=value)
149
+
150
+ def _update_state(self, **changes: object) -> None:
151
+ new_state = replace(self._state, **changes)
152
+ if new_state == self._state:
153
+ return
154
+ self._state = new_state
155
+ for callback in list(self._listeners):
156
+ callback(self._state)