Remove manual sync UI; add auto-sync lifecycle and status

Remove deprecated sync mode UI and manual sync controls from
SettingsScreen and TasksScreen. Implement an always-full sync mode and a
new auto-sync lifecycle in the app store: startAutoSync/stopAutoSync,
periodic polling (60s), debounced file-change-triggered sync (5s), and
window-focus-triggered sync when last sync is older than 30s. Track
syncStatus (idle/synced/error/offline), lastSyncTime, and surface status
in the Tasks drawer with a status dot and a manual sync button. Wire
auto-sync startup/teardown into loadConfig, switchWorkspace,
addWebdavWorkspace, and removeWorkspace.

These changes simplify sync configuration by removing legacy sync modes
and UI elements, provide robust automatic syncing for WebDAV workspaces,
and improve user feedback on sync state and errors.
This commit is contained in:
Tristan Michael 2026-04-05 16:19:43 -07:00
parent e33fb9dd0b
commit 2f90805594
3 changed files with 81 additions and 45 deletions

View file

@ -202,38 +202,6 @@
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<select
value={app.syncMode}
onchange={(e) => app.setSyncMode((e.target as HTMLSelectElement).value as "full" | "push" | "pull")}
class="appearance-none rounded-lg border border-border-light bg-surface-light px-3 py-2 text-sm text-text-light outline-none focus:border-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
>
<option value="full">Sync both ways</option>
<option value="push">Push only</option>
<option value="pull">Pull only</option>
</select>
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="flex-1 rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{app.syncing ? "Syncing..." : "Sync Now"}
</button>
</div>
{#if app.error}
<p class="mt-1.5 text-xs text-danger">{app.error}</p>
{/if}
{#if ws?.last_sync}
{@const lastSync = new Date(ws.last_sync)}
{@const secsAgo = Math.floor((Date.now() - lastSync.getTime()) / 1000)}
{@const relTime = secsAgo < 60 ? "just now" : secsAgo < 3600 ? `${Math.floor(secsAgo / 60)}m ago` : `${Math.floor(secsAgo / 3600)}h ago`}
<p class="mt-1.5 text-xs opacity-40">
Last sync: {relTime}
{#if app.lastSyncResult}
&nbsp;·&nbsp;{app.lastSyncResult.uploaded}{app.lastSyncResult.downloaded}
{/if}
</p>
{/if}
</section>
{/if}

View file

@ -326,6 +326,34 @@
</div>
</div>
<!-- Drawer footer: sync status -->
<div class="shrink-0 border-t border-border-light px-4 py-2.5 dark:border-border-dark">
{#if app.isWebdav}
<div class="flex items-center gap-2">
<!-- Status dot -->
<span
class="inline-block h-2 w-2 rounded-full {app.syncing ? 'animate-pulse bg-primary' : app.syncStatus === 'synced' || app.syncStatus === 'idle' ? 'bg-green-500' : app.syncStatus === 'error' ? 'bg-red-500' : 'bg-gray-400'}"
></span>
<span class="flex-1 text-xs opacity-60">
{app.syncing ? "Syncing..." : app.syncStatus === "synced" || app.syncStatus === "idle" ? "Synced" : app.syncStatus === "error" ? "Sync error" : "Offline"}
</span>
<!-- Manual sync button -->
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="rounded-lg p-1.5 hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10"
title="Sync now"
>
<svg class="h-4 w-4 {app.syncing ? 'animate-spin' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>
</button>
</div>
{:else}
<span class="text-xs opacity-40">Local workspace</span>
{/if}
</div>
</div>
<!-- Main content panel -->

View file

@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type {
AppConfig,
Task,
@ -11,6 +12,8 @@ import type {
// Listen for file system changes from the backend watcher.
listen("fs-changed", () => {
loadLists();
// Debounced sync for WebDAV workspaces on local file changes
if (isWebdav) debouncedSync();
});
// ── Reactive state ───────────────────────────────────────────────────
@ -22,10 +25,17 @@ let activeListId = $state<string | null>(null);
let tasks = $state<Task[]>([]);
let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
let syncing = $state(false);
let syncMode = $state<"full" | "push" | "pull">("full");
let syncStatus = $state<"idle" | "synced" | "error" | "offline">("idle");
let lastSyncResult = $state<SyncResult | null>(null);
let error = $state<string | null>(null);
let missingWorkspace = $state<string | null>(null);
let lastSyncTime = 0;
let _syncInterval: ReturnType<typeof setInterval> | null = null;
let _syncDebounce: ReturnType<typeof setTimeout> | null = null;
let _focusUnlisten: (() => void) | null = null;
const SYNC_POLL_MS = 60_000;
const SYNC_DEBOUNCE_MS = 5_000;
const SYNC_FOCUS_THRESHOLD_MS = 30_000;
// ── Derived ──────────────────────────────────────────────────────────
@ -64,6 +74,11 @@ let currentTheme = $derived(
let isDark = $derived(
currentTheme ? DARK_THEMES.has(currentTheme) : osDark,
);
let isWebdav = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.mode === "webdav"
: false,
);
// ── Actions ──────────────────────────────────────────────────────────
@ -83,6 +98,7 @@ async function loadConfig() {
if (lists.length > 0 && !activeListId) activeListId = lists[0].id;
if (activeListId) await loadTasks();
screen = "tasks";
if (isWebdav) startAutoSync();
} else {
screen = "setup";
}
@ -114,6 +130,7 @@ async function switchWorkspace(id: string) {
await loadLists();
const ws = config?.workspaces[id];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
if (isWebdav) startAutoSync(); else stopAutoSync();
error = null;
} catch (e) {
error = String(e);
@ -131,6 +148,7 @@ async function renameWorkspace(id: string, newName: string) {
}
async function removeWorkspace(id: string) {
stopAutoSync();
try {
await invoke("remove_workspace", { id });
config = await invoke<AppConfig>("get_config");
@ -302,29 +320,46 @@ async function setGroupByDueDate(listId: string, enabled: boolean) {
}
async function triggerSync() {
if (!config?.current_workspace) return;
if (!config?.current_workspace || syncing) return;
syncing = true;
error = null;
try {
const result = await invoke<SyncResult>("sync_workspace", {
workspaceId: config.current_workspace,
mode: syncMode,
mode: "full",
});
lastSyncResult = result;
if (result.errors.length > 0) {
error = result.errors.join("; ");
}
lastSyncTime = Date.now();
syncStatus = result.errors.length > 0 ? "error" : "synced";
if (result.errors.length > 0) error = result.errors.join("; ");
config = await invoke<AppConfig>("get_config");
await loadLists();
} catch (e) {
error = String(e);
const msg = String(e);
syncStatus = msg.includes("timeout") || msg.includes("connect") || msg.includes("network") ? "offline" : "error";
error = msg;
} finally {
syncing = false;
}
}
function setSyncMode(mode: "full" | "push" | "pull") {
syncMode = mode;
function debouncedSync() {
if (_syncDebounce) clearTimeout(_syncDebounce);
_syncDebounce = setTimeout(() => { _syncDebounce = null; triggerSync(); }, SYNC_DEBOUNCE_MS);
}
function startAutoSync() {
stopAutoSync();
triggerSync();
_syncInterval = setInterval(triggerSync, SYNC_POLL_MS);
getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused && Date.now() - lastSyncTime > SYNC_FOCUS_THRESHOLD_MS) triggerSync();
}).then((unlisten) => { _focusUnlisten = unlisten; });
}
function stopAutoSync() {
if (_syncInterval) { clearInterval(_syncInterval); _syncInterval = null; }
if (_syncDebounce) { clearTimeout(_syncDebounce); _syncDebounce = null; }
if (_focusUnlisten) { _focusUnlisten(); _focusUnlisten = null; }
}
async function setTheme(theme: string | null) {
@ -351,6 +386,7 @@ async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: s
}
screen = "tasks";
error = null;
if (isWebdav) startAutoSync();
} catch (e) {
error = String(e);
}
@ -420,8 +456,11 @@ export const app = {
get syncing() {
return syncing;
},
get syncMode() {
return syncMode;
get syncStatus() {
return syncStatus;
},
get isWebdav() {
return isWebdav;
},
get lastSyncResult() {
return lastSyncResult;
@ -455,7 +494,8 @@ export const app = {
renameList,
setGroupByDueDate,
triggerSync,
setSyncMode,
startAutoSync,
stopAutoSync,
setTheme,
addWebdavWorkspace,
forgetMissingWorkspace,