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,493 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Sequence
5
+
6
+ from rich.markup import escape
7
+ from textual import on
8
+ from textual.binding import Binding
9
+ from textual.containers import Container, Horizontal, Vertical
10
+ from textual.screen import ModalScreen
11
+ from textual.timer import Timer
12
+ from textual.widgets import (
13
+ Footer,
14
+ Input,
15
+ Label,
16
+ ListItem,
17
+ ListView,
18
+ LoadingIndicator,
19
+ Static,
20
+ )
21
+
22
+ from ferp.core.state import TaskListState, TaskListStateStore
23
+ from ferp.core.task_store import Task as TodoTask
24
+ from ferp.core.task_store import TaskStore
25
+ from ferp.widgets.dialogs import ConfirmDialog
26
+ from ferp.widgets.task_capture import TaskCaptureModal
27
+
28
+
29
+ class TaskEditModal(ModalScreen[str | None]):
30
+ """Modal editor used for updating an existing task."""
31
+
32
+ BINDINGS = [
33
+ Binding("escape", "close", "Cancel", show=True),
34
+ Binding("enter", "submit", "Update", show=True),
35
+ ]
36
+
37
+ def __init__(self, initial_text: str) -> None:
38
+ super().__init__()
39
+ self._initial_text = initial_text
40
+ self._area: Input | None = None
41
+ self._status: Static | None = None
42
+ self._clear_timer: Timer | None = None
43
+
44
+ def compose(self):
45
+ self._area = Input(id="task_edit_input", placeholder="Edit task")
46
+ self._status = Static("", classes="task_edit_status")
47
+ yield Container(
48
+ Vertical(self._area, self._status, Footer()), id="task_edit_modal"
49
+ )
50
+
51
+ def on_mount(self) -> None:
52
+ area = self.query_one(Input)
53
+ container = self.query_one("#task_edit_modal", Container)
54
+ container.border_title = "Edit Task"
55
+ area.value = self._initial_text
56
+ area.focus()
57
+ self._area = area
58
+
59
+ @on(Input.Submitted, "#task_edit_input")
60
+ def _handle_submit(self, event: Input.Submitted) -> None:
61
+ event.stop()
62
+ self.action_submit()
63
+
64
+ def action_submit(self) -> None:
65
+ area = self._area or self.query_one(Input)
66
+ text = area.value.strip()
67
+ if not text:
68
+ self._set_status("[red]Task text required[/red]")
69
+ return
70
+ self._set_status("")
71
+ self.dismiss(text)
72
+
73
+ def action_close(self) -> None:
74
+ self.dismiss(None)
75
+
76
+ def _set_status(self, message: str) -> None:
77
+ if self._status is None:
78
+ return
79
+ self._status.update(message)
80
+ if self._clear_timer:
81
+ self._clear_timer.stop()
82
+ self._clear_timer = None
83
+ if message:
84
+ self._clear_timer = self.set_timer(1.5, self._clear_status)
85
+
86
+ def _clear_status(self) -> None:
87
+ if self._status:
88
+ self._status.update("")
89
+ if self._clear_timer:
90
+ self._clear_timer.stop()
91
+ self._clear_timer = None
92
+
93
+
94
+ class TaskListItem(ListItem):
95
+ """Visual row representing a task."""
96
+
97
+ def __init__(self, task: TodoTask) -> None:
98
+ self._task_model = task
99
+ self._highlighted = False
100
+ classes = ["task_item"]
101
+ if task.completed:
102
+ classes.append("task_item--completed")
103
+ elif self._is_priority(task):
104
+ classes.append("task_item--priority")
105
+
106
+ self._text_widget = Label("", classes="task_item_text", markup=True)
107
+ self._meta_widget = Label("", classes="task_item_meta")
108
+
109
+ super().__init__(
110
+ Horizontal(
111
+ self._text_widget,
112
+ self._meta_widget,
113
+ ),
114
+ classes=" ".join(classes),
115
+ )
116
+
117
+ def on_mount(self) -> None:
118
+ self._text_widget.update(
119
+ self._render_text_markup(self._task_model, highlighted=False)
120
+ )
121
+ self._meta_widget.update(self._render_meta(self._task_model))
122
+
123
+ def update_task(self, task: TodoTask) -> None:
124
+ self._task_model = task
125
+ self.set_class(task.completed, "task_item--completed")
126
+ self.set_class(
127
+ not task.completed and self._is_priority(task),
128
+ "task_item--priority",
129
+ )
130
+ self._text_widget.update(
131
+ self._render_text_markup(self._task_model, highlighted=self._highlighted)
132
+ )
133
+ self._meta_widget.update(self._render_meta(self._task_model))
134
+
135
+ def set_highlighted(self, highlighted: bool) -> None:
136
+ if self._highlighted == highlighted:
137
+ return
138
+ self._highlighted = highlighted
139
+ self._text_widget.update(
140
+ self._render_text_markup(self._task_model, highlighted=highlighted)
141
+ )
142
+
143
+ @property
144
+ def task_model(self) -> TodoTask:
145
+ return self._task_model
146
+
147
+ @staticmethod
148
+ def _is_priority(task: TodoTask) -> bool:
149
+ stripped = task.text.lstrip()
150
+ return stripped.startswith("!") or stripped.startswith("[!]")
151
+
152
+ @staticmethod
153
+ def _render_text_markup(task: TodoTask, *, highlighted: bool) -> str:
154
+ tokens = task.text.split()
155
+ if not tokens:
156
+ return "[i dim](no text)[/]"
157
+
158
+ tag_color = "$text" if highlighted else "$primary"
159
+ parts: list[str] = []
160
+ for token in tokens:
161
+ safe = escape(token)
162
+ if token.startswith("@") and len(token) > 1:
163
+ parts.append(f"[i {tag_color}]{safe}[/]")
164
+ else:
165
+ parts.append(safe)
166
+
167
+ text = " ".join(parts)
168
+ if task.completed:
169
+ return f"[strike dim]{text}[/]"
170
+ if TaskListItem._is_priority(task):
171
+ return f"[bold]{text}[/]"
172
+ return text
173
+
174
+ @staticmethod
175
+ def _render_meta(task: TodoTask) -> str:
176
+ stamp = task.completed_at or task.created_at
177
+ label = "done" if task.completed else "added"
178
+ return f"{label} {TaskListItem._format_timestamp(stamp)}"
179
+
180
+ @staticmethod
181
+ def _format_timestamp(stamp: datetime) -> str:
182
+ local = stamp.astimezone()
183
+ return local.strftime("%b %d %H:%M")
184
+
185
+
186
+ class TaskListScreen(ModalScreen[None]):
187
+ """Full-screen list of tasks with keyboard interactions."""
188
+
189
+ BINDINGS = [
190
+ Binding("escape", "close", "Close", show=True),
191
+ Binding("q", "close", "Close", show=False),
192
+ Binding("space", "toggle_task", "Toggle completion", show=True),
193
+ Binding("delete", "delete_task", "Delete task", show=True),
194
+ Binding("e", "edit_task", "Edit task", show=True),
195
+ Binding("t", "capture_task", "Add task", show=True),
196
+ Binding("j", "cursor_down", "Next", show=False),
197
+ Binding("k", "cursor_up", "Previous", show=False),
198
+ Binding("C", "clear_completed", "Clear completed", show=True),
199
+ ]
200
+
201
+ def __init__(self, store: TaskStore, *, state_store: TaskListStateStore) -> None:
202
+ super().__init__()
203
+ self._store = store
204
+ self._state_store = state_store
205
+ self._state_subscription = self._handle_state_update
206
+ self._subscription_registered = False
207
+ self._refresh_timer: Timer | None = None
208
+ self._index_assignment_token = 0
209
+ self._filter_input: Input | None = None
210
+ self._pending_focus_list = True
211
+
212
+ def compose(self):
213
+ placeholder = ListItem(
214
+ LoadingIndicator(),
215
+ classes="task_item--loading",
216
+ )
217
+ placeholder.disabled = True
218
+ filter_input = Input(
219
+ id="task_filter_input",
220
+ placeholder="Filter by tags (e.g. @kenny, @cbs)",
221
+ )
222
+ self._filter_input = filter_input
223
+ yield Container(
224
+ Vertical(
225
+ Container(filter_input, id="task_filter_container"),
226
+ ListView(placeholder, id="task_list_view"),
227
+ Footer(id="task_list_footer"),
228
+ ),
229
+ id="task_list_modal",
230
+ )
231
+
232
+ def on_mount(self) -> None:
233
+ list_view = self.query_one(ListView)
234
+ list_view.border_title = "Tasks"
235
+ list_view.focus()
236
+ if self._filter_input is None:
237
+ self._filter_input = self.query_one("#task_filter_input", Input)
238
+ self._state_store.subscribe(self._state_subscription)
239
+ if not self._subscription_registered:
240
+ self._store.subscribe(self._handle_store_update)
241
+ self._subscription_registered = True
242
+
243
+ def on_show(self) -> None:
244
+ if self._refresh_timer is not None:
245
+ self._refresh_timer.stop()
246
+ self._refresh_timer = None
247
+ self._refresh_task_list(focus_list=True)
248
+
249
+ def on_unmount(self) -> None:
250
+ if self._subscription_registered:
251
+ self._store.unsubscribe(self._handle_store_update)
252
+ self._subscription_registered = False
253
+ self._state_store.unsubscribe(self._state_subscription)
254
+ if self._refresh_timer is not None:
255
+ self._refresh_timer.stop()
256
+ self._refresh_timer = None
257
+
258
+ def _handle_store_update(self, _: Sequence[TodoTask]) -> None:
259
+ self._schedule_refresh()
260
+
261
+ def _schedule_refresh(
262
+ self, *, focus_list: bool = True, delay: float = 0.05
263
+ ) -> None:
264
+ if self._refresh_timer is not None:
265
+ self._refresh_timer.stop()
266
+ self._refresh_timer = None
267
+ self._pending_focus_list = focus_list
268
+ self._refresh_timer = self.set_timer(
269
+ delay, self._run_scheduled_refresh, name="task-list-refresh"
270
+ )
271
+
272
+ def _run_scheduled_refresh(self) -> None:
273
+ self._refresh_timer = None
274
+ self._refresh_task_list(focus_list=self._pending_focus_list)
275
+
276
+ def _refresh_task_list(self, *, focus_list: bool = True) -> None:
277
+ list_view = self.query_one(ListView)
278
+
279
+ tasks = self._apply_tag_filter(self._store.sorted())
280
+ if not tasks:
281
+ list_view.index = None
282
+ list_view.clear()
283
+ self._state_store.set_highlighted_task_id(None)
284
+ placeholder_message = "No tasks yet"
285
+ if self._state_store.state.active_tag_filter:
286
+ placeholder_message = "No tasks match selected tags"
287
+ placeholder = ListItem(
288
+ Label(placeholder_message, classes="task_item_text"),
289
+ classes="task_item task_item--empty",
290
+ )
291
+ placeholder.disabled = True
292
+ list_view.append(placeholder)
293
+ self._queue_index_assignment(list_view, None, focus_list=focus_list)
294
+ return
295
+
296
+ reusable_items = self._reuse_items_if_possible(list_view, tasks)
297
+ if reusable_items is not None:
298
+ for item, task in zip(reusable_items, tasks):
299
+ item.update_task(task)
300
+ if focus_list:
301
+ list_view.focus()
302
+ return
303
+
304
+ list_view.index = None
305
+ list_view.clear()
306
+ self._state_store.set_highlighted_task_id(None)
307
+ for task in tasks:
308
+ list_view.append(TaskListItem(task))
309
+ self._queue_index_assignment(list_view, 0, focus_list=focus_list)
310
+
311
+ def _selected_task(self) -> TodoTask | None:
312
+ list_view = self.query_one(ListView)
313
+ item = list_view.highlighted_child
314
+ if isinstance(item, TaskListItem):
315
+ return item.task_model
316
+ return None
317
+
318
+ @on(ListView.Highlighted, "#task_list_view")
319
+ def _handle_highlighted(self, event: ListView.Highlighted) -> None:
320
+ item = event.item
321
+ previous_id = self._state_store.state.highlighted_task_id
322
+ if isinstance(item, TaskListItem):
323
+ if previous_id and previous_id != item.task_model.id:
324
+ previous_item = self._find_task_item(previous_id)
325
+ if previous_item is not None:
326
+ previous_item.set_highlighted(False)
327
+ item.set_highlighted(True)
328
+ self._state_store.set_highlighted_task_id(item.task_model.id)
329
+ return
330
+
331
+ if previous_id:
332
+ previous_item = self._find_task_item(previous_id)
333
+ if previous_item is not None:
334
+ previous_item.set_highlighted(False)
335
+ self._state_store.set_highlighted_task_id(None)
336
+
337
+ def _find_task_item(self, task_id: str) -> TaskListItem | None:
338
+ list_view = self.query_one(ListView)
339
+ for child in list_view.children:
340
+ if isinstance(child, TaskListItem) and child.task_model.id == task_id:
341
+ return child
342
+ return None
343
+
344
+ def _queue_index_assignment(
345
+ self, list_view: ListView, target: int | None, *, focus_list: bool = True
346
+ ) -> None:
347
+ self._index_assignment_token += 1
348
+ token = self._index_assignment_token
349
+
350
+ def assign(idx=target, token=token) -> None:
351
+ if token != self._index_assignment_token:
352
+ return
353
+ self._apply_index(list_view, idx)
354
+ if focus_list:
355
+ list_view.focus()
356
+
357
+ list_view.call_after_refresh(assign)
358
+
359
+ def action_cursor_down(self) -> None:
360
+ list_view = self.query_one(ListView)
361
+ if not self._has_selectable_items(list_view):
362
+ return
363
+ list_view.action_cursor_down()
364
+
365
+ def action_cursor_up(self) -> None:
366
+ list_view = self.query_one(ListView)
367
+ if not self._has_selectable_items(list_view):
368
+ return
369
+ list_view.action_cursor_up()
370
+
371
+ def _apply_index(self, list_view: ListView, target: int | None) -> None:
372
+ children = list_view.children
373
+ if not children or target is None:
374
+ list_view.index = None
375
+ return
376
+ if not self._has_selectable_items(list_view):
377
+ list_view.index = None
378
+ return
379
+ clamped = max(0, min(target, len(children) - 1))
380
+ child = children[clamped]
381
+ if getattr(child, "disabled", False):
382
+ list_view.index = None
383
+ return
384
+ list_view.index = clamped
385
+
386
+ def _has_selectable_items(self, list_view: ListView) -> bool:
387
+ return any(
388
+ not getattr(child, "disabled", False) for child in list_view.children
389
+ )
390
+
391
+ def _reuse_items_if_possible(
392
+ self, list_view: ListView, tasks: list[TodoTask]
393
+ ) -> list[TaskListItem] | None:
394
+ items: list[TaskListItem] = []
395
+ for child in list_view.children:
396
+ if not isinstance(child, TaskListItem):
397
+ return None
398
+ items.append(child)
399
+ if len(items) != len(tasks):
400
+ return None
401
+ if any(item.task_model.id != task.id for item, task in zip(items, tasks)):
402
+ return None
403
+ return items
404
+
405
+ def action_capture_task(self) -> None:
406
+ def handle_submit(text: str) -> None:
407
+ try:
408
+ self._store.add(text)
409
+ except ValueError:
410
+ self.app.bell()
411
+
412
+ self.app.push_screen(TaskCaptureModal(handle_submit))
413
+
414
+ def action_toggle_task(self) -> None:
415
+ task = self._selected_task()
416
+ if task:
417
+ self._store.toggle(task.id)
418
+
419
+ def action_delete_task(self) -> None:
420
+ task = self._selected_task()
421
+ if task:
422
+ self._store.delete(task.id)
423
+
424
+ def action_edit_task(self) -> None:
425
+ task = self._selected_task()
426
+ if not task:
427
+ return
428
+
429
+ def after(result: str | None) -> None:
430
+ if result is None:
431
+ return
432
+ self._store.update_text(task.id, result)
433
+
434
+ self.app.push_screen(TaskEditModal(task.text), after)
435
+
436
+ def action_clear_completed(self) -> None:
437
+ if not any(task.completed for task in self._store.all()):
438
+ return
439
+
440
+ def after(choice: bool | None) -> None:
441
+ if choice:
442
+ self._store.clear_completed()
443
+
444
+ self.app.push_screen(ConfirmDialog("Clear all completed tasks?"), after)
445
+
446
+ def action_close(self) -> None:
447
+ self.dismiss(None)
448
+
449
+ def action_focus_filter(self) -> None:
450
+ field = self._filter_input or self.query_one("#task_filter_input", Input)
451
+ field.focus()
452
+ field.cursor_position = len(field.value)
453
+
454
+ def _apply_tag_filter(self, tasks: list[TodoTask]) -> list[TodoTask]:
455
+ if not self._state_store.state.active_tag_filter:
456
+ return tasks
457
+ return [task for task in tasks if self._task_matches_filter(task)]
458
+
459
+ def _task_matches_filter(self, task: TodoTask) -> bool:
460
+ task_tags = self._extract_tags(task.text)
461
+ for filter_tag in self._state_store.state.active_tag_filter:
462
+ if not self._tag_fuzzy_match(filter_tag, task_tags):
463
+ return False
464
+ return True
465
+
466
+ @staticmethod
467
+ def _tag_fuzzy_match(filter_tag: str, task_tags: set[str]) -> bool:
468
+ query = filter_tag.lower()
469
+ for tag in task_tags:
470
+ if query in tag:
471
+ return True
472
+ return False
473
+
474
+ @staticmethod
475
+ def _extract_tags(text: str) -> set[str]:
476
+ tags: set[str] = set()
477
+ for token in text.split():
478
+ if token.startswith("@") and len(token) > 1:
479
+ tags.add(token.lower())
480
+ return tags
481
+
482
+ @on(Input.Changed, "#task_filter_input")
483
+ def _handle_filter_changed(self, event: Input.Changed) -> None:
484
+ event.stop()
485
+ self._state_store.set_active_tag_filter(self._extract_tags(event.value))
486
+ self._schedule_refresh(focus_list=False, delay=0.15)
487
+
488
+ def _handle_state_update(self, state: TaskListState) -> None:
489
+ if self._filter_input is None:
490
+ return
491
+ if not state.active_tag_filter:
492
+ if self._filter_input.value:
493
+ self._filter_input.value = ""
@@ -0,0 +1,110 @@
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Container, Horizontal
5
+ from textual.reactive import reactive
6
+ from textual.widgets import Label
7
+
8
+ from ferp.core.state import AppState, AppStateStore
9
+
10
+ EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
11
+
12
+
13
+ class TopBar(Container):
14
+ """Custom application title bar."""
15
+
16
+ current_path = reactive("", always_update=True)
17
+ status = reactive("Ready", always_update=True)
18
+ cache_updated_at = reactive(
19
+ datetime(1970, 1, 1, tzinfo=timezone.utc), always_update=True
20
+ )
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ app_title: str | None,
26
+ app_version: str,
27
+ state_store: AppStateStore,
28
+ ) -> None:
29
+ super().__init__()
30
+ self._state_store = state_store
31
+ self._state_subscription = self._handle_state_update
32
+
33
+ self.title_label = Horizontal(
34
+ Label(
35
+ f"{app_title}",
36
+ id="topbar_app_name",
37
+ ),
38
+ Label(
39
+ f"v{app_version}",
40
+ id="topbar_app_version",
41
+ ),
42
+ id="app_meta_container",
43
+ )
44
+ self.status_label = Label("", id="topbar_script_status")
45
+ self.cache_label = Label("", id="topbar_cache")
46
+
47
+ def on_mount(self) -> None:
48
+ self._state_store.subscribe(self._state_subscription)
49
+
50
+ def on_unmount(self) -> None:
51
+ self._state_store.unsubscribe(self._state_subscription)
52
+
53
+ def watch_current_path(self) -> None:
54
+ self._update_status()
55
+
56
+ def watch_status(self) -> None:
57
+ self._update_status()
58
+
59
+ def watch_cache_updated_at(self) -> None:
60
+ self._update_cache_status()
61
+
62
+ def _update_status(self) -> None:
63
+ if not self.current_path:
64
+ self.status_label.update("")
65
+ return
66
+
67
+ status = {
68
+ "ready": f"[dim]Ready - [/dim]{self.current_path}",
69
+ "running": f"[$foreground]Running[/] - {self.current_path}",
70
+ }
71
+ self.status_label.update(
72
+ status["ready"] if self.status == "Ready" else status["running"]
73
+ )
74
+
75
+ def _update_cache_status(self) -> None:
76
+ if self.cache_updated_at == EPOCH:
77
+ self.cache_label.update("[dim]Cache: never updated[/dim]")
78
+ return
79
+
80
+ relative = self._format_relative_time(self.cache_updated_at)
81
+ self.cache_label.update(f"[dim]Cache updated:[/dim] {relative}")
82
+
83
+ def _format_relative_time(self, ts: datetime) -> str:
84
+ now = datetime.now(timezone.utc)
85
+ delta: timedelta = now - ts
86
+
87
+ seconds = int(delta.total_seconds())
88
+
89
+ if seconds < 30:
90
+ return "just now"
91
+ if seconds < 60:
92
+ return f"{seconds}s ago"
93
+ if seconds < 3600:
94
+ return f"{seconds // 60} min ago"
95
+ if seconds < 86400:
96
+ return f"{seconds // 3600} hr ago"
97
+ if seconds < 172800:
98
+ return "yesterday"
99
+
100
+ return f"{seconds // 86400} days ago"
101
+
102
+ def _handle_state_update(self, state: AppState) -> None:
103
+ self.current_path = state.current_path
104
+ self.status = state.status
105
+ self.cache_updated_at = state.cache_updated_at
106
+
107
+ def compose(self) -> ComposeResult:
108
+ yield self.title_label
109
+ yield self.status_label
110
+ yield self.cache_label