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

commitdc5b48b39adc5260b0c98696433ed34013df41a9
parentd086f61dac
authorLucas Galante <[email protected]>
date2026-09-08 10:34
Lists: switch, create, delete - one file per list, synced both ways

The single list.md becomes a lists/ directory of markdown checklists, one
per list, the file stem its title and a first-line <!-- list:ID --> comment
its server identity; a `current` file names the shown one. The title band
is now a Dropdown over the list titles with two trailing entries: "New
list…" turns the input box into a name prompt, "Delete list…" into an
Enter-to-confirm (the last list cannot be deleted). The app re-reads the
directory once a second when its fingerprint changes, so the sync tick or
a hand edit shows up without a relaunch. Rows under an open menu are not
hoverable, the band right of the switcher still drags the window, and the
menu paints through the popover pass on top of everything.

cce-list-sync plans lists three-way like items (state records each
server list's title): local files without an id are created on Google,
renames go the way that changed (local wins on both), a deleted file
deletes the Google list, a list made on the phone becomes a file. Items
then sync per list; a row whose uid belongs to another list has been
moved by hand and is recreated here and deleted there. Guards: a missing
lists/ re-imports rather than deletes, and a run that would delete every
remote list refuses. The iCloud backend maps VTODO calendars to lists
read-only as lists. Legacy list.md is migrated once, its rows split by
the list the state says each belongs to, headers carrying the ids so the
next sync renames the files to the server titles rather than creating
duplicates on the phone.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/bin/sync.rs | 1651 ++++++++++++++++++++++++++++++++++++-------------------
 src/lib.rs      |  297 +++++++++-
 src/main.rs     |  452 ++++++++++++---
 3 files changed, 1735 insertions(+), 665 deletions(-)

diff --git a/src/bin/sync.rs b/src/bin/sync.rs
index ba069cb..444e5c3 100644
--- a/src/bin/sync.rs
+++ b/src/bin/sync.rs
@@ -1,5 +1,5 @@
-//! `cce-list-sync` — two-way sync between a remote task list and cce-list's
-//! markdown checklist.
+//! `cce-list-sync` — two-way sync between remote task lists and cce-list's
+//! markdown checklists.
 //!
 //! Accounts are cce-mail's (accounts.json, owned by cce-system-interface).
 //! Two backends, one of which is chosen per run (`--backend google|icloud`;
@@ -8,11 +8,15 @@
 //! - **Google Tasks**, over its REST API with the OAuth tokens the settings
 //!   app's Google sign-in stores (it requests the `tasks` scope). The
 //!   access token is refreshed in memory each run, never written back. This
-//!   is the backend that reaches the phone.
+//!   is the backend that reaches the phone. Lists sync both ways: a file
+//!   created in cce-list becomes a Google list, a list made on the phone
+//!   becomes a file, renames and deletions follow in either direction.
 //! - **iCloud Reminders**, over CalDAV VTODO with the "cce-mail" keyring
-//!   password. Kept working but unlikely to be useful: an account whose
-//!   Reminders were "upgraded" (CloudKit) exposes only Apple's legacy stub
-//!   list over CalDAV, invisible to the Reminders app.
+//!   password. Each VTODO calendar is a list, read-only as a list (items
+//!   inside it sync both ways; creating, renaming or deleting calendars is
+//!   not attempted). Kept working but unlikely to be useful: an account
+//!   whose Reminders were "upgraded" (CloudKit) exposes only Apple's legacy
+//!   stub list over CalDAV, invisible to the Reminders app.
 //!
 //! The DAV discovery code is deliberately duplicated from cce-calendar-sync
 //! rather than extracted: every crate builds standalone (multi-repo), and
@@ -20,22 +24,25 @@
 //! consumer exists.
 //!
 //! The merge is three-way against `sync-state.json`, the last-synced server
-//! snapshot per uid: a difference between the list and the state is a local
-//! edit to push; between the server and the state, a remote edit to pull;
-//! both changed → local wins (the next tick reconciles). Deletions propagate
-//! both ways, guarded: a missing list.md re-imports instead of deleting, and
-//! a run that would delete most tracked items (>5 and >50%) refuses without
-//! `--force-deletes` — a mangled file must not empty the phone. State
+//! snapshot: for lists (by id: title), and for items (by uid: text, done).
+//! A difference between the files and the state is a local edit to push;
+//! between the server and the state, a remote edit to pull; both changed →
+//! local wins (the next tick reconciles). Deletions propagate both ways,
+//! guarded: a missing `lists/` directory re-imports instead of deleting, a
+//! run that would delete every remote list refuses, and one that would
+//! delete most of a list's tracked items (>5 and >50%) refuses without
+//! `--force-deletes` — a mangled tree must not empty the phone. State
 //! entries record their account, so a run only reasons about its own
-//! backend's items; rows another backend owns pass through untouched.
+//! backend's lists and items; rows another backend owns pass through.
 //!
-//! CalDAV pushes PATCH the fetched iCalendar rather than rebuilding it, so
-//! due dates, notes, and alarms Apple attached survive a checkbox toggle;
-//! Google pushes are field-level PATCHes for the same reason. Recurring
-//! reminders (RRULE) are skipped entirely — completing one means "advance
-//! to the next occurrence", which this checkbox model cannot say.
-//! Server-side completed items that were never tracked are not imported
-//! (years of checked-off junk stays on the phone).
+//! An item whose uid belongs to a different list than the file it sits in
+//! has been moved by hand; it is recreated in the new list and deleted from
+//! the old one (the Tasks API has no cross-list move). CalDAV pushes PATCH
+//! the fetched iCalendar rather than rebuilding it, so due dates, notes, and
+//! alarms Apple attached survive a checkbox toggle; Google pushes are
+//! field-level PATCHes for the same reason. Recurring reminders (RRULE) are
+//! skipped entirely. Server-side completed items that were never tracked
+//! are not imported, and neither are blank-title tasks.
 //!
 //! Usage: `cce-list-sync [--dry-run] [--force-deletes] [--backend google|icloud]`.
 //! Driven by cce-list-sync.timer; harmless to run by hand.
@@ -43,8 +50,9 @@
 use std::collections::{BTreeMap, BTreeSet};
 
 use cce_list::{
-    atomic_write, data_path, load_sync_state, parse_items, save_sync_state, serialize_items,
-    Item, SyncState, SyncedItem,
+    delete_list, legacy_path, list_path, lists_dir, load_current, load_lists, load_sync_state,
+    rename_list, safe_title, save_current, save_list, save_sync_state, Item, ListFile, SyncState,
+    SyncedItem, SyncedList,
 };
 use chrono::Utc;
 
@@ -118,248 +126,729 @@ enum Session {
     Google(String),
 }
 
-// ── Google Tasks ──────────────────────────────────────────────────────────
-
-const TASKS_API: &str = "https://tasks.googleapis.com/tasks/v1";
+// ── Remote model (both backends produce it) ───────────────────────────────
 
-struct GoogleAccount {
-    email: String,
-    refresh_token: String,
-    client_id: String,
-    client_secret: String,
+#[derive(Debug, Clone)]
+struct RemoteList {
+    title: String,
+    etag: String,
+    /// Where a new item in this list is created.
+    create_target: reqwest::Url,
 }
 
-/// The OAuth fields the settings app's Google sign-in writes.
-#[derive(serde::Deserialize)]
-struct OAuthOnDisk {
-    email: String,
-    #[serde(default)]
-    is_oauth: bool,
-    #[serde(default)]
-    refresh_token: Option<String>,
-    #[serde(default)]
-    client_id: Option<String>,
-    #[serde(default)]
-    client_secret: Option<String>,
+#[derive(Debug)]
+struct RemoteTodo {
+    url: reqwest::Url,
+    etag: String,
+    summary: String,
+    done: bool,
+    /// Unfolded logical lines of the full VCALENDAR, for patch-and-PUT
+    /// (CalDAV only; empty for Google).
+    lines: Vec<String>,
+    /// The list (id) the item lives in.
+    list: String,
 }
 
-#[derive(serde::Deserialize, Default)]
-struct GoogleClientConfig {
-    #[serde(default)]
-    client_id: String,
-    #[serde(default)]
-    client_secret: String,
+struct RemoteSnapshot {
+    lists: BTreeMap<String, RemoteList>,
+    todos: BTreeMap<String, RemoteTodo>,
 }
 
-fn google_accounts() -> Result<Vec<GoogleAccount>, String> {
-    let dir = cce_ui::config::cce_config_dir();
-    let path = dir.join("accounts.json");
-    let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
-    let on_disk: Vec<OAuthOnDisk> =
-        serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
-    // An account without its own pinned client credentials falls back to
-    // the global template the settings app maintains.
-    let template: GoogleClientConfig = std::fs::read_to_string(dir.join("google_client.json"))
-        .ok()
-        .and_then(|t| serde_json::from_str(&t).ok())
-        .unwrap_or_default();
-    let mut out = Vec::new();
-    for acc in on_disk {
-        if !acc.is_oauth {
-            continue;
+// ── The pass ──────────────────────────────────────────────────────────────
+
+fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(), String> {
+    let client = reqwest::blocking::Client::builder()
+        .timeout(std::time::Duration::from_secs(60))
+        .build()
+        .map_err(|e| e.to_string())?;
+    let email = backend.email().to_string();
+
+    // Whether there is any local knowledge at all, judged BEFORE load_lists
+    // runs the legacy migration (which creates lists/ from list.md).
+    let had_local = lists_dir().exists() || legacy_path().exists();
+    let mut local = load_lists().map_err(|e| format!("reading lists: {e}"))?;
+    let mut state = load_sync_state().map_err(|e| format!("sync-state.json: {e}"))?;
+    if !had_local && (!state.items.is_empty() || !state.lists.is_empty()) {
+        // The tree is gone (fresh clone, deleted directory). Re-import
+        // rather than reading absence as "delete everything on the server".
+        log::warn!("lists/ missing; discarding sync state and re-importing");
+        state = SyncState::default();
+    }
+
+    let (remote, session) = match backend {
+        Backend::ICloud(acc) => (fetch_icloud(&client, acc)?, Session::ICloud),
+        Backend::Google(acc) => {
+            let token = google_access_token(&client, acc)?;
+            (google_fetch(&client, &token, &acc.email)?, Session::Google(token))
         }
-        let Some(refresh_token) = acc.refresh_token.filter(|t| !t.is_empty()) else {
-            log::warn!("{}: OAuth account without a refresh token; sign in again", acc.email);
+    };
+    let ops = Ops { client: &client, backend, session: &session };
+
+    // ── Lists ───────────────────────────────────────────────────────────
+    let mine_lists: BTreeMap<String, SyncedList> = state
+        .lists
+        .iter()
+        .filter(|(_, v)| v.account == email)
+        .map(|(k, v)| (k.clone(), v.clone()))
+        .collect();
+    let lplan = plan_lists(&local, &mine_lists, &remote.lists);
+    if !force_deletes && !lplan.push_deletes.is_empty() && lplan.push_deletes.len() >= remote.lists.len()
+    {
+        return Err(format!(
+            "refusing to delete every remote list ({}) — if that was really meant, run \
+             cce-list-sync --force-deletes",
+            lplan.push_deletes.len()
+        ));
+    }
+    log::info!(
+        "{email}: lists — pull {} new / {} renamed / {} deleted; push {} new / {} renamed / {} deleted",
+        lplan.pull_new.len(),
+        lplan.pull_renames.len(),
+        lplan.pull_deletes.len(),
+        lplan.push_creates.len(),
+        lplan.push_renames.len(),
+        lplan.push_deletes.len(),
+    );
+    if dry_run {
+        print_list_plan(&lplan, &remote.lists);
+    } else {
+        apply_list_plan(&ops, &lplan, &mut local, &mut state, &remote, &email)?;
+    }
+
+    // The lists this run can sync items for: local files with an id the
+    // server knows (after the list phase, that is every list unless a push
+    // failed and will retry next tick).
+    let mut remote_lists = remote.lists.clone();
+    for (id, l) in &state.lists {
+        // Lists created this run are not in the fetched snapshot yet.
+        if l.account == email && !remote_lists.contains_key(id) {
+            if let Some(target) = ops.create_target_for(id) {
+                remote_lists.insert(
+                    id.clone(),
+                    RemoteList { title: l.title.clone(), etag: l.etag.clone(), create_target: target },
+                );
+            }
+        }
+    }
+
+    // ── Items, per list ─────────────────────────────────────────────────
+    let foreign_uids: BTreeSet<String> = state
+        .items
+        .iter()
+        .filter(|(_, v)| v.account != email)
+        .map(|(k, _)| k.clone())
+        .collect();
+    // Where each uid lives, server-side or as last synced: a row found in a
+    // different file has been moved by hand. Precomputed so the loop below
+    // can take `state` mutably.
+    let owners: BTreeMap<String, String> = state
+        .items
+        .iter()
+        .filter(|(_, v)| v.account == email)
+        .map(|(k, v)| (k.clone(), v.list_id()))
+        .chain(remote.todos.iter().map(|(k, t)| (k.clone(), t.list.clone())))
+        .collect();
+    let owner_of = |uid: &str| -> Option<String> { owners.get(uid).cloned() };
+
+    let mut current_title = load_current();
+    for file in &local {
+        let Some(list_id) = file.id.clone() else {
+            continue; // creation failed this run; retried next tick
+        };
+        if !remote_lists.contains_key(&list_id) {
             continue;
+        }
+        let rows: Vec<Item> = file
+            .items
+            .iter()
+            .filter(|i| i.uid.as_deref().is_none_or(|u| !foreign_uids.contains(u)))
+            .map(|i| {
+                let moved = i
+                    .uid
+                    .as_deref()
+                    .and_then(owner_of)
+                    .is_some_and(|owner| owner != list_id);
+                if moved {
+                    // Recreate here; the old list's plan pushes the delete.
+                    Item { uid: None, ..i.clone() }
+                } else {
+                    i.clone()
+                }
+            })
+            .collect();
+        // Rows that were moved keep their old uid on disk until apply_local
+        // swaps it, so remember which text came from which uid.
+        let moved_from: Vec<(String, String)> = file
+            .items
+            .iter()
+            .filter_map(|i| {
+                let uid = i.uid.as_deref()?;
+                (owner_of(uid)? != list_id).then(|| (i.text.clone(), uid.to_string()))
+            })
+            .collect();
+
+        let mine = SyncState {
+            items: state
+                .items
+                .iter()
+                .filter(|(_, v)| v.account == email && v.list_id() == list_id)
+                .map(|(k, v)| (k.clone(), v.clone()))
+                .collect(),
+            lists: BTreeMap::new(),
         };
-        out.push(GoogleAccount {
-            email: acc.email,
-            refresh_token,
-            client_id: acc.client_id.filter(|s| !s.is_empty()).unwrap_or(template.client_id.clone()),
-            client_secret: acc
-                .client_secret
-                .filter(|s| !s.is_empty())
-                .unwrap_or(template.client_secret.clone()),
+        let todos: BTreeMap<String, &RemoteTodo> = remote
+            .todos
+            .iter()
+            .filter(|(_, t)| t.list == list_id)
+            .map(|(k, v)| (k.clone(), v))
+            .collect();
+        let plan = plan_items(&rows, &mine, &todos);
+
+        if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > mine.items.len()
+        {
+            return Err(format!(
+                "{}: refusing to delete {} of {} tracked items on the server — if the list \
+                 was really emptied on purpose, run cce-list-sync --force-deletes",
+                file.title,
+                plan.push_deletes.len(),
+                mine.items.len()
+            ));
+        }
+        log::info!(
+            "{email}: {} — pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
+            file.title,
+            plan.pull_new.len(),
+            plan.pull_updates.len(),
+            plan.pull_deletes.len(),
+            plan.push_updates.len(),
+            plan.push_creates.len(),
+            plan.push_deletes.len(),
+        );
+        if dry_run {
+            print_item_plan(&plan, &todos);
+            continue;
+        }
+
+        let target = &remote_lists[&list_id].create_target;
+        let created = apply_item_plan(&ops, &plan, &rows, &todos, target, &list_id, &mut state, &email)?;
+
+        // Local side last, as deltas on a FRESH read: the user may have
+        // edited the list while the network calls ran, and rows this plan
+        // does not touch must survive verbatim.
+        let mut fresh = cce_list::load_list(&file.title).unwrap_or_else(|_| ListFile {
+            title: file.title.clone(),
+            id: Some(list_id.clone()),
+            items: Vec::new(),
         });
+        fresh.id = Some(list_id.clone());
+        apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
+        save_list(&fresh).map_err(|e| e.to_string())?;
     }
-    Ok(out)
+
+    if !dry_run {
+        // The app's pointer may name a list this run renamed or removed.
+        if let Some(cur) = current_title.take() {
+            if !list_path(&cur).exists() {
+                if let Some(first) = local.first() {
+                    save_current(&first.title).map_err(|e| e.to_string())?;
+                }
+            }
+        }
+        save_sync_state(&state).map_err(|e| e.to_string())?;
+    }
+    Ok(())
 }
 
-/// A fresh access token from the refresh grant. Tokens last an hour and a
-/// tick is one request burst, so refreshing every run is simpler than
-/// tracking expiry — and keeps this helper from writing accounts.json.
-fn google_access_token(
-    client: &reqwest::blocking::Client,
-    acc: &GoogleAccount,
-) -> Result<String, String> {
-    let resp = client
-        .post("https://oauth2.googleapis.com/token")
-        .form(&[
-            ("client_id", acc.client_id.as_str()),
-            ("client_secret", acc.client_secret.as_str()),
-            ("refresh_token", acc.refresh_token.as_str()),
-            ("grant_type", "refresh_token"),
-        ])
-        .send()
-        .map_err(|e| format!("token refresh: {e}"))?;
-    let status = resp.status();
-    let body: serde_json::Value = resp.json().map_err(|e| format!("token refresh: {e}"))?;
-    if !status.is_success() {
-        // invalid_grant here means the refresh token was revoked or the
-        // consent predates the tasks scope — a re-login fixes both.
-        return Err(format!("token refresh: HTTP {status} {body}"));
+/// The backend operations, bundled so the two phases share one signature.
+struct Ops<'a> {
+    client: &'a reqwest::blocking::Client,
+    backend: &'a Backend,
+    session: &'a Session,
+}
+
+impl Ops<'_> {
+    fn token(&self) -> &str {
+        match self.session {
+            Session::Google(t) => t,
+            Session::ICloud => "",
+        }
+    }
+
+    fn create_target_for(&self, list_id: &str) -> Option<reqwest::Url> {
+        match self.backend {
+            Backend::Google(_) => reqwest::Url::parse(&format!("{TASKS_API}/lists/{list_id}/tasks")).ok(),
+            Backend::ICloud(_) => reqwest::Url::parse(list_id).ok(),
+        }
+    }
+
+    fn list_create(&self, title: &str) -> Result<(String, RemoteList), String> {
+        match self.backend {
+            Backend::Google(_) => google_list_create(self.client, self.token(), title),
+            Backend::ICloud(_) => Err("the iCloud backend cannot create lists".into()),
+        }
+    }
+
+    fn list_rename(&self, id: &str, title: &str) -> Result<String, String> {
+        match self.backend {
+            Backend::Google(_) => google_list_rename(self.client, self.token(), id, title),
+            Backend::ICloud(_) => Err("the iCloud backend cannot rename lists".into()),
+        }
+    }
+
+    fn list_delete(&self, id: &str) -> Result<(), String> {
+        match self.backend {
+            Backend::Google(_) => google_list_delete(self.client, self.token(), id),
+            Backend::ICloud(_) => Err("the iCloud backend cannot delete lists".into()),
+        }
+    }
+
+    fn item_update(&self, todo: &RemoteTodo, text: &str, done: bool) -> Result<String, String> {
+        match self.backend {
+            Backend::ICloud(acc) => {
+                let body = patch_vtodo(&todo.lines, text, done);
+                put_ics(self.client, acc, &todo.url, &body, Some(&todo.etag))
+            }
+            Backend::Google(_) => google_update(self.client, self.token(), &todo.url, text, done),
+        }
+    }
+
+    fn item_create(
+        &self,
+        target: &reqwest::Url,
+        text: &str,
+        done: bool,
+    ) -> Result<(String, reqwest::Url, String), String> {
+        match self.backend {
+            Backend::ICloud(acc) => {
+                let uid = new_uid();
+                let url = target.join(&format!("{uid}.ics")).map_err(|e| e.to_string())?;
+                put_ics(self.client, acc, &url, &new_vtodo(&uid, text, done), None)
+                    .map(|etag| (uid, url, etag))
+            }
+            Backend::Google(_) => google_create(self.client, self.token(), target, text, done),
+        }
+    }
+
+    fn item_delete(&self, url: &reqwest::Url, etag: &str) -> Result<(), String> {
+        match self.backend {
+            Backend::ICloud(acc) => delete_ics(self.client, acc, url, etag),
+            Backend::Google(_) => google_delete(self.client, self.token(), url),
+        }
     }
-    body.get("access_token")
-        .and_then(|v| v.as_str())
-        .map(String::from)
-        .ok_or_else(|| "token refresh: no access_token in response".to_string())
 }
 
-fn google_call(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    method: reqwest::Method,
-    url: &str,
-    query: &[(&str, &str)],
-    body: Option<&serde_json::Value>,
-) -> Result<serde_json::Value, String> {
-    let mut req = client.request(method.clone(), url).bearer_auth(token).query(query);
-    if let Some(b) = body {
-        req = req.json(b);
+// ── List planning (pure; tested) ──────────────────────────────────────────
+
+#[derive(Default, Debug, PartialEq)]
+struct ListPlan {
+    /// Remote ids with no local file and no state: new on the server.
+    pull_new: Vec<String>,
+    /// (id, local title, remote title): renamed on the server.
+    pull_renames: Vec<(String, String, String)>,
+    /// (id, local title): the server list is gone.
+    pull_deletes: Vec<(String, String)>,
+    /// (title, stale id if the file carried one the server does not know).
+    push_creates: Vec<(String, Option<String>)>,
+    /// (id, new title): renamed locally.
+    push_renames: Vec<(String, String)>,
+    /// Ids whose local file is gone.
+    push_deletes: Vec<String>,
+    /// Title/etag unchanged in substance; just record the server's etag.
+    refresh: Vec<String>,
+}
+
+fn plan_lists(
+    local: &[ListFile],
+    state: &BTreeMap<String, SyncedList>,
+    remote: &BTreeMap<String, RemoteList>,
+) -> ListPlan {
+    let mut plan = ListPlan::default();
+    let mut claimed: BTreeSet<&str> = BTreeSet::new();
+    for file in local {
+        match &file.id {
+            None => plan.push_creates.push((file.title.clone(), None)),
+            Some(id) => match (remote.get(id), state.get(id)) {
+                (Some(r), base) => {
+                    claimed.insert(id.as_str());
+                    let remote_title = safe_title(&r.title);
+                    match base {
+                        // Never synced as a list before (e.g. the migrated
+                        // legacy file): the server's name wins.
+                        None if remote_title != file.title => {
+                            plan.pull_renames.push((id.clone(), file.title.clone(), remote_title));
+                        }
+                        None => plan.refresh.push(id.clone()),
+                        Some(b) => {
+                            let base_title = safe_title(&b.title);
+                            let local_changed = file.title != base_title;
+                            let remote_changed = remote_title != base_title;
+                            if local_changed && file.title != remote_title {
+                                plan.push_renames.push((id.clone(), file.title.clone()));
+                            } else if remote_changed && file.title != remote_title {
+                                plan.pull_renames.push((id.clone(), file.title.clone(), remote_title));
+                            } else if r.etag != b.etag || local_changed {
+                                plan.refresh.push(id.clone());
+                            }
+                        }
+                    }
+                }
+                (None, Some(_)) => plan.pull_deletes.push((id.clone(), file.title.clone())),
+                // A header the server never heard of and the state does not
+                // track: recreate rather than orphan the file.
+                (None, None) => plan.push_creates.push((file.title.clone(), Some(id.clone()))),
+            },
+        }
     }
-    let resp = req.send().map_err(|e| format!("{method} {url}: {e}"))?;
-    let status = resp.status();
-    if status == reqwest::StatusCode::NO_CONTENT {
-        return Ok(serde_json::Value::Null);
+    for id in remote.keys() {
+        if claimed.contains(id.as_str()) {
+            continue;
+        }
+        if state.contains_key(id) {
+            plan.push_deletes.push(id.clone());
+        } else {
+            plan.pull_new.push(id.clone());
+        }
     }
-    let text = resp.text().map_err(|e| format!("{method} {url}: {e}"))?;
-    if !status.is_success() {
-        return Err(format!("{method} {url}: HTTP {status} {text}"));
+    plan
+}
+
+fn print_list_plan(plan: &ListPlan, remote: &BTreeMap<String, RemoteList>) {
+    for id in &plan.pull_new {
+        println!("list pull new:    {} ({id})", remote[id].title);
     }
-    if text.trim().is_empty() {
-        return Ok(serde_json::Value::Null);
+    for (id, from, to) in &plan.pull_renames {
+        println!("list pull rename: {from} -> {to} ({id})");
+    }
+    for (id, title) in &plan.pull_deletes {
+        println!("list pull delete: {title} ({id})");
+    }
+    for (title, _) in &plan.push_creates {
+        println!("list push new:    {title}");
+    }
+    for (id, title) in &plan.push_renames {
+        println!("list push rename: -> {title} ({id})");
+    }
+    for id in &plan.push_deletes {
+        println!("list push delete: {} ({id})", remote.get(id).map(|l| l.title.as_str()).unwrap_or("?"));
     }
-    serde_json::from_str(&text).map_err(|e| format!("{method} {url}: bad JSON: {e}"))
 }
 
-/// Every task in every list; the default list is where creates go.
-fn google_fetch(
-    client: &reqwest::blocking::Client,
-    token: &str,
+/// A free file stem for a server title: `Groceries`, then `Groceries (2)`.
+fn unique_local_title(title: &str, taken: &[ListFile]) -> String {
+    let base = safe_title(title);
+    let exists = |t: &str| taken.iter().any(|l| l.title == t) || list_path(t).exists();
+    if !exists(&base) {
+        return base;
+    }
+    (2..)
+        .map(|n| format!("{base} ({n})"))
+        .find(|t| !exists(t))
+        .expect("unbounded")
+}
+
+fn apply_list_plan(
+    ops: &Ops,
+    plan: &ListPlan,
+    local: &mut Vec<ListFile>,
+    state: &mut SyncState,
+    remote: &RemoteSnapshot,
     email: &str,
-) -> Result<RemoteSnapshot, String> {
-    let get = |url: &str, q: &[(&str, &str)]| {
-        google_call(client, token, reqwest::Method::GET, url, q, None)
-    };
-    let default = get(&format!("{TASKS_API}/users/@me/lists/@default"), &[])?;
-    let default_id = default["id"].as_str().ok_or("default task list has no id")?.to_string();
-    let lists = get(&format!("{TASKS_API}/users/@me/lists"), &[("maxResults", "100")])?;
-    let lists: Vec<(String, String)> = lists["items"]
-        .as_array()
-        .into_iter()
-        .flatten()
-        .filter_map(|l| Some((l["id"].as_str()?.to_string(), l["title"].as_str().unwrap_or("?").to_string())))
+) -> Result<(), String> {
+    // Server side first; each success is recorded before the next call, so
+    // a failure mid-way retries just the remainder next tick.
+    for (title, stale_id) in &plan.push_creates {
+        match ops.list_create(title) {
+            Ok((id, rl)) => {
+                if let Some(file) = local.iter_mut().find(|f| f.title == *title) {
+                    file.id = Some(id.clone());
+                    save_list(file).map_err(|e| e.to_string())?;
+                }
+                if let Some(old) = stale_id {
+                    state.lists.remove(old);
+                }
+                state.lists.insert(
+                    id,
+                    SyncedList { title: rl.title, etag: rl.etag, account: email.to_string() },
+                );
+            }
+            Err(e) => log::warn!("creating list {title:?} failed (will retry next tick): {e}"),
+        }
+    }
+    for (id, title) in &plan.push_renames {
+        match ops.list_rename(id, title) {
+            Ok(etag) => {
+                state.lists.insert(
+                    id.clone(),
+                    SyncedList { title: title.clone(), etag, account: email.to_string() },
+                );
+            }
+            Err(e) => log::warn!("renaming list {id} failed (will retry next tick): {e}"),
+        }
+    }
+    for id in &plan.push_deletes {
+        match ops.list_delete(id) {
+            Ok(()) => {
+                state.lists.remove(id);
+                state.items.retain(|_, v| v.list_id() != *id);
+            }
+            Err(e) => log::warn!("deleting list {id} failed (will retry next tick): {e}"),
+        }
+    }
+
+    // Then the local tree.
+    let current = load_current();
+    for (id, from, to) in &plan.pull_renames {
+        let to = unique_local_title(to, local);
+        rename_list(from, &to).map_err(|e| format!("renaming {from} -> {to}: {e}"))?;
+        if let Some(file) = local.iter_mut().find(|f| f.title == *from) {
+            file.title = to.clone();
+        }
+        if current.as_deref() == Some(from.as_str()) {
+            save_current(&to).map_err(|e| e.to_string())?;
+        }
+        let r = &remote.lists[id];
+        state.lists.insert(
+            id.clone(),
+            SyncedList { title: r.title.clone(), etag: r.etag.clone(), account: email.to_string() },
+        );
+    }
+    for (id, title) in &plan.pull_deletes {
+        delete_list(title).map_err(|e| format!("deleting {title}: {e}"))?;
+        local.retain(|f| f.title != *title);
+        state.lists.remove(id);
+        state.items.retain(|_, v| v.list_id() != *id);
+    }
+    for id in &plan.pull_new {
+        let r = &remote.lists[id];
+        let title = unique_local_title(&r.title, local);
+        let file = ListFile { title, id: Some(id.clone()), items: Vec::new() };
+        save_list(&file).map_err(|e| e.to_string())?;
+        local.push(file);
+        state.lists.insert(
+            id.clone(),
+            SyncedList { title: r.title.clone(), etag: r.etag.clone(), account: email.to_string() },
+        );
+    }
+    for id in &plan.refresh {
+        let r = &remote.lists[id];
+        let title = local
+            .iter()
+            .find(|f| f.id.as_deref() == Some(id))
+            .map(|f| f.title.clone())
+            .unwrap_or_else(|| r.title.clone());
+        state.lists.insert(
+            id.clone(),
+            SyncedList { title, etag: r.etag.clone(), account: email.to_string() },
+        );
+    }
+    local.sort_by_key(|l| l.title.to_lowercase());
+    Ok(())
+}
+
+// ── Item planning (pure; tested) ──────────────────────────────────────────
+
+#[derive(Default, Debug)]
+struct Plan {
+    pull_new: Vec<String>,
+    pull_updates: Vec<String>,
+    pull_deletes: Vec<String>,
+    push_updates: Vec<String>,
+    /// (text, done, the row's stale uid if it carried one) to create.
+    push_creates: Vec<(String, bool, Option<String>)>,
+    push_deletes: Vec<String>,
+    /// Server etag moved but content is identical — track it, change nothing.
+    refresh_etags: Vec<String>,
+}
+
+fn plan_items(local: &[Item], state: &SyncState, remote: &BTreeMap<String, &RemoteTodo>) -> Plan {
+    let mut plan = Plan::default();
+    let local_by_uid: BTreeMap<&str, &Item> = local
+        .iter()
+        .filter_map(|i| i.uid.as_deref().map(|u| (u, i)))
         .collect();
-    log::info!(
-        "{email}: {} task list(s), new items go to {}",
-        lists.len(),
-        lists.iter().find(|(id, _)| *id == default_id).map(|(_, t)| t.as_str()).unwrap_or("default")
-    );
 
-    let mut todos = BTreeMap::new();
-    for (list_id, title) in &lists {
-        let base = format!("{TASKS_API}/lists/{list_id}/tasks");
-        let mut page_token = String::new();
-        loop {
-            let mut q = vec![
-                ("showCompleted", "true"),
-                ("showHidden", "true"),
-                ("maxResults", "100"),
-                ("fields", "nextPageToken,items(id,title,status,etag,deleted)"),
-            ];
-            if !page_token.is_empty() {
-                q.push(("pageToken", page_token.as_str()));
+    for (uid, todo) in remote {
+        let in_state = state.items.get(uid);
+        let in_local = local_by_uid.get(uid.as_str());
+        match (in_state, in_local) {
+            (Some(base), Some(item)) => {
+                let local_changed = item.text != base.text || item.done != base.done;
+                let remote_changed = todo.summary != base.text || todo.done != base.done;
+                if local_changed {
+                    // Local wins on both-changed; the push makes the server
+                    // match, and the next tick sees all three agree.
+                    plan.push_updates.push(uid.clone());
+                } else if remote_changed {
+                    plan.pull_updates.push(uid.clone());
+                } else if todo.etag != base.etag {
+                    plan.refresh_etags.push(uid.clone());
+                }
             }
-            let page = get(&base, &q).map_err(|e| format!("list {title}: {e}"))?;
-            for t in page["items"].as_array().into_iter().flatten() {
-                if t["deleted"].as_bool().unwrap_or(false) {
-                    continue;
+            (Some(_), None) => plan.push_deletes.push(uid.clone()),
+            (None, Some(item)) => {
+                // Untracked but present on both ends (state lost, or a
+                // hand-copied line): adopt it, local text/done winning.
+                if item.text != todo.summary || item.done != todo.done {
+                    plan.push_updates.push(uid.clone());
+                } else {
+                    plan.refresh_etags.push(uid.clone());
                 }
-                let Some(id) = t["id"].as_str() else { continue };
-                let summary = t["title"].as_str().unwrap_or("").trim().to_string();
-                // Google's apps mint blank placeholder tasks freely; a row
-                // with no text is nothing to remember, so leave them there.
-                if summary.is_empty() {
-                    continue;
+            }
+            (None, None) => {
+                if !todo.done {
+                    plan.pull_new.push(uid.clone());
                 }
-                let url = reqwest::Url::parse(&format!("{base}/{id}")).map_err(|e| e.to_string())?;
-                todos.insert(id.to_string(), RemoteTodo {
-                    url,
-                    etag: t["etag"].as_str().unwrap_or("").to_string(),
-                    summary,
-                    done: t["status"].as_str() == Some("completed"),
-                    lines: Vec::new(),
-                });
             }
-            match page["nextPageToken"].as_str() {
-                Some(next) if !next.is_empty() => page_token = next.to_string(),
-                _ => break,
+        }
+    }
+    for uid in state.items.keys() {
+        if !remote.contains_key(uid) {
+            plan.pull_deletes.push(uid.clone());
+        }
+    }
+    for item in local {
+        match &item.uid {
+            None => plan.push_creates.push((item.text.clone(), item.done, None)),
+            // A uid the server never heard of and the state does not track:
+            // recreate it rather than orphan the row.
+            Some(uid) if !remote.contains_key(uid) && !state.items.contains_key(uid) => {
+                plan.push_creates.push((item.text.clone(), item.done, Some(uid.clone())));
             }
+            Some(_) => {}
         }
     }
-    let create_target = reqwest::Url::parse(&format!("{TASKS_API}/lists/{default_id}/tasks"))
-        .map_err(|e| e.to_string())?;
-    Ok(RemoteSnapshot { todos, create_target })
-}
-
-/// Field-level PATCH: title and status only, so due dates and notes set in
-/// Google's own apps ride through. Un-completing must also clear the
-/// completion timestamp or the API rejects the status.
-fn google_update(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    url: &reqwest::Url,
-    text: &str,
-    done: bool,
-) -> Result<String, String> {
-    let body = if done {
-        serde_json::json!({ "title": text, "status": "completed" })
-    } else {
-        serde_json::json!({ "title": text, "status": "needsAction", "completed": null })
-    };
-    let resp = google_call(client, token, reqwest::Method::PATCH, url.as_str(), &[], Some(&body))?;
-    Ok(resp["etag"].as_str().unwrap_or("").to_string())
+    plan
+}
+
+fn print_item_plan(plan: &Plan, remote: &BTreeMap<String, &RemoteTodo>) {
+    for uid in &plan.pull_new {
+        println!("  pull new:    {} ({uid})", remote[uid].summary);
+    }
+    for uid in &plan.pull_updates {
+        println!("  pull change: {} ({uid})", remote[uid].summary);
+    }
+    for uid in &plan.pull_deletes {
+        println!("  pull delete: {uid}");
+    }
+    for uid in &plan.push_updates {
+        println!("  push change: {uid}");
+    }
+    for (text, done, _) in &plan.push_creates {
+        println!("  push new:    {}{text}", if *done { "[x] " } else { "" });
+    }
+    for uid in &plan.push_deletes {
+        println!("  push delete: {uid}");
+    }
 }
 
-fn google_create(
-    client: &reqwest::blocking::Client,
-    token: &str,
+/// Server-side half of an item plan. Returns the creates that succeeded as
+/// (text, stale uid the row carried, new uid) for `apply_local` to annotate.
+#[allow(clippy::too_many_arguments)]
+fn apply_item_plan(
+    ops: &Ops,
+    plan: &Plan,
+    rows: &[Item],
+    todos: &BTreeMap<String, &RemoteTodo>,
     target: &reqwest::Url,
-    text: &str,
-    done: bool,
-) -> Result<(String, reqwest::Url, String), String> {
-    let status = if done { "completed" } else { "needsAction" };
-    let body = serde_json::json!({ "title": text, "status": status });
-    let resp = google_call(client, token, reqwest::Method::POST, target.as_str(), &[], Some(&body))?;
-    let id = resp["id"].as_str().ok_or("created task has no id")?.to_string();
-    let url = reqwest::Url::parse(&format!("{}/{id}", target.as_str().trim_end_matches('/')))
-        .map_err(|e| e.to_string())?;
-    Ok((id, url, resp["etag"].as_str().unwrap_or("").to_string()))
+    list_id: &str,
+    state: &mut SyncState,
+    email: &str,
+) -> Result<Vec<(String, Option<String>, String)>, String> {
+    let synced = |url: &reqwest::Url, etag: String, text: &str, done: bool| SyncedItem {
+        url: url.to_string(),
+        etag,
+        account: email.to_string(),
+        text: text.to_string(),
+        done,
+        list: list_id.to_string(),
+    };
+    for uid in &plan.push_updates {
+        let todo = todos[uid];
+        let item = rows.iter().find(|i| i.uid.as_deref() == Some(uid)).expect("planned");
+        match ops.item_update(todo, &item.text, item.done) {
+            Ok(etag) => {
+                state.items.insert(uid.clone(), synced(&todo.url, etag, &item.text, item.done));
+            }
+            Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
+        }
+    }
+    let mut created = Vec::new();
+    for (text, done, stale) in &plan.push_creates {
+        match ops.item_create(target, text, *done) {
+            Ok((uid, url, etag)) => {
+                state.items.insert(uid.clone(), synced(&url, etag, text, *done));
+                created.push((text.clone(), stale.clone(), uid));
+            }
+            Err(e) => log::warn!("push create {text:?} failed (will retry next tick): {e}"),
+        }
+    }
+    for uid in &plan.push_deletes {
+        let entry = &state.items[uid];
+        let url = reqwest::Url::parse(&entry.url).map_err(|e| e.to_string())?;
+        match ops.item_delete(&url, &entry.etag) {
+            Ok(()) => {
+                state.items.remove(uid);
+            }
+            Err(e) => log::warn!("push delete {uid} failed (will retry next tick): {e}"),
+        }
+    }
+    // Pulls refresh the state from the server snapshot.
+    for uid in plan.pull_new.iter().chain(&plan.pull_updates) {
+        let todo = todos[uid];
+        state.items.insert(uid.clone(), synced(&todo.url, todo.etag.clone(), &todo.summary, todo.done));
+    }
+    for uid in &plan.pull_deletes {
+        state.items.remove(uid);
+    }
+    for uid in &plan.refresh_etags {
+        if let (Some(entry), Some(todo)) = (state.items.get_mut(uid), todos.get(uid)) {
+            entry.etag = todo.etag.clone();
+            entry.list = list_id.to_string();
+        }
+    }
+    Ok(created)
 }
 
-fn google_delete(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    url: &reqwest::Url,
-) -> Result<(), String> {
-    match google_call(client, token, reqwest::Method::DELETE, url.as_str(), &[], None) {
-        Ok(_) => Ok(()),
-        // Already gone counts as done.
-        Err(e) if e.contains("HTTP 404") => Ok(()),
-        Err(e) => Err(e),
+/// Apply the plan's local half as deltas onto a fresh read of the list.
+/// `moved_from` pairs a text with the stale uid its row carried when it was
+/// moved in from another list, so the annotation swap finds the row.
+fn apply_local(
+    items: &mut Vec<Item>,
+    plan: &Plan,
+    remote: &BTreeMap<String, &RemoteTodo>,
+    created: &[(String, Option<String>, String)],
+    moved_from: &[(String, String)],
+) {
+    items.retain(|i| {
+        i.uid.as_deref().is_none_or(|u| !plan.pull_deletes.iter().any(|d| d == u))
+    });
+    for uid in &plan.pull_updates {
+        let todo = remote[uid];
+        if let Some(item) = items.iter_mut().find(|i| i.uid.as_deref() == Some(uid)) {
+            item.text = todo.summary.clone();
+            item.done = todo.done;
+        }
+    }
+    for (text, stale, uid) in created {
+        let stale = stale.clone().or_else(|| {
+            moved_from.iter().find(|(t, _)| t == text).map(|(_, u)| u.clone())
+        });
+        let row = match &stale {
+            Some(old) => items.iter_mut().find(|i| i.uid.as_deref() == Some(old)),
+            None => items.iter_mut().find(|i| i.uid.is_none() && i.text == *text),
+        };
+        if let Some(item) = row {
+            item.uid = Some(uid.clone());
+        }
+    }
+    for uid in &plan.pull_new {
+        let todo = remote[uid];
+        items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()) });
     }
 }
 
+// ── Accounts ──────────────────────────────────────────────────────────────
+
 struct Account {
     email: String,
     password: String,
@@ -376,8 +865,7 @@ struct AccountOnDisk {
 
 fn icloud_accounts() -> Result<Vec<Account>, String> {
     let path = cce_ui::config::cce_config_dir().join("accounts.json");
-    let text =
-        std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
+    let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
     let on_disk: Vec<AccountOnDisk> =
         serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
     let mut out = Vec::new();
@@ -404,354 +892,295 @@ fn icloud_accounts() -> Result<Vec<Account>, String> {
     Ok(out)
 }
 
-// ── The pass ──────────────────────────────────────────────────────────────
-
-fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(), String> {
-    let client = reqwest::blocking::Client::builder()
-        .timeout(std::time::Duration::from_secs(60))
-        .build()
-        .map_err(|e| e.to_string())?;
-    let email = backend.email().to_string();
-
-    // Read the list first: if it is unreadable there is nothing safe to do.
-    let list_exists = data_path().exists();
-    let local_all = if list_exists {
-        parse_items(&std::fs::read_to_string(data_path()).map_err(|e| e.to_string())?)
-    } else {
-        Vec::new()
-    };
-    let mut state = load_sync_state().map_err(|e| format!("sync-state.json: {e}"))?;
-    if !list_exists && !state.items.is_empty() {
-        // The list is gone (fresh clone, deleted file). Re-import rather
-        // than reading absence as "delete everything on the server".
-        log::warn!("list.md missing; discarding sync state and re-importing");
-        state = SyncState::default();
-    }
+// ── Google Tasks ──────────────────────────────────────────────────────────
 
-    // This run reasons only about its own account: the state entries it
-    // owns, and the local rows not claimed by some other account's entry.
-    let foreign: BTreeSet<&str> = state
-        .items
-        .iter()
-        .filter(|(_, v)| v.account != email)
-        .map(|(k, _)| k.as_str())
-        .collect();
-    let local: Vec<Item> = local_all
-        .iter()
-        .filter(|i| i.uid.as_deref().is_none_or(|u| !foreign.contains(u)))
-        .cloned()
-        .collect();
-    let mine = SyncState {
-        items: state.items.iter().filter(|(_, v)| v.account == email).map(|(k, v)| (k.clone(), v.clone())).collect(),
-    };
+const TASKS_API: &str = "https://tasks.googleapis.com/tasks/v1";
 
-    let (remote, session) = match backend {
-        Backend::ICloud(acc) => (fetch_remote(&client, acc)?, Session::ICloud),
-        Backend::Google(acc) => {
-            let token = google_access_token(&client, acc)?;
-            (google_fetch(&client, &token, &acc.email)?, Session::Google(token))
-        }
-    };
-    let plan = plan(&local, &mine, &remote.todos);
+struct GoogleAccount {
+    email: String,
+    refresh_token: String,
+    client_id: String,
+    client_secret: String,
+}
 
-    if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > mine.items.len()
-    {
-        return Err(format!(
-            "refusing to delete {} of {} tracked items on the server — if the list \
-             was really emptied on purpose, run cce-list-sync --force-deletes",
-            plan.push_deletes.len(),
-            mine.items.len()
-        ));
-    }
+/// The OAuth fields the settings app's Google sign-in writes.
+#[derive(serde::Deserialize)]
+struct OAuthOnDisk {
+    email: String,
+    #[serde(default)]
+    is_oauth: bool,
+    #[serde(default)]
+    refresh_token: Option<String>,
+    #[serde(default)]
+    client_id: Option<String>,
+    #[serde(default)]
+    client_secret: Option<String>,
+}
 
-    log::info!(
-        "{email}: pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
-        plan.pull_new.len(),
-        plan.pull_updates.len(),
-        plan.pull_deletes.len(),
-        plan.push_updates.len(),
-        plan.push_creates.len(),
-        plan.push_deletes.len(),
-    );
-    if dry_run {
-        print_plan(&plan, &remote.todos);
-        return Ok(());
-    }
+#[derive(serde::Deserialize, Default)]
+struct GoogleClientConfig {
+    #[serde(default)]
+    client_id: String,
+    #[serde(default)]
+    client_secret: String,
+}
 
-    // Server side first: every push refreshes `state` only on success, so a
-    // failed request is simply retried next tick.
-    for uid in &plan.push_updates {
-        let todo = &remote.todos[uid];
-        let item = local.iter().find(|i| i.uid.as_deref() == Some(uid)).expect("planned");
-        let pushed = match (backend, &session) {
-            (Backend::ICloud(acc), _) => {
-                let body = patch_vtodo(&todo.lines, &item.text, item.done);
-                put_ics(&client, acc, &todo.url, &body, Some(&todo.etag))
-            }
-            (Backend::Google(_), Session::Google(token)) => {
-                google_update(&client, token, &todo.url, &item.text, item.done)
-            }
-            (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
-        };
-        match pushed {
-            Ok(etag) => {
-                state.items.insert(uid.clone(), SyncedItem {
-                    url: todo.url.to_string(),
-                    etag,
-                    account: email.clone(),
-                    text: item.text.clone(),
-                    done: item.done,
-                });
-            }
-            Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
-        }
-    }
-    let mut created: Vec<(String, String)> = Vec::new(); // (text, uid) to annotate
-    for (text, done) in &plan.push_creates {
-        // A row checked off before it ever reached the server is created
-        // completed; recording that in the state is what keeps the next
-        // pass from seeing a phantom local change.
-        let made = match (backend, &session) {
-            (Backend::ICloud(acc), _) => {
-                let uid = new_uid();
-                remote
-                    .create_target
-                    .join(&format!("{uid}.ics"))
-                    .map_err(|e| e.to_string())
-                    .and_then(|url| {
-                        put_ics(&client, acc, &url, &new_vtodo(&uid, text, *done), None)
-                            .map(|etag| (uid, url, etag))
-                    })
-            }
-            (Backend::Google(_), Session::Google(token)) => {
-                google_create(&client, token, &remote.create_target, text, *done)
-            }
-            (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
-        };
-        match made {
-            Ok((uid, url, etag)) => {
-                state.items.insert(uid.clone(), SyncedItem {
-                    url: url.to_string(),
-                    etag,
-                    account: email.clone(),
-                    text: text.clone(),
-                    done: *done,
-                });
-                created.push((text.clone(), uid));
-            }
-            Err(e) => log::warn!("push create {text:?} failed (will retry next tick): {e}"),
+fn google_accounts() -> Result<Vec<GoogleAccount>, String> {
+    let dir = cce_ui::config::cce_config_dir();
+    let path = dir.join("accounts.json");
+    let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
+    let on_disk: Vec<OAuthOnDisk> =
+        serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
+    // An account without its own pinned client credentials falls back to
+    // the global template the settings app maintains.
+    let template: GoogleClientConfig = std::fs::read_to_string(dir.join("google_client.json"))
+        .ok()
+        .and_then(|t| serde_json::from_str(&t).ok())
+        .unwrap_or_default();
+    let mut out = Vec::new();
+    for acc in on_disk {
+        if !acc.is_oauth {
+            continue;
         }
-    }
-    for uid in &plan.push_deletes {
-        let entry = &state.items[uid];
-        let url = reqwest::Url::parse(&entry.url).map_err(|e| e.to_string())?;
-        let gone = match (backend, &session) {
-            (Backend::ICloud(acc), _) => delete_ics(&client, acc, &url, &entry.etag),
-            (Backend::Google(_), Session::Google(token)) => google_delete(&client, token, &url),
-            (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
+        let Some(refresh_token) = acc.refresh_token.filter(|t| !t.is_empty()) else {
+            log::warn!("{}: OAuth account without a refresh token; sign in again", acc.email);
+            continue;
         };
-        match gone {
-            Ok(()) => {
-                state.items.remove(uid);
-            }
-            Err(e) => log::warn!("push delete {uid} failed (will retry next tick): {e}"),
-        }
-    }
-
-    // Pulls refresh the state from the server snapshot.
-    for uid in plan.pull_new.iter().chain(&plan.pull_updates) {
-        let todo = &remote.todos[uid];
-        state.items.insert(uid.clone(), SyncedItem {
-            url: todo.url.to_string(),
-            etag: todo.etag.clone(),
-            account: email.clone(),
-            text: todo.summary.clone(),
-            done: todo.done,
+        out.push(GoogleAccount {
+            email: acc.email,
+            refresh_token,
+            client_id: acc.client_id.filter(|s| !s.is_empty()).unwrap_or(template.client_id.clone()),
+            client_secret: acc
+                .client_secret
+                .filter(|s| !s.is_empty())
+                .unwrap_or(template.client_secret.clone()),
         });
     }
-    for uid in &plan.pull_deletes {
-        state.items.remove(uid);
-    }
-    for uid in &plan.refresh_etags {
-        if let (Some(entry), Some(todo)) = (state.items.get_mut(uid), remote.todos.get(uid)) {
-            entry.etag = todo.etag.clone();
-        }
+    Ok(out)
+}
+
+/// A fresh access token from the refresh grant. Tokens last an hour and a
+/// tick is one request burst, so refreshing every run is simpler than
+/// tracking expiry — and keeps this helper from writing accounts.json.
+fn google_access_token(
+    client: &reqwest::blocking::Client,
+    acc: &GoogleAccount,
+) -> Result<String, String> {
+    let resp = client
+        .post("https://oauth2.googleapis.com/token")
+        .form(&[
+            ("client_id", acc.client_id.as_str()),
+            ("client_secret", acc.client_secret.as_str()),
+            ("refresh_token", acc.refresh_token.as_str()),
+            ("grant_type", "refresh_token"),
+        ])
+        .send()
+        .map_err(|e| format!("token refresh: {e}"))?;
+    let status = resp.status();
+    let body: serde_json::Value = resp.json().map_err(|e| format!("token refresh: {e}"))?;
+    if !status.is_success() {
+        // invalid_grant here means the refresh token was revoked or the
+        // consent predates the tasks scope — a re-login fixes both.
+        return Err(format!("token refresh: HTTP {status} {body}"));
     }
-
-    // Local side last, as deltas on a FRESH read: the user may have edited
-    // the list while the network calls ran, and rows this plan does not
-    // touch must survive verbatim.
-    let mut fresh = if data_path().exists() {
-        parse_items(&std::fs::read_to_string(data_path()).map_err(|e| e.to_string())?)
-    } else {
-        Vec::new()
-    };
-    apply_local(&mut fresh, &plan, &remote.todos, &created);
-    atomic_write(&data_path(), &serialize_items(&fresh)).map_err(|e| e.to_string())?;
-    save_sync_state(&state).map_err(|e| e.to_string())?;
-    Ok(())
+    body.get("access_token")
+        .and_then(|v| v.as_str())
+        .map(String::from)
+        .ok_or_else(|| "token refresh: no access_token in response".to_string())
 }
 
-fn print_plan(plan: &Plan, remote: &BTreeMap<String, RemoteTodo>) {
-    for uid in &plan.pull_new {
-        println!("pull new:    {} ({uid})", remote[uid].summary);
-    }
-    for uid in &plan.pull_updates {
-        println!("pull change: {} ({uid})", remote[uid].summary);
-    }
-    for uid in &plan.pull_deletes {
-        println!("pull delete: {uid}");
+fn google_call(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    method: reqwest::Method,
+    url: &str,
+    query: &[(&str, &str)],
+    body: Option<&serde_json::Value>,
+) -> Result<serde_json::Value, String> {
+    let mut req = client.request(method.clone(), url).bearer_auth(token).query(query);
+    if let Some(b) = body {
+        req = req.json(b);
     }
-    for uid in &plan.push_updates {
-        println!("push change: {uid}");
+    let resp = req.send().map_err(|e| format!("{method} {url}: {e}"))?;
+    let status = resp.status();
+    if status == reqwest::StatusCode::NO_CONTENT {
+        return Ok(serde_json::Value::Null);
     }
-    for (text, done) in &plan.push_creates {
-        println!("push new:    {}{text}", if *done { "[x] " } else { "" });
+    let text = resp.text().map_err(|e| format!("{method} {url}: {e}"))?;
+    if !status.is_success() {
+        return Err(format!("{method} {url}: HTTP {status} {text}"));
     }
-    for uid in &plan.push_deletes {
-        println!("push delete: {uid}");
+    if text.trim().is_empty() {
+        return Ok(serde_json::Value::Null);
     }
+    serde_json::from_str(&text).map_err(|e| format!("{method} {url}: bad JSON: {e}"))
 }
 
-// ── Merge planning (pure; the tests live on this) ─────────────────────────
-
-#[derive(Default, Debug)]
-struct Plan {
-    pull_new: Vec<String>,
-    pull_updates: Vec<String>,
-    pull_deletes: Vec<String>,
-    push_updates: Vec<String>,
-    /// (text, done) of local uid-less rows to create server-side.
-    push_creates: Vec<(String, bool)>,
-    push_deletes: Vec<String>,
-    /// Server etag moved but content is identical — track it, change nothing.
-    refresh_etags: Vec<String>,
+fn google_list_url(id: &str) -> String {
+    format!("{TASKS_API}/users/@me/lists/{id}")
 }
 
-fn plan(local: &[Item], state: &SyncState, remote: &BTreeMap<String, RemoteTodo>) -> Plan {
-    let mut plan = Plan::default();
-    let local_by_uid: BTreeMap<&str, &Item> = local
-        .iter()
-        .filter_map(|i| i.uid.as_deref().map(|u| (u, i)))
-        .collect();
+/// Every list and every task in it.
+fn google_fetch(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    email: &str,
+) -> Result<RemoteSnapshot, String> {
+    let get = |url: &str, q: &[(&str, &str)]| {
+        google_call(client, token, reqwest::Method::GET, url, q, None)
+    };
+    let page = get(&format!("{TASKS_API}/users/@me/lists"), &[("maxResults", "100")])?;
+    let mut lists = BTreeMap::new();
+    for l in page["items"].as_array().into_iter().flatten() {
+        let Some(id) = l["id"].as_str() else { continue };
+        lists.insert(
+            id.to_string(),
+            RemoteList {
+                title: l["title"].as_str().unwrap_or("Untitled").to_string(),
+                etag: l["etag"].as_str().unwrap_or("").to_string(),
+                create_target: reqwest::Url::parse(&format!("{TASKS_API}/lists/{id}/tasks"))
+                    .map_err(|e| e.to_string())?,
+            },
+        );
+    }
+    log::info!("{email}: {} task list(s)", lists.len());
 
-    for (uid, todo) in remote {
-        let in_state = state.items.get(uid);
-        let in_local = local_by_uid.get(uid.as_str());
-        match (in_state, in_local) {
-            (Some(base), Some(item)) => {
-                let local_changed = item.text != base.text || item.done != base.done;
-                let remote_changed = todo.summary != base.text || todo.done != base.done;
-                if local_changed {
-                    // Local wins on both-changed; the push makes the server
-                    // match, and the next tick sees all three agree.
-                    plan.push_updates.push(uid.clone());
-                } else if remote_changed {
-                    plan.pull_updates.push(uid.clone());
-                } else if todo.etag != base.etag {
-                    plan.refresh_etags.push(uid.clone());
-                }
+    let mut todos = BTreeMap::new();
+    for (list_id, list) in &lists {
+        let base = list.create_target.as_str().to_string();
+        let mut page_token = String::new();
+        loop {
+            let mut q = vec![
+                ("showCompleted", "true"),
+                ("showHidden", "true"),
+                ("maxResults", "100"),
+                ("fields", "nextPageToken,items(id,title,status,etag,deleted)"),
+            ];
+            if !page_token.is_empty() {
+                q.push(("pageToken", page_token.as_str()));
             }
-            (Some(_), None) => plan.push_deletes.push(uid.clone()),
-            (None, Some(item)) => {
-                // Untracked but present on both ends (state lost, or a
-                // hand-copied line): adopt it, local text/done winning.
-                if item.text != todo.summary || item.done != todo.done {
-                    plan.push_updates.push(uid.clone());
-                } else {
-                    plan.refresh_etags.push(uid.clone());
+            let page = get(&base, &q).map_err(|e| format!("list {}: {e}", list.title))?;
+            for t in page["items"].as_array().into_iter().flatten() {
+                if t["deleted"].as_bool().unwrap_or(false) {
+                    continue;
                 }
-            }
-            (None, None) => {
-                if !todo.done {
-                    plan.pull_new.push(uid.clone());
+                let Some(id) = t["id"].as_str() else { continue };
+                let summary = t["title"].as_str().unwrap_or("").trim().to_string();
+                if summary.is_empty() {
+                    // Google's apps mint blank placeholder tasks freely; a
+                    // row with no text is nothing to remember.
+                    continue;
                 }
+                let url = reqwest::Url::parse(&format!("{base}/{id}")).map_err(|e| e.to_string())?;
+                todos.insert(id.to_string(), RemoteTodo {
+                    url,
+                    etag: t["etag"].as_str().unwrap_or("").to_string(),
+                    summary,
+                    done: t["status"].as_str() == Some("completed"),
+                    lines: Vec::new(),
+                    list: list_id.clone(),
+                });
             }
-        }
-    }
-    for (uid, _) in &state.items {
-        if !remote.contains_key(uid) {
-            if local_by_uid.contains_key(uid.as_str()) {
-                plan.pull_deletes.push(uid.clone());
-            } else {
-                // Gone on both ends independently; just forget it.
-                plan.pull_deletes.push(uid.clone());
-            }
-        }
-    }
-    for item in local {
-        match &item.uid {
-            None => plan.push_creates.push((item.text.clone(), item.done)),
-            // A uid the server never heard of and the state does not track:
-            // recreate it under that uid rather than orphaning the row.
-            Some(uid) if !remote.contains_key(uid) && !state.items.contains_key(uid) => {
-                plan.push_creates.push((item.text.clone(), item.done));
+            match page["nextPageToken"].as_str() {
+                Some(next) if !next.is_empty() => page_token = next.to_string(),
+                _ => break,
             }
-            Some(_) => {}
         }
     }
-    plan
+    Ok(RemoteSnapshot { lists, todos })
 }
 
-/// Apply the plan's local half as deltas onto a fresh read of the list.
-fn apply_local(
-    items: &mut Vec<Item>,
-    plan: &Plan,
-    remote: &BTreeMap<String, RemoteTodo>,
-    created: &[(String, String)],
-) {
-    items.retain(|i| {
-        i.uid.as_deref().is_none_or(|u| !plan.pull_deletes.iter().any(|d| d == u))
-    });
-    for uid in &plan.pull_updates {
-        let todo = &remote[uid];
-        if let Some(item) = items.iter_mut().find(|i| i.uid.as_deref() == Some(uid)) {
-            item.text = todo.summary.clone();
-            item.done = todo.done;
-        }
-    }
-    for (text, uid) in created {
-        // A row we recreated under its own stale uid already carries it.
-        if let Some(item) =
-            items.iter_mut().find(|i| i.uid.is_none() && i.text == *text)
-        {
-            item.uid = Some(uid.clone());
-        } else if let Some(item) = items
-            .iter_mut()
-            .find(|i| i.text == *text && i.uid.as_deref() == Some(uid))
-        {
-            item.uid = Some(uid.clone());
-        }
-    }
-    for uid in &plan.pull_new {
-        let todo = &remote[uid];
-        items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()) });
-    }
+fn google_list_create(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    title: &str,
+) -> Result<(String, RemoteList), String> {
+    let body = serde_json::json!({ "title": title });
+    let resp = google_call(
+        client, token, reqwest::Method::POST,
+        &format!("{TASKS_API}/users/@me/lists"), &[], Some(&body),
+    )?;
+    let id = resp["id"].as_str().ok_or("created list has no id")?.to_string();
+    let list = RemoteList {
+        title: resp["title"].as_str().unwrap_or(title).to_string(),
+        etag: resp["etag"].as_str().unwrap_or("").to_string(),
+        create_target: reqwest::Url::parse(&format!("{TASKS_API}/lists/{id}/tasks"))
+            .map_err(|e| e.to_string())?,
+    };
+    Ok((id, list))
 }
 
-// ── CalDAV ────────────────────────────────────────────────────────────────
+fn google_list_rename(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    id: &str,
+    title: &str,
+) -> Result<String, String> {
+    let body = serde_json::json!({ "title": title });
+    let resp = google_call(client, token, reqwest::Method::PATCH, &google_list_url(id), &[], Some(&body))?;
+    Ok(resp["etag"].as_str().unwrap_or("").to_string())
+}
 
-#[derive(Debug)]
-struct RemoteTodo {
-    url: reqwest::Url,
-    etag: String,
-    summary: String,
+fn google_list_delete(client: &reqwest::blocking::Client, token: &str, id: &str) -> Result<(), String> {
+    match google_call(client, token, reqwest::Method::DELETE, &google_list_url(id), &[], None) {
+        Ok(_) => Ok(()),
+        Err(e) if e.contains("HTTP 404") => Ok(()),
+        Err(e) => Err(e),
+    }
+}
+
+/// Field-level PATCH: title and status only, so due dates and notes set in
+/// Google's own apps ride through. Un-completing must also clear the
+/// completion timestamp or the API rejects the status.
+fn google_update(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    url: &reqwest::Url,
+    text: &str,
     done: bool,
-    /// Unfolded logical lines of the full VCALENDAR, for patch-and-PUT.
-    lines: Vec<String>,
+) -> Result<String, String> {
+    let body = if done {
+        serde_json::json!({ "title": text, "status": "completed" })
+    } else {
+        serde_json::json!({ "title": text, "status": "needsAction", "completed": null })
+    };
+    let resp = google_call(client, token, reqwest::Method::PATCH, url.as_str(), &[], Some(&body))?;
+    Ok(resp["etag"].as_str().unwrap_or("").to_string())
 }
 
-struct RemoteSnapshot {
-    todos: BTreeMap<String, RemoteTodo>,
-    /// Where push_creates land: the list named "Reminders" if there is one,
-    /// else the first VTODO calendar.
-    create_target: reqwest::Url,
+fn google_create(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    target: &reqwest::Url,
+    text: &str,
+    done: bool,
+) -> Result<(String, reqwest::Url, String), String> {
+    let status = if done { "completed" } else { "needsAction" };
+    let body = serde_json::json!({ "title": text, "status": status });
+    let resp = google_call(client, token, reqwest::Method::POST, target.as_str(), &[], Some(&body))?;
+    let id = resp["id"].as_str().ok_or("created task has no id")?.to_string();
+    let url = reqwest::Url::parse(&format!("{}/{id}", target.as_str().trim_end_matches('/')))
+        .map_err(|e| e.to_string())?;
+    Ok((id, url, resp["etag"].as_str().unwrap_or("").to_string()))
 }
 
-fn fetch_remote(
+fn google_delete(
     client: &reqwest::blocking::Client,
-    acc: &Account,
-) -> Result<RemoteSnapshot, String> {
+    token: &str,
+    url: &reqwest::Url,
+) -> Result<(), String> {
+    match google_call(client, token, reqwest::Method::DELETE, url.as_str(), &[], None) {
+        Ok(_) => Ok(()),
+        // Already gone counts as done.
+        Err(e) if e.contains("HTTP 404") => Ok(()),
+        Err(e) => Err(e),
+    }
+}
+
+// ── CalDAV (iCloud) ───────────────────────────────────────────────────────
+
+/// Every VTODO calendar as a list (its URL is the list id) and its items.
+fn fetch_icloud(client: &reqwest::blocking::Client, acc: &Account) -> Result<RemoteSnapshot, String> {
     let root = reqwest::Url::parse(CALDAV_ROOT).expect("static url");
     let principal = discover_href(
         client, acc, &root, "0",
@@ -765,33 +1194,27 @@ fn fetch_remote(
 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><prop><C:calendar-home-set/></prop></propfind>"#,
         "calendar-home-set",
     )?;
-    let lists = todo_calendars(client, acc, &home)?;
-    if lists.is_empty() {
+    let calendars = todo_calendars(client, acc, &home)?;
+    if calendars.is_empty() {
         return Err("no VTODO calendars (Reminders lists) found".into());
     }
-    let create_target = lists
-        .iter()
-        .find(|(_, name)| name == "Reminders")
-        .unwrap_or(&lists[0])
-        .0
-        .clone();
-    log::info!(
-        "{}: {} Reminders list(s), new items go to {}",
-        acc.email,
-        lists.len(),
-        lists.iter().find(|(u, _)| *u == create_target).map(|(_, n)| n.as_str()).unwrap_or("?")
-    );
+    log::info!("{}: {} Reminders list(s)", acc.email, calendars.len());
 
+    let mut lists = BTreeMap::new();
     let mut todos = BTreeMap::new();
     let mut skipped_recurring = 0usize;
-    for (url, name) in &lists {
+    for (url, name) in &calendars {
+        lists.insert(
+            url.to_string(),
+            RemoteList { title: name.clone(), etag: String::new(), create_target: url.clone() },
+        );
         fetch_todos(client, acc, url, &mut todos, &mut skipped_recurring)
             .map_err(|e| format!("list {name}: {e}"))?;
     }
     if skipped_recurring > 0 {
         log::info!("{skipped_recurring} recurring reminder(s) left alone (RRULE)");
     }
-    Ok(RemoteSnapshot { todos, create_target })
+    Ok(RemoteSnapshot { lists, todos })
 }
 
 fn dav_request(
@@ -931,6 +1354,7 @@ fn fetch_todos(
                     summary: parsed.summary,
                     done: parsed.done,
                     lines: parsed.lines,
+                    list: cal.to_string(),
                 });
             }
             None => log::warn!("unparsable VTODO at {url}, skipping"),
@@ -1195,6 +1619,7 @@ mod tests {
             summary: summary.into(),
             done,
             lines: Vec::new(),
+            list: "L".into(),
         }
     }
 
@@ -1205,16 +1630,37 @@ mod tests {
             account: "[email protected]".into(),
             text: text.into(),
             done,
+            list: "L".into(),
+        }
+    }
+
+    fn rlist(title: &str, etag: &str) -> RemoteList {
+        RemoteList {
+            title: title.into(),
+            etag: etag.into(),
+            create_target: reqwest::Url::parse("https://example.com/l/tasks").unwrap(),
         }
     }
 
+    fn slist(title: &str, etag: &str) -> SyncedList {
+        SyncedList { title: title.into(), etag: etag.into(), account: "a".into() }
+    }
+
+    fn lfile(title: &str, id: Option<&str>) -> ListFile {
+        ListFile { title: title.into(), id: id.map(String::from), items: Vec::new() }
+    }
+
+    fn refs<'a>(m: &'a BTreeMap<String, RemoteTodo>) -> BTreeMap<String, &'a RemoteTodo> {
+        m.iter().map(|(k, v)| (k.clone(), v)).collect()
+    }
+
     #[test]
     fn merge_decision_table() {
         let local = vec![
             item("unchanged", false, Some("u1")),
-            item("toggled here", true, Some("u2")),  // local change → push
-            item("renamed on phone", false, Some("u3")), // remote change → pull
-            item("fresh local", false, None),        // no uid → create
+            item("toggled here", true, Some("u2")), // local change → push
+            item("old name", false, Some("u3")),    // remote change → pull
+            item("fresh local", false, None),       // no uid → create
         ];
         // u4 in state but not local → deleted here → push delete.
         // u5 on server, unknown → pull new. u6 server-completed, unknown → ignore.
@@ -1231,17 +1677,12 @@ mod tests {
         remote.insert("u5".into(), todo("from the phone", false, "e5"));
         remote.insert("u6".into(), todo("ancient done thing", true, "e6"));
 
-        // The local u3 text matches the state ("old name" changed remotely),
-        // so fix the fixture: local u3 must equal the state's text.
-        let mut local = local;
-        local[2].text = "old name".into();
-
-        let p = plan(&local, &state, &remote);
+        let p = plan_items(&local, &state, &refs(&remote));
         assert_eq!(p.push_updates, vec!["u2"]);
         assert_eq!(p.pull_updates, vec!["u3"]);
         assert_eq!(p.push_deletes, vec!["u4"]);
         assert_eq!(p.pull_new, vec!["u5"]);
-        assert_eq!(p.push_creates, vec![("fresh local".to_string(), false)]);
+        assert_eq!(p.push_creates, vec![("fresh local".to_string(), false, None)]);
         assert!(p.pull_deletes.is_empty());
     }
 
@@ -1252,7 +1693,7 @@ mod tests {
         state.items.insert("u1".into(), synced("base", false, "e1"));
         let mut remote = BTreeMap::new();
         remote.insert("u1".into(), todo("theirs", false, "e2"));
-        let p = plan(&local, &state, &remote);
+        let p = plan_items(&local, &state, &refs(&remote));
         assert_eq!(p.push_updates, vec!["u1"]);
         assert!(p.pull_updates.is_empty());
     }
@@ -1263,15 +1704,43 @@ mod tests {
         let mut state = SyncState::default();
         state.items.insert("u1".into(), synced("gone on phone", false, "e1"));
         let remote = BTreeMap::new();
-        let p = plan(&local, &state, &remote);
+        let p = plan_items(&local, &state, &refs(&remote));
         assert_eq!(p.pull_deletes, vec!["u1"]);
         assert!(p.push_creates.is_empty());
 
         let mut items = local;
-        apply_local(&mut items, &p, &remote, &[]);
+        apply_local(&mut items, &p, &refs(&remote), &[], &[]);
         assert!(items.is_empty());
     }
 
+    #[test]
+    fn recreated_row_is_reannotated_by_its_stale_uid() {
+        // A row carrying a uid nobody knows is recreated; the new uid must
+        // replace the stale one on THAT row, not on a same-text sibling.
+        let plan = Plan {
+            push_creates: vec![("dup".into(), false, Some("stale".into()))],
+            ..Default::default()
+        };
+        let mut items = vec![item("dup", false, None), item("dup", false, Some("stale"))];
+        apply_local(&mut items, &plan, &BTreeMap::new(), &[("dup".into(), Some("stale".into()), "new".into())], &[]);
+        assert_eq!(items[0].uid, None);
+        assert_eq!(items[1].uid.as_deref(), Some("new"));
+    }
+
+    #[test]
+    fn moved_row_swaps_uid_via_moved_from() {
+        let plan = Plan { push_creates: vec![("milk".into(), false, None)], ..Default::default() };
+        let mut items = vec![item("milk", false, Some("from-other-list"))];
+        apply_local(
+            &mut items,
+            &plan,
+            &BTreeMap::new(),
+            &[("milk".into(), None, "new".into())],
+            &[("milk".into(), "from-other-list".into())],
+        );
+        assert_eq!(items[0].uid.as_deref(), Some("new"));
+    }
+
     #[test]
     fn patch_preserves_foreign_properties() {
         let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:u\r\nDUE;VALUE=DATE:20261001\r\nSUMMARY:old\r\nSTATUS:NEEDS-ACTION\r\nX-APPLE-SORT-ORDER:7\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
@@ -1292,9 +1761,63 @@ mod tests {
         let mut remote = BTreeMap::new();
         remote.insert("u9".into(), todo("from phone", false, "e9"));
         let mut items = vec![item("typed mid-sync", false, None)];
-        apply_local(&mut items, &plan, &remote, &[]);
+        apply_local(&mut items, &plan, &refs(&remote), &[], &[]);
         assert_eq!(items.len(), 2);
         assert_eq!(items[0].text, "typed mid-sync");
         assert_eq!(items[1].uid.as_deref(), Some("u9"));
     }
+
+    #[test]
+    fn list_decision_table() {
+        let local = vec![
+            lfile("Tasks", Some("L1")),      // unchanged
+            lfile("Errands", Some("L2")),    // renamed here (base "Chores") → push
+            lfile("Work", Some("L3")),       // renamed on phone → pull
+            lfile("Gone remote", Some("L4")), // server deleted it → pull delete
+            lfile("Brand new", None),        // → push create
+            lfile("Orphan", Some("LX")),     // header nobody knows → recreate
+            lfile("Tasks (legacy)", Some("L7")), // never synced as list; server name wins
+        ];
+        let mut state = BTreeMap::new();
+        state.insert("L1".into(), slist("Tasks", "e1"));
+        state.insert("L2".into(), slist("Chores", "e2"));
+        state.insert("L3".into(), slist("Work", "e3"));
+        state.insert("L4".into(), slist("Gone remote", "e4"));
+        state.insert("L5".into(), slist("Deleted here", "e5")); // no file → push delete
+        let mut remote = BTreeMap::new();
+        remote.insert("L1".into(), rlist("Tasks", "e1"));
+        remote.insert("L2".into(), rlist("Chores", "e2"));
+        remote.insert("L3".into(), rlist("Office", "e3b"));
+        remote.insert("L5".into(), rlist("Deleted here", "e5"));
+        remote.insert("L6".into(), rlist("From the phone", "e6")); // → pull new
+        remote.insert("L7".into(), rlist("LSGalante12's list", "e7"));
+
+        let p = plan_lists(&local, &state, &remote);
+        assert_eq!(p.push_renames, vec![("L2".to_string(), "Errands".to_string())]);
+        assert_eq!(p.pull_renames, vec![
+            ("L3".to_string(), "Work".to_string(), "Office".to_string()),
+            ("L7".to_string(), "Tasks (legacy)".to_string(), "LSGalante12's list".to_string()),
+        ]);
+        assert_eq!(p.pull_deletes, vec![("L4".to_string(), "Gone remote".to_string())]);
+        assert_eq!(p.push_creates, vec![
+            ("Brand new".to_string(), None),
+            ("Orphan".to_string(), Some("LX".to_string())),
+        ]);
+        assert_eq!(p.push_deletes, vec!["L5"]);
+        assert_eq!(p.pull_new, vec!["L6"]);
+        // L1 matches the base exactly; nothing to record.
+        assert!(p.refresh.is_empty());
+    }
+
+    #[test]
+    fn list_rename_both_sides_local_wins() {
+        let local = vec![lfile("Mine", Some("L1"))];
+        let mut state = BTreeMap::new();
+        state.insert("L1".into(), slist("Base", "e1"));
+        let mut remote = BTreeMap::new();
+        remote.insert("L1".into(), rlist("Theirs", "e2"));
+        let p = plan_lists(&local, &state, &remote);
+        assert_eq!(p.push_renames, vec![("L1".to_string(), "Mine".to_string())]);
+        assert!(p.pull_renames.is_empty());
+    }
 }
diff --git a/src/lib.rs b/src/lib.rs
index 1f64dfc..65ef573 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,17 +1,22 @@
 //! Shared between the `cce-list` app and the `cce-list-sync` helper: the
-//! item model, the markdown checklist on disk, and the sync-state sidecar.
+//! item model, the markdown checklists on disk, and the sync-state sidecar.
 //!
-//! The list stays a plain markdown checklist (`~/.local/share/cce-list/
-//! list.md`), readable and editable with anything. Items mirrored from a
-//! server carry their identity as a trailing HTML comment —
-//! `- [ ] call mom <!-- uid:ABC-123 -->` — which markdown renderers hide and
-//! hand-editors can ignore (or delete, which reads as "delete and recreate").
-//! Everything else the sync needs (etags, item URLs, the last-synced
-//! snapshot) lives in `sync-state.json` next to the list, never in the
-//! markdown.
+//! Lists are plain markdown checklists, one file per list under
+//! `~/.local/share/cce-list/lists/<title>.md`, readable and editable with
+//! anything. The file's stem is the list's title. A list mirrored from a
+//! server carries its identity as a first-line HTML comment —
+//! `<!-- list:MDM5… -->` — and each mirrored item as a trailing one —
+//! `- [ ] call mom <!-- uid:ABC-123 -->`; markdown renderers hide both and
+//! hand-editors can ignore them (deleting one reads as "delete and
+//! recreate"). Which list the app shows is a one-line `current` file next
+//! to `lists/`. Everything else the sync needs (etags, item URLs, the
+//! last-synced snapshot) lives in `sync-state.json`, never in the markdown.
+//!
+//! Before lists existed there was a single `list.md`; `load_lists` migrates
+//! it on first sight (see [`migrate_legacy`]).
 
 use std::collections::BTreeMap;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Item {
@@ -21,15 +26,15 @@ pub struct Item {
     pub uid: Option<String>,
 }
 
-pub fn data_path() -> PathBuf {
-    data_dir().join("list.md")
-}
-
-pub fn sync_state_path() -> PathBuf {
-    data_dir().join("sync-state.json")
+/// One checklist: its file stem, its server identity (if mirrored), items.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ListFile {
+    pub title: String,
+    pub id: Option<String>,
+    pub items: Vec<Item>,
 }
 
-fn data_dir() -> PathBuf {
+pub fn data_dir() -> PathBuf {
     std::env::var_os("XDG_DATA_HOME")
         .map(PathBuf::from)
         .filter(|p| p.is_absolute())
@@ -39,6 +44,37 @@ fn data_dir() -> PathBuf {
         .join("cce-list")
 }
 
+pub fn lists_dir() -> PathBuf {
+    data_dir().join("lists")
+}
+
+/// The pre-lists single checklist; only read by the migration.
+pub fn legacy_path() -> PathBuf {
+    data_dir().join("list.md")
+}
+
+pub fn current_path() -> PathBuf {
+    data_dir().join("current")
+}
+
+pub fn sync_state_path() -> PathBuf {
+    data_dir().join("sync-state.json")
+}
+
+/// A title as a file stem. `/` is the one character a stem cannot hold; a
+/// server title carrying one comes back to the server renamed, which is the
+/// lesser evil next to a list that cannot be written at all.
+pub fn safe_title(title: &str) -> String {
+    let t: String = title.trim().replace('/', "-");
+    if t.is_empty() || t == "." || t == ".." { "Untitled".to_string() } else { t }
+}
+
+pub fn list_path(title: &str) -> PathBuf {
+    lists_dir().join(format!("{}.md", safe_title(title)))
+}
+
+// ── Markdown ──────────────────────────────────────────────────────────────
+
 /// Checklist lines become items; any other non-empty line is adopted as a
 /// not-done item rather than parsed around — the next save rewrites the file,
 /// so a line this reader skipped would be a line silently deleted.
@@ -91,20 +127,104 @@ pub fn serialize_items(items: &[Item]) -> String {
         .collect()
 }
 
-pub fn load_items() -> Vec<Item> {
-    match std::fs::read_to_string(data_path()) {
-        Ok(text) => parse_items(&text),
-        Err(_) => Vec::new(),
+/// A whole list file: the optional `<!-- list:ID -->` header, then items.
+pub fn parse_list(text: &str) -> (Option<String>, Vec<Item>) {
+    let mut lines = text.lines();
+    let mut first = lines.next();
+    while matches!(first, Some(l) if l.trim().is_empty()) {
+        first = lines.next();
+    }
+    if let Some(id) = first
+        .map(str::trim)
+        .and_then(|l| l.strip_prefix("<!-- list:"))
+        .and_then(|l| l.strip_suffix("-->"))
+        .map(str::trim)
+        .filter(|id| !id.is_empty())
+    {
+        let rest: Vec<&str> = lines.collect();
+        return (Some(id.to_string()), parse_items(&rest.join("\n")));
+    }
+    (None, parse_items(text))
+}
+
+pub fn serialize_list(id: Option<&str>, items: &[Item]) -> String {
+    let mut out = String::new();
+    if let Some(id) = id {
+        out.push_str(&format!("<!-- list:{id} -->\n"));
+    }
+    out.push_str(&serialize_items(items));
+    out
+}
+
+// ── Files ─────────────────────────────────────────────────────────────────
+
+/// Every list on disk, titles sorted case-insensitively. Runs the legacy
+/// migration first, so a pre-lists install comes up with its old checklist
+/// intact rather than empty.
+pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
+    migrate_legacy()?;
+    let dir = lists_dir();
+    let mut out = Vec::new();
+    let entries = match std::fs::read_dir(&dir) {
+        Ok(e) => e,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
+        Err(e) => return Err(e),
+    };
+    for entry in entries {
+        let path = entry?.path();
+        if path.extension().and_then(|e| e.to_str()) != Some("md") {
+            continue;
+        }
+        let Some(title) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
+            continue;
+        };
+        let text = std::fs::read_to_string(&path)?;
+        let (id, items) = parse_list(&text);
+        out.push(ListFile { title, id, items });
     }
+    out.sort_by_key(|l| l.title.to_lowercase());
+    Ok(out)
+}
+
+pub fn load_list(title: &str) -> std::io::Result<ListFile> {
+    let text = std::fs::read_to_string(list_path(title))?;
+    let (id, items) = parse_list(&text);
+    Ok(ListFile { title: safe_title(title), id, items })
 }
 
 /// Write-temp-then-rename in the same directory, so a crash mid-write never
 /// leaves a truncated list behind.
-pub fn save_items(items: &[Item]) -> std::io::Result<()> {
-    atomic_write(&data_path(), &serialize_items(items))
+pub fn save_list(list: &ListFile) -> std::io::Result<()> {
+    atomic_write(&list_path(&list.title), &serialize_list(list.id.as_deref(), &list.items))
+}
+
+pub fn delete_list(title: &str) -> std::io::Result<()> {
+    match std::fs::remove_file(list_path(title)) {
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        r => r,
+    }
+}
+
+pub fn rename_list(old: &str, new: &str) -> std::io::Result<()> {
+    let (from, to) = (list_path(old), list_path(new));
+    if from == to {
+        return Ok(());
+    }
+    std::fs::rename(from, to)
+}
+
+pub fn load_current() -> Option<String> {
+    std::fs::read_to_string(current_path())
+        .ok()
+        .map(|s| s.trim().to_string())
+        .filter(|s| !s.is_empty())
 }
 
-pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
+pub fn save_current(title: &str) -> std::io::Result<()> {
+    atomic_write(&current_path(), &format!("{}\n", safe_title(title)))
+}
+
+pub fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
     if let Some(dir) = path.parent() {
         std::fs::create_dir_all(dir)?;
     }
@@ -113,6 +233,48 @@ pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()
     std::fs::rename(&tmp, path)
 }
 
+/// `list.md` → `lists/…`, once. The single checklist used to mirror EVERY
+/// server list flat, so its rows are split by the list the sync state says
+/// each belongs to: the biggest group becomes `Tasks.md`, any other group a
+/// file named by its list id — both carrying that id in the header, so the
+/// next sync recognises them as those lists and renames the files to the
+/// server's titles instead of creating new lists on the phone. Rows the
+/// state does not know (typed locally, never synced) go with the biggest
+/// group. The old file is kept as `list.md.migrated`.
+pub fn migrate_legacy() -> std::io::Result<()> {
+    let legacy = legacy_path();
+    if lists_dir().exists() || !legacy.exists() {
+        return Ok(());
+    }
+    let text = std::fs::read_to_string(&legacy)?;
+    let items = parse_items(&text);
+    let state = load_sync_state().unwrap_or_default();
+    let majority = state.majority_list_id();
+    let mut groups: BTreeMap<Option<String>, Vec<Item>> = BTreeMap::new();
+    for item in items {
+        let owner = item
+            .uid
+            .as_deref()
+            .and_then(|u| state.items.get(u))
+            .map(|s| s.list_id())
+            .filter(|id| !id.is_empty())
+            .or_else(|| majority.clone());
+        groups.entry(owner).or_default().push(item);
+    }
+    if groups.is_empty() {
+        groups.insert(majority.clone(), Vec::new());
+    }
+    for (id, items) in groups {
+        let title = match (&id, &majority) {
+            (Some(i), Some(m)) if i != m => safe_title(i),
+            _ => "Tasks".to_string(),
+        };
+        save_list(&ListFile { title, id, items })?;
+    }
+    save_current("Tasks")?;
+    std::fs::rename(&legacy, legacy.with_extension("md.migrated"))
+}
+
 // ── Sync state (cce-list-sync's merge base; the app never touches it) ─────
 
 /// What the server held for one item at the end of the last sync. Comparing
@@ -129,12 +291,67 @@ pub struct SyncedItem {
     pub account: String,
     pub text: String,
     pub done: bool,
+    /// The server list the item belongs to. Older state files lack it; see
+    /// [`SyncedItem::list_id`], which falls back to reading the URL.
+    #[serde(default)]
+    pub list: String,
+}
+
+impl SyncedItem {
+    /// Google task URLs are `…/lists/{id}/tasks/{task}`; CalDAV item URLs
+    /// are `<calendar>/<uid>.ics`, where the calendar URL is the list id.
+    pub fn list_id(&self) -> String {
+        if !self.list.is_empty() {
+            return self.list.clone();
+        }
+        list_id_from_url(&self.url)
+    }
+}
+
+pub fn list_id_from_url(url: &str) -> String {
+    if let Some(rest) = url.split("/lists/").nth(1) {
+        if let Some(id) = rest.split("/tasks").next() {
+            return id.to_string();
+        }
+    }
+    match url.rfind('/') {
+        Some(i) => url[..=i].to_string(),
+        None => String::new(),
+    }
+}
+
+/// A server list as last synced: its title then, so a rename on either
+/// side is told apart from the other.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct SyncedList {
+    pub title: String,
+    #[serde(default)]
+    pub etag: String,
+    pub account: String,
 }
 
 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
 pub struct SyncState {
     #[serde(default)]
     pub items: BTreeMap<String, SyncedItem>,
+    /// Keyed by server list id.
+    #[serde(default)]
+    pub lists: BTreeMap<String, SyncedList>,
+}
+
+impl SyncState {
+    /// The list most tracked items belong to — what the legacy single
+    /// checklist "was", for the migration.
+    pub fn majority_list_id(&self) -> Option<String> {
+        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
+        for item in self.items.values() {
+            let id = item.list_id();
+            if !id.is_empty() {
+                *counts.entry(id).or_default() += 1;
+            }
+        }
+        counts.into_iter().max_by_key(|(_, n)| *n).map(|(id, _)| id)
+    }
 }
 
 pub fn load_sync_state() -> std::io::Result<SyncState> {
@@ -189,4 +406,36 @@ mod tests {
         assert_eq!(parsed[1].uid, None);
         assert_eq!(parsed[1].text, "literal <!-- not a uid -->");
     }
+
+    #[test]
+    fn list_header_round_trips_and_is_optional() {
+        let items = vec![Item { text: "a".into(), done: false, uid: None }];
+        let text = serialize_list(Some("MDM5"), &items);
+        assert_eq!(parse_list(&text), (Some("MDM5".into()), items.clone()));
+        // No header: a hand-made file is a local-only list, first line and all.
+        assert_eq!(parse_list("- [ ] a\n"), (None, items));
+        // A header that is not `list:` is just an adopted line.
+        let (id, adopted) = parse_list("<!-- note -->\n- [ ] a\n");
+        assert_eq!(id, None);
+        assert_eq!(adopted.len(), 2);
+    }
+
+    #[test]
+    fn list_ids_come_from_urls_when_the_state_predates_them() {
+        assert_eq!(
+            list_id_from_url("https://tasks.googleapis.com/tasks/v1/lists/MDM5/tasks/abc"),
+            "MDM5"
+        );
+        assert_eq!(
+            list_id_from_url("https://p1-caldav.icloud.com/1/calendars/reminders/X.ics"),
+            "https://p1-caldav.icloud.com/1/calendars/reminders/"
+        );
+    }
+
+    #[test]
+    fn titles_become_safe_stems() {
+        assert_eq!(safe_title("Home/Garden"), "Home-Garden");
+        assert_eq!(safe_title("  "), "Untitled");
+    }
 }
+
diff --git a/src/main.rs b/src/main.rs
index 409fec5..faef495 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,22 +1,32 @@
-//! `cce-list` — a small list of things to remember, kept on the desktop.
+//! `cce-list` — small lists of things to remember, kept on the desktop.
 //!
 //! A plain floating window: the compositor saves and restores it across
 //! sessions (position, size, and respawn) like any other app, and in overview
-//! mode it takes the normal move/resize ring. One `TextBox` adds items; a
+//! mode it takes the normal move/resize ring. The title band is a dropdown
+//! naming the current list; it switches between lists and carries two
+//! trailing entries, "New list…" and "Delete list…", which turn the input
+//! box into a name prompt or a confirmation. One `TextBox` adds items; a
 //! click on a row toggles it done; the ✕ that appears on hover deletes it.
-//! Rows scroll when they outgrow the window. The list is a plain markdown
-//! checklist on disk (`~/.local/share/cce-list/list.md`), so it can be read
-//! and edited with anything. Items mirrored from iCloud Reminders by
-//! `cce-list-sync` carry a trailing `<!-- uid:… -->` comment; toggling or
-//! deleting them here is pushed to the server on the next sync tick.
-
-use cce_list::{load_items, save_items, Item};
+//! Rows scroll when they outgrow the window.
+//!
+//! Each list is a plain markdown checklist on disk
+//! (`~/.local/share/cce-list/lists/<title>.md`), so it can be read and
+//! edited with anything; the shown list is named in a `current` file next
+//! to them. Lists and items mirrored from Google Tasks by `cce-list-sync`
+//! carry `<!-- list:… -->` / `<!-- uid:… -->` comments; toggling, adding,
+//! deleting — items or whole lists — here is pushed to the server on the
+//! next sync tick, and the app re-reads the directory when the sync (or a
+//! hand edit) changes it.
+
+use cce_list::{
+    delete_list, lists_dir, load_current, load_lists, save_current, save_list, Item, ListFile,
+};
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx, PlateSpec};
 use cce_ui::widget::{
-    Adapted, Bounds, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey,
-    ScrollMotion, TextBox, WidgetHost,
+    Adapted, Bounds, Dropdown, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta,
+    NamedKey, ScrollMotion, TextBox, WidgetHost,
 };
 use wayland_client::QueueHandle;
 
@@ -29,24 +39,38 @@ const MIN_SIZE: (u32, u32) = (220, 160);
 const ROW_H: f32 = 26.0;
 const INPUT_H: f32 = 30.0;
 const TITLE_FONT_SIZE: f32 = 14.0;
+/// The list switcher in the title band: its height, and the share of the
+/// band's width it takes — the rest stays a drag handle for the window.
+const SWITCHER_H: f32 = 24.0;
+const SWITCHER_SHARE: f32 = 0.62;
 /// Checkbox disc radius; its hit target is the whole row, this is only drawn.
 /// The mark itself is cce-ui's round `Checkbox` style, so it matches one.
 const CHECK_R: f32 = cce_ui::widget::Checkbox::ROUND_RADIUS;
 /// Side of the ✕ delete target at a row's right edge.
 const DELETE_S: f32 = 18.0;
+/// How often the lists directory is re-read for outside changes (the sync
+/// timer, a hand edit), in seconds.
+const WATCH_EVERY: f32 = 1.0;
+
+/// The switcher's trailing pseudo-entries, after the list titles.
+const NEW_LIST: &str = "New list…";
+const DELETE_LIST: &str = "Delete list…";
+const ITEM_PLACEHOLDER: &str = "Remember to…";
 
 #[derive(Debug, Clone)]
 enum ListMessage {
     Exit,
 }
 
-// Item, the markdown parse/serialize, and load/save live in the lib
-// (src/lib.rs), shared with the cce-list-sync helper.
-
-fn save(items: &[Item]) {
-    if let Err(e) = save_items(items) {
-        log::error!("cce-list: failed to save list: {e}");
-    }
+/// What the input box is for right now.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Mode {
+    /// Typing adds an item to the current list.
+    Items,
+    /// Typing names a new list; Enter creates and shows it.
+    NamingList,
+    /// Enter deletes the current list, Escape keeps it.
+    ConfirmDelete,
 }
 
 // ── Layout: hand math over a fixed-width column ───────────────────────────
@@ -56,6 +80,7 @@ fn save(items: &[Item]) {
 struct Metrics {
     pad: f32,
     band_h: f32,
+    switcher: Rect,
     input: Rect,
     list_top: f32,
 }
@@ -72,6 +97,12 @@ fn metrics(width: f32) -> Metrics {
     Metrics {
         pad,
         band_h,
+        switcher: Rect {
+            x: pad,
+            y: ((band_h - SWITCHER_H) / 2.0).max(2.0),
+            width: ((width - 2.0 * pad) * SWITCHER_SHARE).max(80.0),
+            height: SWITCHER_H,
+        },
         input: Rect { x: pad, y: input_y, width: width - 2.0 * pad, height: INPUT_H },
         list_top: input_y + INPUT_H + gap,
     }
@@ -86,11 +117,34 @@ fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
     ]
 }
 
+/// A cheap fingerprint of the lists directory and the `current` pointer:
+/// names and mtimes. Compared each second; a change means something else
+/// wrote there and the app re-reads.
+fn disk_signature() -> Vec<(String, Option<std::time::SystemTime>)> {
+    let mut sig = Vec::new();
+    if let Ok(entries) = std::fs::read_dir(lists_dir()) {
+        for e in entries.flatten() {
+            let mtime = e.metadata().and_then(|m| m.modified()).ok();
+            sig.push((e.file_name().to_string_lossy().into_owned(), mtime));
+        }
+    }
+    sig.push((
+        "current".to_string(),
+        std::fs::metadata(cce_list::current_path()).and_then(|m| m.modified()).ok(),
+    ));
+    sig.sort();
+    sig
+}
+
 // ── Application ───────────────────────────────────────────────────────────
 
 struct ListApp {
-    /// The source of truth; every mutation saves before the frame that shows it.
-    items: Vec<Item>,
+    /// Every list on disk, sorted by title; `cur` indexes the shown one.
+    /// Every mutation saves before the frame that shows it.
+    lists: Vec<ListFile>,
+    cur: usize,
+    mode: Mode,
+    switcher: Adapted<Dropdown>,
     input_box: Adapted<TextBox>,
     ui_context: cce_ui::context::UiContext,
     width: u32,
@@ -106,9 +160,191 @@ struct ListApp {
     scroll_motion: ScrollMotion,
     pointer: Option<(f32, f32)>,
     hovered_row: Option<usize>,
+    /// Outside-change detection: what the directory looked like when the
+    /// lists were last read, and the countdown to the next look.
+    disk_sig: Vec<(String, Option<std::time::SystemTime>)>,
+    watch_timer: f32,
 }
 
 impl ListApp {
+    fn items(&self) -> &[Item] {
+        self.lists.get(self.cur).map(|l| l.items.as_slice()).unwrap_or(&[])
+    }
+
+    fn switcher_options(lists: &[ListFile]) -> Vec<String> {
+        lists
+            .iter()
+            .map(|l| l.title.clone())
+            .chain([NEW_LIST.to_string(), DELETE_LIST.to_string()])
+            .collect()
+    }
+
+    /// (Re)read every list from disk. Keeps the shown list by title where it
+    /// still exists (the sync may have renamed or removed it), guarantees at
+    /// least one list, and refreshes the switcher.
+    fn load_from_disk(&mut self) {
+        let mut lists = match load_lists() {
+            Ok(l) => l,
+            Err(e) => {
+                log::error!("cce-list: reading lists: {e}");
+                Vec::new()
+            }
+        };
+        if lists.is_empty() {
+            let first = ListFile { title: "Tasks".to_string(), id: None, items: Vec::new() };
+            if let Err(e) = save_list(&first) {
+                log::error!("cce-list: creating the first list: {e}");
+            }
+            lists.push(first);
+        }
+        let wanted = load_current().or_else(|| self.lists.get(self.cur).map(|l| l.title.clone()));
+        let cur = wanted
+            .as_deref()
+            .and_then(|t| lists.iter().position(|l| l.title == t))
+            .unwrap_or(0);
+        if wanted.as_deref() != Some(lists[cur].title.as_str()) {
+            let _ = save_current(&lists[cur].title);
+        }
+        self.lists = lists;
+        self.cur = cur;
+        self.switcher.options = Self::switcher_options(&self.lists);
+        self.switcher.selected = cur;
+        self.disk_sig = disk_signature();
+        self.clamp_scroll();
+        if let Some((px, py)) = self.pointer {
+            self.hovered_row = self.row_at(px, py);
+        }
+        self.needs_rebuild = true;
+    }
+
+    fn save_current_list(&mut self) {
+        if let Some(list) = self.lists.get(self.cur) {
+            if let Err(e) = save_list(list) {
+                log::error!("cce-list: failed to save {}: {e}", list.title);
+            }
+        }
+        // Our own write must not read as an outside change next tick.
+        self.disk_sig = disk_signature();
+    }
+
+    fn select_list(&mut self, idx: usize) {
+        if idx >= self.lists.len() {
+            return;
+        }
+        self.cur = idx;
+        self.switcher.selected = idx;
+        if let Err(e) = save_current(&self.lists[idx].title) {
+            log::error!("cce-list: saving current list: {e}");
+        }
+        self.disk_sig = disk_signature();
+        self.scroll = 0.0;
+        self.hovered_row = None;
+        self.set_mode(Mode::Items);
+        self.needs_rebuild = true;
+    }
+
+    fn set_mode(&mut self, mode: Mode) {
+        self.mode = mode;
+        let placeholder = match mode {
+            Mode::Items => ITEM_PLACEHOLDER.to_string(),
+            Mode::NamingList => "Name the new list, then Enter".to_string(),
+            Mode::ConfirmDelete => format!(
+                "Enter deletes “{}” · Esc keeps it",
+                self.lists.get(self.cur).map(|l| l.title.as_str()).unwrap_or("")
+            ),
+        };
+        self.input_box.set_placeholder(&placeholder);
+        self.clear_input();
+    }
+
+    fn clear_input(&mut self) {
+        self.input_box.text.clear();
+        self.input_box.edit_buffer.clear();
+        self.input_box.cursor_idx = 0;
+    }
+
+    /// The live value: while the box is in edit mode the typed text sits in
+    /// `edit_buffer`; `text` is only the last committed value.
+    fn input_value(&self) -> String {
+        let raw = if self.input_box.editing {
+            &self.input_box.edit_buffer
+        } else {
+            &self.input_box.text
+        };
+        raw.trim().to_string()
+    }
+
+    fn begin_new_list(&mut self) {
+        self.set_mode(Mode::NamingList);
+        self.input_box.focus();
+        self.needs_rebuild = true;
+    }
+
+    fn create_list(&mut self, title: &str) {
+        let title = cce_list::safe_title(title);
+        if let Some(idx) = self.lists.iter().position(|l| l.title == title) {
+            // Already there: just show it.
+            self.select_list(idx);
+            return;
+        }
+        let list = ListFile { title: title.clone(), id: None, items: Vec::new() };
+        if let Err(e) = save_list(&list) {
+            log::error!("cce-list: creating {title}: {e}");
+            return;
+        }
+        self.lists.push(list);
+        self.lists.sort_by_key(|l| l.title.to_lowercase());
+        self.switcher.options = Self::switcher_options(&self.lists);
+        let idx = self.lists.iter().position(|l| l.title == title).unwrap_or(0);
+        self.select_list(idx);
+    }
+
+    fn begin_delete(&mut self) {
+        if self.lists.len() <= 1 {
+            // The server keeps a default list too; one is the floor.
+            self.set_mode(Mode::Items);
+            self.input_box.set_placeholder("Keep at least one list");
+            self.needs_rebuild = true;
+            return;
+        }
+        self.set_mode(Mode::ConfirmDelete);
+        self.input_box.unfocus();
+        self.needs_rebuild = true;
+    }
+
+    fn confirm_delete(&mut self) {
+        let Some(list) = self.lists.get(self.cur) else { return };
+        let title = list.title.clone();
+        if let Err(e) = delete_list(&title) {
+            log::error!("cce-list: deleting {title}: {e}");
+            self.set_mode(Mode::Items);
+            return;
+        }
+        self.lists.remove(self.cur);
+        self.switcher.options = Self::switcher_options(&self.lists);
+        let idx = self.cur.min(self.lists.len().saturating_sub(1));
+        self.select_list(idx);
+    }
+
+    /// The switcher reported a pick: a list, or one of the two actions.
+    fn switcher_picked(&mut self) {
+        let idx = self.switcher.selected;
+        if idx < self.lists.len() {
+            if idx != self.cur {
+                self.select_list(idx);
+            }
+        } else {
+            // A pseudo-entry: restore the trigger to the shown list.
+            self.switcher.selected = self.cur;
+            if idx == self.lists.len() {
+                self.begin_new_list();
+            } else {
+                self.begin_delete();
+            }
+        }
+        self.needs_rebuild = true;
+    }
+
     fn list_viewport(&self, m: &Metrics) -> Rect {
         Rect {
             x: 0.0,
@@ -138,7 +374,7 @@ impl ListApp {
     }
 
     fn max_scroll(&self, m: &Metrics) -> f32 {
-        (self.items.len() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
+        (self.items().len() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
     }
 
     fn clamp_scroll(&mut self) {
@@ -176,32 +412,35 @@ impl ListApp {
             return None;
         }
         let i = ((y - m.list_top + self.scroll) / ROW_H).floor();
-        let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.items.len())?;
+        let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.items().len())?;
         let r = self.row_rect(&m, row);
         (x >= r.x && x <= r.x + r.width).then_some(row)
     }
 
     fn submit_input(&mut self) {
-        // The live value: while the box is in edit mode the typed text sits in
-        // `edit_buffer`; `text` is only the last committed value.
-        let raw = if self.input_box.editing {
-            &self.input_box.edit_buffer
-        } else {
-            &self.input_box.text
-        };
-        let text = raw.trim().to_string();
-        if text.is_empty() {
-            return;
+        let text = self.input_value();
+        match self.mode {
+            Mode::ConfirmDelete => self.confirm_delete(),
+            Mode::NamingList => {
+                if !text.is_empty() {
+                    self.create_list(&text);
+                }
+            }
+            Mode::Items => {
+                if text.is_empty() {
+                    return;
+                }
+                if let Some(list) = self.lists.get_mut(self.cur) {
+                    list.items.push(Item { text, done: false, uid: None });
+                }
+                self.clear_input();
+                self.save_current_list();
+                // Keep the fresh item in view once the window is at its height cap.
+                let m = metrics(self.width as f32);
+                self.scroll = self.max_scroll(&m);
+                self.needs_rebuild = true;
+            }
         }
-        self.items.push(Item { text, done: false, uid: None });
-        self.input_box.text.clear();
-        self.input_box.edit_buffer.clear();
-        self.input_box.cursor_idx = 0;
-        save(&self.items);
-        // Keep the fresh item in view once the window is at its height cap.
-        let m = metrics(self.width as f32);
-        self.scroll = self.max_scroll(&m);
-        self.needs_rebuild = true;
     }
 }
 
@@ -213,9 +452,12 @@ impl Application for ListApp {
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
-        Self {
-            items: load_items(),
-            input_box: TextBox::new(String::new()).with_placeholder("Remember to…"),
+        let mut app = Self {
+            lists: Vec::new(),
+            cur: 0,
+            mode: Mode::Items,
+            switcher: Dropdown::new(Vec::new(), 0),
+            input_box: TextBox::new(String::new()).with_placeholder(ITEM_PLACEHOLDER),
             ui_context: cce_ui::context::UiContext::new(),
             width: INIT_W,
             height: INIT_H,
@@ -226,7 +468,11 @@ impl Application for ListApp {
             scroll_motion: ScrollMotion::new(),
             pointer: None,
             hovered_row: None,
-        }
+            disk_sig: Vec::new(),
+            watch_timer: 0.0,
+        };
+        app.load_from_disk();
+        app
     }
 
     fn settings(&self) -> WindowSettings {
@@ -255,14 +501,26 @@ impl Application for ListApp {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
+        // Outside changes (the sync tick, a hand edit) show up without a
+        // relaunch — but never while typing a name, which a reload would
+        // interrupt; that waits a second.
+        self.watch_timer += dt;
+        if self.watch_timer >= WATCH_EVERY {
+            self.watch_timer = 0.0;
+            if self.mode == Mode::Items && disk_signature() != self.disk_sig {
+                self.load_from_disk();
+                *needs_rebuild = true;
+            }
+        }
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
         // Register once, at self's final address (the registry stores pointers).
         if !self.widgets_registered {
             self.widgets_registered = true;
-            let ptr = self.input_box.as_ptr_mut();
-            let id = self.input_box.id();
+            let (id, ptr) = (self.input_box.id(), self.input_box.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.switcher.id(), self.switcher.as_ptr_mut());
             self.ui_context.register_widget(id, ptr);
         }
 
@@ -280,9 +538,19 @@ impl Application for ListApp {
         if self.needs_rebuild || size_changed {
             self.input_box
                 .set_rect(m.input.x, m.input.y, m.input.width, m.input.height);
+            self.switcher
+                .set_rect(m.switcher.x, m.switcher.y, m.switcher.width, m.switcher.height);
             self.needs_rebuild = false;
             self.ui_context.rebuild_spatial_grid();
         }
+        // An open menu overlays the rows: registered as a popover so it is
+        // hit-tested above them and clips the row text beneath; the popover
+        // pass at the end of this function draws it. Re-registered every
+        // frame from a clean slate, since the rect animates and closes.
+        self.ui_context.clear_popovers();
+        if self.switcher.popover_rect().is_some() {
+            self.ui_context.register_popover(&mut self.switcher);
+        }
 
         let (w, h) = (self.width as f32, self.height as f32);
         let mut pc = PaintCtx::new();
@@ -307,25 +575,15 @@ impl Application for ListApp {
             (false, false, true, false),
         );
 
-        let (title_family, _) = cce_ui::layout::menubar_font_parsed();
-        pc.text_with(
-            "cce-list".to_string(),
-            m.pad,
-            cce_ui::layout::align_text_y(0.0, m.band_h, TITLE_FONT_SIZE, 0.0),
-            TITLE_FONT_SIZE,
-            srgb_u8(cce_ui::colors::TEXT_HEADER),
-            Some(title_family),
-            None,
-        );
-
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.input_box, &mut pc);
 
         // The rows, clipped to the viewport so a scrolled list never bleeds
         // into the input or the plate's bottom roll.
         let (family, font_size) = cce_ui::layout::list_font_parsed();
         let vp = self.list_viewport(&m);
+        let items = self.items();
         pc.clip(vp, |pc| {
-            if self.items.is_empty() {
+            if items.is_empty() {
                 let r = self.row_rect(&m, 0);
                 pc.text_with(
                     "nothing to remember".to_string(),
@@ -337,7 +595,7 @@ impl Application for ListApp {
                     None,
                 );
             }
-            for (i, item) in self.items.iter().enumerate() {
+            for (i, item) in items.iter().enumerate() {
                 let r = self.row_rect(&m, i);
                 if r.y + r.height < vp.y || r.y > vp.y + vp.height {
                     continue;
@@ -390,6 +648,10 @@ impl Application for ListApp {
             }
         });
 
+        // The switcher's trigger, then its open menu on top of everything.
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.switcher, &mut pc);
+        self.switcher.render_popover(&mut pc);
+
         Some(pc.finish())
     }
 
@@ -405,9 +667,11 @@ impl Application for ListApp {
         Some(&mut self.ui_context)
     }
 
-    /// Drag the window by its title band; everywhere else is content.
-    fn is_movable_root_plate_at(&self, _px: f32, py: f32) -> bool {
-        py <= metrics(self.width as f32).band_h
+    /// Drag the window by the title band, right of the switcher; everywhere
+    /// else is content.
+    fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
+        let m = metrics(self.width as f32);
+        py <= m.band_h && px > m.switcher.x + m.switcher.width
     }
 
     fn clear_color(&self) -> [f32; 4] {
@@ -416,12 +680,16 @@ impl Application for ListApp {
 
     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
         self.pointer = Some((pos.x, pos.y));
-        let hovered = self.row_at(pos.x, pos.y);
+        let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+        if self.ui_context.propagate_event(&ev, self.switcher.id()) {
+            *needs_rebuild = true;
+        }
+        // Rows under an open menu are not hoverable.
+        let hovered = if self.switcher.open { None } else { self.row_at(pos.x, pos.y) };
         if hovered != self.hovered_row {
             self.hovered_row = hovered;
             *needs_rebuild = true;
         }
-        let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
             *needs_rebuild = true;
         }
@@ -435,18 +703,33 @@ impl Application for ListApp {
         needs_rebuild: &mut bool,
     ) -> Option<Self::Message> {
         let (px, py) = (pos.x, pos.y);
+        let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
+
+        // The switcher routes first: its open menu overlays the rows, so a
+        // press it handles must not fall through to what is beneath.
+        if self.ui_context.propagate_event(&ev, self.switcher.id()) {
+            if self.switcher.take_change() {
+                self.switcher_picked();
+            }
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+            return None;
+        }
+
         if button == MouseButton::Left && state == ElementState::Pressed {
             if let Some(i) = self.row_at(px, py) {
                 let m = metrics(self.width as f32);
                 let d = Self::delete_rect(self.row_rect(&m, i));
-                if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
-                    self.items.remove(i);
-                    self.clamp_scroll();
-                    self.hovered_row = self.row_at(px, py);
-                } else {
-                    self.items[i].done = !self.items[i].done;
+                if let Some(list) = self.lists.get_mut(self.cur) {
+                    if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
+                        list.items.remove(i);
+                        self.clamp_scroll();
+                        self.hovered_row = self.row_at(px, py);
+                    } else {
+                        list.items[i].done = !list.items[i].done;
+                    }
                 }
-                save(&self.items);
+                self.save_current_list();
                 self.needs_rebuild = true;
                 *needs_rebuild = true;
                 return None;
@@ -456,7 +739,6 @@ impl Application for ListApp {
             self.input_box.unfocus();
             *needs_rebuild = true;
         }
-        let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
             *needs_rebuild = true;
         }
@@ -471,7 +753,7 @@ impl Application for ListApp {
     ) {
         let m = metrics(self.width as f32);
         let max = self.max_scroll(&m);
-        if max <= 0.0 {
+        if max <= 0.0 || self.switcher.open {
             return;
         }
         self.scroll_motion.reconcile(0.0, self.scroll);
@@ -484,12 +766,23 @@ impl Application for ListApp {
         }
     }
 
-
     fn handle_key_input(
         &mut self,
         event: &KeyEvent,
         needs_rebuild: &mut bool,
     ) -> Option<Self::Message> {
+        let ev = Event::KeyInput(event.clone());
+        // An open menu takes the keyboard: arrows move, Enter picks.
+        if self.switcher.open {
+            if self.ui_context.propagate_event(&ev, self.switcher.id()) {
+                if self.switcher.take_change() {
+                    self.switcher_picked();
+                }
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+                return None;
+            }
+        }
         if event.state == ElementState::Pressed && !event.repeat {
             if event.ctrl {
                 if let Key::Character(ref c) = event.logical_key {
@@ -500,21 +793,26 @@ impl Application for ListApp {
             }
             if let Key::Named(NamedKey::Escape) = event.logical_key {
                 self.input_box.unfocus();
+                if self.mode != Mode::Items {
+                    self.set_mode(Mode::Items);
+                }
                 *needs_rebuild = true;
+                self.needs_rebuild = true;
                 return None;
             }
             if let Key::Named(NamedKey::Enter) = event.logical_key {
                 // The box's own edit mode, not `focused(&ctx)`: a click focuses
                 // through the thread-local focus registry, so the UiContext's
                 // focused_widget — which that checks — never learns of it.
-                if self.input_box.editing {
+                // A pending deletion takes Enter from anywhere.
+                if self.input_box.editing || self.mode == Mode::ConfirmDelete {
                     self.submit_input();
                     *needs_rebuild = true;
+                    self.needs_rebuild = true;
                     return None;
                 }
             }
         }
-        let ev = Event::KeyInput(event.clone());
         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
             *needs_rebuild = true;
         }