Add per-workspace sync interval and fix download timestamp recording

Add a configurable per-workspace sync interval (sync_interval_secs) with
Tauri command set_sync_interval, UI dropdown in Settings, and store
support to restart auto-sync when changed. Remove the redundant
main-panel sync status chip and surface upload/download counts in the
drawer footer and Tasks screen. Fix the sync logic to record the remote
file's last_modified timestamp when downloading (instead of local mtime)
so subsequent diffs don’t falsely trigger downloads.

These changes were needed to allow users to control auto-sync frequency
per workspace, simplify the UI by moving sync counts to the drawer
footer, and correct a bug where downloads were always considered new
because the local file mtime was used instead of the authoritative
remote timestamp.
This commit is contained in:
Tristan Michael 2026-04-05 16:35:22 -07:00
parent 7e5b3ea7b0
commit 0ae0705331
7 changed files with 68 additions and 18 deletions

View file

@ -511,6 +511,19 @@ fn set_workspace_theme(
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
}
#[tauri::command]
fn set_sync_interval(
workspace_id: String,
interval_secs: Option<u64>,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.sync_interval_secs = interval_secs;
}
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
}
/// A remote folder entry returned to the frontend.
#[derive(Debug, Serialize, Deserialize)]
struct RemoteFolderEntry {
@ -862,6 +875,7 @@ pub fn run() {
get_group_by_due_date,
set_webdav_config,
set_workspace_theme,
set_sync_interval,
add_webdav_workspace,
list_remote_folder,
inspect_remote_workspace,

View file

@ -202,6 +202,23 @@
</div>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-medium opacity-60">Sync interval</label>
<select
value={String(app.syncIntervalSecs)}
onchange={(e) => {
const val = parseInt((e.target as HTMLSelectElement).value);
app.setSyncInterval(val === 60 ? null : val);
}}
class="w-full 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="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="120">2 minutes</option>
<option value="300">5 minutes</option>
<option value="600">10 minutes</option>
</select>
</div>
</section>
{/if}

View file

@ -335,7 +335,7 @@
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"}
{app.syncing ? "Syncing..." : app.syncStatus === "synced" || app.syncStatus === "idle" ? "Synced" : app.syncStatus === "error" ? "Sync error" : "Offline"}{#if !app.syncing && app.lastSyncResult}&nbsp;&nbsp;{app.lastSyncResult.uploaded}{app.lastSyncResult.downloaded}{/if}
</span>
<!-- Manual sync button -->
<button
@ -595,15 +595,6 @@
</div>
</div>
<!-- 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>
</div>

View file

@ -33,7 +33,7 @@ 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 DEFAULT_SYNC_INTERVAL_SECS = 60;
const SYNC_DEBOUNCE_MS = 5_000;
const SYNC_FOCUS_THRESHOLD_MS = 30_000;
@ -79,6 +79,11 @@ let isWebdav = $derived(
? config.workspaces[config.current_workspace]?.mode === "webdav"
: false,
);
let syncIntervalSecs = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS
: DEFAULT_SYNC_INTERVAL_SECS,
);
// ── Actions ──────────────────────────────────────────────────────────
@ -350,7 +355,7 @@ function debouncedSync() {
function startAutoSync() {
stopAutoSync();
triggerSync();
_syncInterval = setInterval(triggerSync, SYNC_POLL_MS);
_syncInterval = setInterval(triggerSync, syncIntervalSecs * 1000);
getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused && Date.now() - lastSyncTime > SYNC_FOCUS_THRESHOLD_MS) triggerSync();
}).then((unlisten) => { _focusUnlisten = unlisten; });
@ -362,6 +367,20 @@ function stopAutoSync() {
if (_focusUnlisten) { _focusUnlisten(); _focusUnlisten = null; }
}
async function setSyncInterval(secs: number | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_sync_interval", {
workspaceId: config.current_workspace,
intervalSecs: secs,
});
config = await invoke<AppConfig>("get_config");
if (isWebdav) startAutoSync();
} catch (e) {
error = String(e);
}
}
async function setTheme(theme: string | null) {
if (!config?.current_workspace) return;
try {
@ -462,6 +481,9 @@ export const app = {
get isWebdav() {
return isWebdav;
},
get syncIntervalSecs() {
return syncIntervalSecs;
},
get lastSyncResult() {
return lastSyncResult;
},
@ -496,6 +518,7 @@ export const app = {
triggerSync,
startAutoSync,
stopAutoSync,
setSyncInterval,
setTheme,
addWebdavWorkspace,
forgetMissingWorkspace,

View file

@ -29,6 +29,7 @@ export interface WorkspaceConfig {
webdav_path: string | null;
last_sync: string | null;
theme: string | null;
sync_interval_secs: number | null;
}
export interface AppConfig {

View file

@ -31,11 +31,13 @@ pub struct WorkspaceConfig {
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sync_interval_secs: Option<u64>,
}
impl WorkspaceConfig {
pub fn new(name: String, path: PathBuf) -> Self {
Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None }
Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None, sync_interval_secs: None }
}
}

View file

@ -553,8 +553,11 @@ async fn sync_workspace_inner(
// Execute actions, collecting failures for the queue
let mut failed_actions = Vec::new();
// Build remote timestamp lookup for recording accurate download times
let remote_meta: HashMap<&str, &RemoteFileSnapshot> = remote_files.iter().map(|f| (f.path.as_str(), f)).collect();
for action in &actions {
match execute_action(&client, workspace_path, action, &mut sync_state, &report).await {
match execute_action(&client, workspace_path, action, &mut sync_state, &remote_meta, &report).await {
Ok(()) => {
match action {
SyncAction::Upload { .. } => result.uploaded += 1,
@ -592,6 +595,7 @@ async fn execute_action(
workspace_path: &Path,
action: &SyncAction,
sync_state: &mut SyncState,
remote_meta: &HashMap<&str, &RemoteFileSnapshot>,
report: &(dyn Fn(&str) + Send + Sync),
) -> Result<()> {
match action {
@ -701,10 +705,8 @@ async fn execute_action(
}
std::fs::write(&local_path, &data)?;
// Record in sync state
let modified = std::fs::metadata(&local_path).ok()
.and_then(|m| m.modified().ok())
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
// Record remote's last_modified so next diff won't see a timestamp mismatch
let modified = remote_meta.get(path.as_str()).and_then(|r| r.last_modified.clone());
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
}