git.lucas.co / cce-list
things-to-remember checklist
git clone https://git.lucas.co/cce-list.git

commitb276597df69255c225298f37d3adbe2e455c89ea
parent3da0485001
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 18:09
perf: with a vault, the watcher wakes the list instead of a 1 s poll

The lists directory was re-read every second (read_dir + a stat per file
+ the current pointer) to notice outside edits, and the vault watcher's
batches sat queued until that same tick drained them: nothing woke the
loop when they arrived.

VaultTasks::open takes a wake callback, which sends ListMessage::
VaultChanged from the watcher's thread; tick then applies the batch and,
when not mid-name, compares the lists folder's signature and reloads.
With a vault (the lists are its Tasks/ notes) nothing polls any more and
idle_poll_interval is None; without one, the app's own lists folder is
still looked at every second as before.

Shadow, vault copy: an outside append to a Tasks note shows after
~158 ms instead of ~471 ms; idle wakes unchanged at the runner's 1 s cap
(20 per 20 s); the shown list pixel-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/main.rs        | 45 ++++++++++++++++++++++++++++++++++++---------
 src/vault_tasks.rs | 11 ++++++++++-
 2 files changed, 46 insertions(+), 10 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index e450e79..7811546 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -73,6 +73,8 @@ struct RowView {
 #[derive(Debug, Clone)]
 enum ListMessage {
     Exit,
+    /// The vault watcher queued changes (`VaultTasks::open`'s `wake`).
+    VaultChanged,
 }
 
 /// What the input box is for right now.
@@ -188,6 +190,10 @@ struct ListApp {
     /// frame after an idle sleep, and a list nobody is typing into is idle —
     /// so the once-a-second look actually happened about once a minute.
     watch_at: std::time::Instant,
+    /// Something outside the app may have changed the lists: the vault
+    /// watcher said so, or (unwatched) the cadence came round. Checked in
+    /// `tick` once the input box is not taking a name.
+    outside_change: bool,
 }
 
 impl ListApp {
@@ -551,12 +557,20 @@ impl ListApp {
     }
 }
 
+impl ListApp {
+    /// Whether outside changes to the lists arrive as messages: lists live
+    /// in the vault and its watcher is running.
+    fn watched(&self) -> bool {
+        cce_list::in_vault() && self.vault.as_ref().is_some_and(|v| v.watching())
+    }
+}
+
 impl Application for ListApp {
     type Message = ListMessage;
 
     fn new(
         _qh: &QueueHandle<EngineState<Self>>,
-        _sender: calloop::channel::Sender<Self::Message>,
+        sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
         let mut app = Self {
@@ -576,9 +590,12 @@ impl Application for ListApp {
             pointer: None,
             hovered_row: None,
             disk_sig: Vec::new(),
-            vault: VaultTasks::open(),
+            vault: VaultTasks::open(move || {
+                let _ = sender.send(ListMessage::VaultChanged);
+            }),
             in_vault: false,
             watch_at: std::time::Instant::now(),
+            outside_change: false,
         };
         app.load_from_disk();
         app
@@ -598,14 +615,19 @@ impl Application for ListApp {
     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
         match msg {
             ListMessage::Exit => *exit = true,
+            // Applied by `tick`, which the message's turn runs next.
+            ListMessage::VaultChanged => self.outside_change = true,
         }
     }
 
-    /// The directory watch in `tick` is work the runner cannot see — nothing
-    /// redraws until the files change underneath us — so name the cadence the
-    /// loop has to come back at.
+    /// With a vault, the lists are its `Tasks/` notes and the vault watcher
+    /// wakes the loop when they change (`ListMessage::VaultChanged`), so
+    /// nothing needs polling. Without one, the app's own lists folder is
+    /// re-read every [`WATCH_EVERY`] as before — work the runner cannot see,
+    /// so name the cadence. Until 2026-10-06 the folder was re-read every
+    /// second either way, and the watcher's batches waited for that tick.
     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
-        Some(WATCH_EVERY)
+        (!self.watched()).then_some(WATCH_EVERY)
     }
 
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
@@ -628,11 +650,16 @@ impl Application for ListApp {
         }
         // Outside changes (a vault sync, a hand edit) show up without a
         // relaunch — but never while typing a name, which a reload would
-        // interrupt; that waits a second.
+        // interrupt; that waits until the name is done. Watched, a change
+        // is announced; unwatched, the folder is looked at on the cadence.
         let now = std::time::Instant::now();
-        if now >= self.watch_at {
+        if !self.watched() && now >= self.watch_at {
             self.watch_at = now + WATCH_EVERY;
-            if self.mode == Mode::Items && disk_signature() != self.disk_sig {
+            self.outside_change = true;
+        }
+        if self.outside_change && self.mode == Mode::Items {
+            self.outside_change = false;
+            if disk_signature() != self.disk_sig {
                 self.load_from_disk();
                 *needs_rebuild = true;
             }
diff --git a/src/vault_tasks.rs b/src/vault_tasks.rs
index 883d328..d522a76 100644
--- a/src/vault_tasks.rs
+++ b/src/vault_tasks.rs
@@ -54,13 +54,17 @@ pub fn build_rows<'a>(tasks: impl Iterator<Item = (&'a str, &'a Task)>, ticked:
 
 impl VaultTasks {
     /// The configured vault's tasks, or `None` when no vault is set up.
-    pub fn open() -> Option<VaultTasks> {
+    /// `wake` runs on the watcher's thread after each batch is queued, so
+    /// the app's loop comes round to [`poll`](Self::poll) instead of
+    /// finding the batch on its next timed tick.
+    pub fn open(wake: impl Fn() + Send + 'static) -> Option<VaultTasks> {
         let root = cce_vault::config::vault_root(None).ok()?;
         let index = Index::open(&root, true).map_err(|e| log::warn!("vault tasks: {e}")).ok()?;
         let pending = Arc::new(Mutex::new(Vec::new()));
         let queue = pending.clone();
         let watcher = VaultWatcher::spawn(&root, move |paths| {
             queue.lock().unwrap_or_else(|e| e.into_inner()).extend(paths);
+            wake();
         })
         .map_err(|e| log::warn!("vault watcher: {e}"))
         .ok();
@@ -73,6 +77,11 @@ impl VaultTasks {
         self.rows = build_rows(self.index.tasks(), &self.ticked);
     }
 
+    /// Whether the vault watcher is running (it can fail to start).
+    pub fn watching(&self) -> bool {
+        self._watcher.is_some()
+    }
+
     /// Apply queued vault changes; true when the rows may have changed.
     pub fn poll(&mut self) -> bool {
         let paths: Vec<PathBuf> = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));