feat: sync status indicators and push/pull mode selection

Add workspace_name and mode params to sync_workspace Tauri command.
After each sync, persist last_sync to WorkspaceConfig and save config.
Frontend gains lastSyncResult and syncMode state; triggerSync reloads
config post-sync and stores the result. SettingsScreen shows a sync
direction selector (Full/Push/Pull) and a last-sync status line.
TasksScreen shows an upload/download count chip after sync completes.
This commit is contained in:
Tristan Michael 2026-04-01 01:04:13 -07:00
parent 72475a552a
commit 70d519de64
4 changed files with 78 additions and 11 deletions

View file

@ -2,6 +2,8 @@ use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
use chrono::Utc;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
@ -422,21 +424,40 @@ async fn test_webdav_connection(
#[tauri::command]
async fn sync_workspace(
workspace_name: String,
workspace_path: String,
webdav_url: String,
username: String,
password: String,
mode: String,
state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> {
let sync_mode = match mode.as_str() {
"push" => SyncMode::Push,
"pull" => SyncMode::Pull,
_ => SyncMode::Full,
};
let result = sync::sync_workspace(
&PathBuf::from(workspace_path),
&PathBuf::from(&workspace_path),
&webdav_url,
&username,
&password,
SyncMode::Full,
sync_mode,
None,
)
.await
.map_err(|e| e.to_string())?;
// Persist last_sync timestamp to config
{
let mut s = state.lock().unwrap();
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.last_sync = Some(Utc::now());
}
let config_path = AppConfig::get_config_path();
s.config.save_to_file(&config_path).map_err(|e| e.to_string())?;
}
Ok(result.into())
}

View file

@ -122,13 +122,35 @@
</div>
{#if app.config?.current_workspace}
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="mt-3 w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{app.syncing ? "Syncing…" : "Sync Now"}
</button>
<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="rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-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.config.workspaces[app.config.current_workspace]?.last_sync}
{@const lastSync = new Date(app.config.workspaces[app.config.current_workspace].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}
{/if}
</section>

View file

@ -550,9 +550,14 @@
</div>
</div>
<!-- Sync spinner -->
<!-- Sync status indicator -->
{#if app.syncing}
<div class="absolute bottom-4 right-4 z-20 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{:else if app.lastSyncResult}
<div class="absolute bottom-4 right-4 z-20 flex items-center gap-1 rounded-full bg-black/10 px-2.5 py-1 text-xs opacity-60 dark:bg-white/10">
<span>{app.lastSyncResult.uploaded}</span>
<span>{app.lastSyncResult.downloaded}</span>
</div>
{/if}
</div>
</div>

View file

@ -24,6 +24,8 @@ let darkMode = $state(
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
);
let syncing = $state(false);
let syncMode = $state<"full" | "push" | "pull">("full");
let lastSyncResult = $state<SyncResult | null>(null);
let error = $state<string | null>(null);
// ── Derived ──────────────────────────────────────────────────────────
@ -254,7 +256,8 @@ async function setGroupByDueDate(listId: string, enabled: boolean) {
async function triggerSync() {
if (!config?.current_workspace) return;
const ws = config.workspaces[config.current_workspace];
const workspaceName = config.current_workspace;
const ws = config.workspaces[workspaceName];
if (!ws?.webdav_url) {
error = "No WebDAV URL configured";
return;
@ -265,14 +268,19 @@ async function triggerSync() {
const domain = new URL(ws.webdav_url).hostname;
const [username, password] = await invoke<[string, string]>("load_credentials", { domain });
const result = await invoke<SyncResult>("sync_workspace", {
workspaceName,
workspacePath: ws.path,
webdavUrl: ws.webdav_url,
username,
password,
mode: syncMode,
});
lastSyncResult = result;
if (result.errors.length > 0) {
error = result.errors.join("; ");
}
// Reload config to pick up updated last_sync timestamp
config = await invoke<AppConfig>("get_config");
await loadLists();
} catch (e) {
error = String(e);
@ -281,6 +289,10 @@ async function triggerSync() {
}
}
function setSyncMode(mode: "full" | "push" | "pull") {
syncMode = mode;
}
function toggleDarkMode() {
darkMode = !darkMode;
}
@ -326,6 +338,12 @@ export const app = {
get syncing() {
return syncing;
},
get syncMode() {
return syncMode;
},
get lastSyncResult() {
return lastSyncResult;
},
get error() {
return error;
},
@ -350,6 +368,7 @@ export const app = {
renameList,
setGroupByDueDate,
triggerSync,
setSyncMode,
toggleDarkMode,
setScreen,
clearError,