feat(projects/khora): refresh on vdir changes

Monitor local calendar directories and debounce automatic refreshes when vdirsyncer or another application changes event files.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 16:15:11 -03:00
parent cb7fe0bb7d
commit 2957f91fb8
4 changed files with 48 additions and 2 deletions
+2 -2
View File
@@ -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
+9
View File
@@ -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]
+32
View File
@@ -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
@@ -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: