diff --git a/projects/khora/ROADMAP.md b/projects/khora/ROADMAP.md index 19648d31..cf01555f 100644 --- a/projects/khora/ROADMAP.md +++ b/projects/khora/ROADMAP.md @@ -9,8 +9,8 @@ The order reflects engineering dependencies rather than product priority. refresh, and zoom shortcuts. 3. **Session persistence — done.** Remember the selected view, zoom, sidebar width, collapsed accounts, and visible calendars across restarts. -4. **Filesystem monitoring** — refresh when local vdir files change, without - requiring the toolbar refresh button. +4. **Filesystem monitoring — done.** Refresh when local vdir files change, + without requiring the toolbar refresh button. 5. **Forward agenda** — replace the selected-day list with a lazily loaded, continuously scrolling agenda starting at the selected date. 6. **Overlapping event layout** — place simultaneous timed events in adjacent diff --git a/projects/khora/khora/khal_adapter.py b/projects/khora/khora/khal_adapter.py index de98807a..17bb3096 100644 --- a/projects/khora/khora/khal_adapter.py +++ b/projects/khora/khora/khal_adapter.py @@ -29,6 +29,15 @@ class KhalRepository: if settings.get("type", "calendar") == "calendar" ) + @property + def watch_paths(self) -> tuple[Path, ...]: + paths = { + Path(settings["path"]).expanduser() + for settings in self._config["calendars"].values() + if settings.get("type", "calendar") == "calendar" + } + return tuple(sorted(paths)) + def events_on(self, day: dt.date, visible: set[str] | None = None) -> tuple[Event, ...]: return self.events_for_days((day,), visible)[day] diff --git a/projects/khora/khora/window.py b/projects/khora/khora/window.py index fb44f18f..9abd6126 100644 --- a/projects/khora/khora/window.py +++ b/projects/khora/khora/window.py @@ -35,6 +35,8 @@ class KhoraWindow(Adw.ApplicationWindow): self._state_store = state_store or StateStore() self._state = self._state_store.load() self._save_source = 0 + self._refresh_source = 0 + self._file_monitors: list[Gio.FileMonitor] = [] self._visible_calendars: set[str] = set() self._collapsed_accounts = set(self._state.collapsed_accounts) self._view_mode = ( @@ -87,6 +89,7 @@ class KhoraWindow(Adw.ApplicationWindow): self._split.connect("notify::position", lambda *_: self._schedule_state_save()) self._toolbar.set_content(self._split) self._refresh() + self._install_file_monitors() self._clock_source = GLib.timeout_add_seconds(30, self._on_clock_tick) self.connect("close-request", self._on_close_request) @@ -224,6 +227,29 @@ class KhoraWindow(Adw.ApplicationWindow): overlay.add_overlay(self._empty) return overlay + def _install_file_monitors(self) -> None: + assert self._repository is not None + for path in getattr(self._repository, "watch_paths", ()): + try: + monitor = Gio.File.new_for_path(str(path)).monitor_directory( + Gio.FileMonitorFlags.WATCH_MOVES, + None, + ) + except GLib.Error: + continue + monitor.connect("changed", self._on_vdir_changed) + self._file_monitors.append(monitor) + + def _on_vdir_changed(self, *_args) -> None: + if self._refresh_source: + GLib.source_remove(self._refresh_source) + self._refresh_source = GLib.timeout_add(500, self._refresh_after_vdir_change) + + def _refresh_after_vdir_change(self) -> bool: + self._refresh_source = 0 + self._refresh() + return GLib.SOURCE_REMOVE + def _error_page(self, error: str) -> Adw.StatusPage: return Adw.StatusPage( icon_name="dialog-error-symbolic", @@ -626,6 +652,12 @@ class KhoraWindow(Adw.ApplicationWindow): if self._save_source: GLib.source_remove(self._save_source) self._save_source = 0 + if self._refresh_source: + GLib.source_remove(self._refresh_source) + self._refresh_source = 0 + for monitor in self._file_monitors: + monitor.cancel() + self._file_monitors.clear() self._save_state() return False diff --git a/projects/khora/tests/test_khal_adapter.py b/projects/khora/tests/test_khal_adapter.py index 80d44b28..57031c3b 100644 --- a/projects/khora/tests/test_khal_adapter.py +++ b/projects/khora/tests/test_khal_adapter.py @@ -1,4 +1,5 @@ import datetime as dt +from pathlib import Path from types import SimpleNamespace from khora.khal_adapter import KhalRepository @@ -25,6 +26,10 @@ def test_calendar_accounts_come_from_their_parent_directory() -> None: calendars = repository.calendars assert tuple(calendar.account for calendar in calendars) == ("personal", "university") + assert repository.watch_paths == ( + Path("/home/example/Calendars/personal/default"), + Path("/home/example/Calendars/university/classes"), + ) def test_event_details_cross_the_khal_boundary() -> None: