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::sync::Mutex;
use std::time::Instant; use std::time::Instant;
use chrono::Utc;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
@ -422,21 +424,40 @@ async fn test_webdav_connection(
#[tauri::command] #[tauri::command]
async fn sync_workspace( async fn sync_workspace(
workspace_name: String,
workspace_path: String, workspace_path: String,
webdav_url: String, webdav_url: String,
username: String, username: String,
password: String, password: String,
mode: String,
state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> { ) -> Result<SyncResult, String> {
let sync_mode = match mode.as_str() {
"push" => SyncMode::Push,
"pull" => SyncMode::Pull,
_ => SyncMode::Full,
};
let result = sync::sync_workspace( let result = sync::sync_workspace(
&PathBuf::from(workspace_path), &PathBuf::from(&workspace_path),
&webdav_url, &webdav_url,
&username, &username,
&password, &password,
SyncMode::Full, sync_mode,
None, None,
) )
.await .await
.map_err(|e| e.to_string())?; .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()) Ok(result.into())
} }

View file

@ -122,13 +122,35 @@
</div> </div>
{#if app.config?.current_workspace} {#if app.config?.current_workspace}
<button <div class="mt-3 flex items-center gap-2">
onclick={() => app.triggerSync()} <select
disabled={app.syncing} value={app.syncMode}
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" 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"
{app.syncing ? "Syncing…" : "Sync Now"} >
</button> <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} {/if}
</section> </section>

View file

@ -550,9 +550,14 @@
</div> </div>
</div> </div>
<!-- Sync spinner --> <!-- Sync status indicator -->
{#if app.syncing} {#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> <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} {/if}
</div> </div>
</div> </div>

View file

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