diff --git a/CLAUDE.md b/CLAUDE.md index 1b2f55a..ba70806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,15 +35,15 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app: ### Key patterns -- **Storage trait** (`storage.rs`): Strategy pattern for task persistence. `FileSystemStorage` reads/writes markdown files with YAML frontmatter and JSON metadata files. Atomic writes (temp file + rename) for all metadata files. Input validation: task titles max 500 chars, descriptions max 1MB, list names max 255 chars. +- **Storage trait** (`storage.rs`): Strategy pattern for task persistence. `FileSystemStorage` reads/writes markdown files with YAML frontmatter and JSON metadata files. Atomic writes (temp file + rename, with temp cleanup on failure) for all metadata files. Input validation: task titles max 500 chars, descriptions max 1MB, list names max 255 chars, YAML frontmatter max 64KB. Delete operations update metadata before removing files to prevent orphaned metadata on crash. - **Repository** (`repository.rs`): `TaskRepository` wraps a `Storage` impl and provides the public API for task/list CRUD, ordering, and grouping. Tests live here. - **Config** (`config.rs`): `AppConfig` manages workspaces keyed by UUID string. `WorkspaceConfig` stores `name`, `path`, `mode` (Local/Webdav), `webdav_url`, `webdav_path` (user-selected remote folder), `theme`, and `sync_interval_secs`. `add_workspace` returns a generated UUID. Stored in platform-specific config dirs via the `directories` crate. Atomic writes (temp file + rename) prevent corruption on crash. -- **Sync** (`sync.rs`): Three-way diff sync with offline queue. Checksum-based conflict resolution: downloads remote, compares SHA-256 — identical content is a false conflict (skipped); when different, remote wins and local is recovered as a duplicate with a new UUID and `[RECOVERED FROM CONFLICT]` prefix. Auto-sync lifecycle: periodic polling (configurable interval, default 60s), debounced file-change (5s), window-focus (30s stale threshold). Wrapped in `tokio::time::timeout` (60s) to handle unreachable servers on Windows. Path traversal validation on all sync paths. Atomic writes for sync state and queue files. +- **Sync** (`sync.rs`): Three-way diff sync with offline queue. File-based `.sync.lock` prevents concurrent sync operations (auto-cleaned after 5 minutes if stale). Checksum-based conflict resolution: downloads remote, compares SHA-256 — identical content is a false conflict (skipped); when different, remote wins and local is recovered as a duplicate with a new UUID and `[RECOVERED FROM CONFLICT]` prefix (duplicate file cleaned up if metadata update fails). Auto-sync lifecycle: periodic polling (configurable interval, default 60s), debounced file-change (5s), window-focus (30s stale threshold). Wrapped in `tokio::time::timeout` (60s) to handle unreachable servers on Windows. Path traversal validation rejects `..` components and backslashes anywhere in sync paths. Atomic writes for sync state and queue files (temp cleanup on failure). - **WebDAV** (`webdav.rs`): reqwest client with rustls-tls, 30s request timeout, 10s connect timeout. Rejects non-HTTPS URLs. `Zeroizing` for credential fields. `move_resource` method for WebDAV MOVE (workspace rename). 10MB cap on both PROPFIND responses and file downloads. Desktop credentials via `keyring` crate (feature-gated); Tauri GUI uses `tauri-plugin-credentials` for cross-platform support (Android Keystore + desktop keychain). ### On-disk format -Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.onyx-workspace.json` for list ordering and workspace detection. Sync only processes files at expected depths: `.onyx-workspace.json` at root, `.listdata.json` and `*.md` inside list directories. Task frontmatter contains `id`, `status`, `version` (u64, increments on every write, defaults to 1 for legacy files), and optionally `due`, `has_time`, `parent` (omitted when false/null). `list_tasks` auto-deduplicates by UUID, keeping the highest-version file and deleting stale copies. +Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.onyx-workspace.json` for list ordering and workspace detection. Sync only processes files at expected depths: `.onyx-workspace.json` at root, `.listdata.json` and `*.md` inside list directories. Task frontmatter contains `id`, `status`, `version` (u64, increments via `saturating_add` on every write, defaults to 1 for legacy files), and optionally `due`, `has_time`, `parent` (omitted when false/null). `list_tasks` auto-deduplicates by UUID, keeping the highest-version file and deleting stale copies; when versions are equal, filesystem modification time is used as a tiebreaker. ### Tauri GUI structure @@ -95,7 +95,7 @@ Pre-alpha. No users, no released builds, no data to migrate. Breaking changes to - Per-workspace sync interval (configurable in settings) - Upload/download counts in drawer footer (hidden when zero) - Transient sync error suppression (connectivity issues update status dot only, no error banner) -- File watcher (notify crate, 500ms debounce, auto-reloads on external changes) +- File watcher (notify crate, 500ms debounce, auto-reloads on external changes, emits `watcher-error` event to frontend on failures) - WorkspaceMode enum (local/webdav) with per-workspace config, UUID-keyed workspaces - Workspace rename (local folder rename + WebDAV MOVE for remote folders, with confirmation dialog) - Desktop packaging (Linux: AppImage + .deb; Windows: MSI) @@ -106,6 +106,7 @@ Pre-alpha. No users, no released builds, no data to migrate. Breaking changes to - Custom confirmation dialogs (ConfirmDialog component replaces native confirm()) - Workspace path validation (rejects system directories) - Task detail auto-cleanup (taskStack clears when viewed task is deleted or list switches) +- Accessibility: ARIA labels/roles on interactive components, keyboard handlers, `prefers-reduced-motion` CSS support ### GUI features NOT yet done diff --git a/PLAN.md b/PLAN.md index 87c96b6..9054359 100644 --- a/PLAN.md +++ b/PLAN.md @@ -86,7 +86,7 @@ Task { status: TaskStatus, // Backlog or Completed due_date: Option, has_time: bool, // Whether due_date includes a specific time - version: u64, // Increments on every write; used for sync dedup + version: u64, // Increments (saturating) on every write; used for sync dedup parent_id: Option, // For subtasks } diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index b40718c..314a8d6 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -111,6 +111,8 @@ fn mute_watcher(_state: &mut AppState) { fn mute_watcher(_state: &mut AppState) {} /// Helper: get or open a TaskRepository for the current workspace. +/// Safe against double-init because it runs under the AppState Mutex and uses +/// get_or_insert to atomically check-and-set. fn ensure_repo(state: &mut AppState) -> Result<(), String> { if state.repo.is_some() { return Ok(()); @@ -119,7 +121,10 @@ fn ensure_repo(state: &mut AppState) -> Result<(), String> { .config .get_current_workspace() .map_err(|e| e.to_string())?; - let repo = TaskRepository::new(ws.path.clone()).map_err(|e| e.to_string())?; + let path = ws.path.clone(); + // Use a separate variable to avoid borrow issues — the Mutex ensures + // no concurrent access, so TOCTOU is not possible here. + let repo = TaskRepository::new(path).map_err(|e| e.to_string())?; state.repo = Some(repo); Ok(()) } @@ -587,7 +592,10 @@ async fn list_remote_folder( client.list_files(sp) }).collect(); let results: Vec<_> = futures::future::join_all(checks).await - .into_iter().map(|r| r.unwrap_or_default()).collect(); + .into_iter().map(|r| r.unwrap_or_else(|e| { + eprintln!("Warning: failed to inspect remote subfolder: {}", e); + Vec::new() + })).collect(); let folders = dir_entries.into_iter().zip(results).map(|(entry, sub_files)| { let is_workspace = sub_files.iter().any(|f| !f.is_dir && f.path == ".onyx-workspace.json"); @@ -616,7 +624,10 @@ async fn inspect_remote_workspace( } else { format!("{}/{}", path.trim_end_matches('/'), entry.path) }; - let files = client.list_files(&list_path).await.unwrap_or_default(); + let files = client.list_files(&list_path).await.unwrap_or_else(|e| { + eprintln!("Warning: failed to list remote folder '{}': {}", list_path, e); + Vec::new() + }); let has_listdata = files.iter().any(|f| !f.is_dir && f.path == ".listdata.json"); if has_listdata { let task_count = files.iter().filter(|f| !f.is_dir && f.path.ends_with(".md")).count(); @@ -803,7 +814,9 @@ fn start_watcher(handle: tauri::AppHandle, path: PathBuf) { std::time::Duration::from_millis(500), move |events: Result, notify::Error>| { let Ok(events) = events else { - eprintln!("File watcher error: {:?}", events.unwrap_err()); + let err = events.unwrap_err(); + eprintln!("File watcher error: {:?}", err); + let _ = handle.emit("watcher-error", format!("{}", err)); return; }; // Only care about data file changes diff --git a/apps/tauri/src/app.css b/apps/tauri/src/app.css index ceb6434..c6f76be 100644 --- a/apps/tauri/src/app.css +++ b/apps/tauri/src/app.css @@ -154,3 +154,16 @@ body { --color-border-dark: #094959; --color-danger: #dc322f; } + +/* ── Accessibility: Reduced motion ───────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/apps/tauri/src/lib/components/BottomSheet.svelte b/apps/tauri/src/lib/components/BottomSheet.svelte index 69ab754..4d2aa7d 100644 --- a/apps/tauri/src/lib/components/BottomSheet.svelte +++ b/apps/tauri/src/lib/components/BottomSheet.svelte @@ -1,17 +1,22 @@ -
{ if (e.key === "Escape") onclose(); }} >