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:
parent
7e5b3ea7b0
commit
0ae0705331
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} ↑{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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue