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:
parent
72475a552a
commit
70d519de64
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,13 +122,35 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if app.config?.current_workspace}
|
{#if app.config?.current_workspace}
|
||||||
|
<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
|
<button
|
||||||
onclick={() => app.triggerSync()}
|
onclick={() => app.triggerSync()}
|
||||||
disabled={app.syncing}
|
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"
|
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"}
|
{app.syncing ? "Syncing…" : "Sync Now"}
|
||||||
</button>
|
</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}
|
||||||
|
· ↑{app.lastSyncResult.uploaded} ↓{app.lastSyncResult.downloaded}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue