feat: add separate sync interval for unfocused state

Introduce a new sync interval configuration for when the app is
in the background, allowing users to set a longer sync period to
reduce resource usage. Replace the fixed focus threshold with
dynamic interval switching based on app focus state. Add
syncIntervalUnfocused setting to the UI with a new background
sync interval selector. Refactor sync interval management into
a restartSyncInterval function to handle both focused and
unfocused intervals consistently.
This commit is contained in:
Tristan Michael 2026-04-06 09:37:06 -07:00 committed by GitButler
parent f5295b5980
commit 50800f0c2d
5 changed files with 79 additions and 13 deletions

View file

@ -556,6 +556,19 @@ fn set_sync_interval(
s.save_config()
}
#[tauri::command]
fn set_sync_interval_unfocused(
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_unfocused_secs = interval_secs;
}
s.save_config()
}
/// A remote folder entry returned to the frontend.
#[derive(Debug, Serialize, Deserialize)]
struct RemoteFolderEntry {
@ -792,6 +805,8 @@ async fn sync_workspace(
{
let mut s = lock_state(&state)?;
// Suppress file watcher events from sync-written files (500ms debounce + margin)
mute_watcher(&mut s);
if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.last_sync = Some(Utc::now());
}
@ -914,6 +929,7 @@ pub fn run() {
set_webdav_config,
set_workspace_theme,
set_sync_interval,
set_sync_interval_unfocused,
add_webdav_workspace,
list_remote_folder,
inspect_remote_workspace,

View file

@ -205,7 +205,7 @@
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-medium opacity-60">Sync interval</label>
<label class="mb-1 block text-xs font-medium opacity-60">Sync interval (focused)</label>
<select
value={String(app.syncIntervalSecs)}
onchange={(e) => {
@ -221,6 +221,24 @@
<option value="600">10 minutes</option>
</select>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-medium opacity-60">Sync interval (background)</label>
<select
value={String(app.syncIntervalUnfocusedSecs)}
onchange={(e) => {
const val = parseInt((e.target as HTMLSelectElement).value);
app.setSyncIntervalUnfocused(val === 600 ? 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="60">1 minute</option>
<option value="120">2 minutes</option>
<option value="300">5 minutes</option>
<option value="600">10 minutes</option>
<option value="1800">30 minutes</option>
</select>
</div>
</section>
{/if}

View file

@ -35,8 +35,9 @@ let _syncInterval: ReturnType<typeof setInterval> | null = null;
let _syncDebounce: ReturnType<typeof setTimeout> | null = null;
let _focusUnlisten: (() => void) | null = null;
const DEFAULT_SYNC_INTERVAL_SECS = 60;
const DEFAULT_SYNC_INTERVAL_UNFOCUSED_SECS = 600;
const SYNC_DEBOUNCE_MS = 5_000;
const SYNC_FOCUS_THRESHOLD_MS = 30_000;
let _appFocused = true;
// ── Derived ──────────────────────────────────────────────────────────
@ -85,6 +86,11 @@ let syncIntervalSecs = $derived(
? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS
: DEFAULT_SYNC_INTERVAL_SECS,
);
let syncIntervalUnfocusedSecs = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.sync_interval_unfocused_secs ?? DEFAULT_SYNC_INTERVAL_UNFOCUSED_SECS
: DEFAULT_SYNC_INTERVAL_UNFOCUSED_SECS,
);
// ── Actions ──────────────────────────────────────────────────────────
@ -364,21 +370,26 @@ function debouncedSync() {
_syncDebounce = setTimeout(() => { _syncDebounce = null; triggerSync(); }, SYNC_DEBOUNCE_MS);
}
function restartSyncInterval() {
if (_syncInterval) clearInterval(_syncInterval);
var secs = _appFocused ? syncIntervalSecs : syncIntervalUnfocusedSecs;
_syncInterval = setInterval(triggerSync, secs * 1000);
}
function startAutoSync() {
stopAutoSync();
_appFocused = true;
triggerSync();
_syncInterval = setInterval(triggerSync, syncIntervalSecs * 1000);
// Store the promise-returned unlisten function, ensuring we clean up any
// previous listener before assigning a new one.
restartSyncInterval();
getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused && Date.now() - lastSyncTime > SYNC_FOCUS_THRESHOLD_MS) triggerSync();
// Sync on re-focus if stale beyond the focused interval
if (focused && !_appFocused && Date.now() - lastSyncTime > syncIntervalSecs * 1000)
triggerSync();
_appFocused = focused;
restartSyncInterval();
}).then((unlisten) => {
// If stopAutoSync was called while the promise was pending, immediately clean up
if (!_syncInterval) {
unlisten();
} else {
_focusUnlisten = unlisten;
}
if (!_syncInterval) unlisten();
else _focusUnlisten = unlisten;
}).catch((e) => {
console.warn("Failed to set up focus listener:", e);
});
@ -404,6 +415,20 @@ async function setSyncInterval(secs: number | null) {
}
}
async function setSyncIntervalUnfocused(secs: number | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_sync_interval_unfocused", {
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 {
@ -518,6 +543,9 @@ export const app = {
get syncIntervalSecs() {
return syncIntervalSecs;
},
get syncIntervalUnfocusedSecs() {
return syncIntervalUnfocusedSecs;
},
get lastSyncResult() {
return lastSyncResult;
},
@ -553,6 +581,7 @@ export const app = {
startAutoSync,
stopAutoSync,
setSyncInterval,
setSyncIntervalUnfocused,
setTheme,
addWebdavWorkspace,
forgetMissingWorkspace,

View file

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

View file

@ -33,11 +33,13 @@ pub struct WorkspaceConfig {
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sync_interval_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sync_interval_unfocused_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, sync_interval_secs: None }
Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None, sync_interval_secs: None, sync_interval_unfocused_secs: None }
}
}