diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 6e65564..3d8f82f 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -891,6 +891,7 @@ fn main() { sync_unbind, sync_pause, sync_status, + sync_set_scope, sync_resolve, sync_logout, sync_run, diff --git a/frontend/src-tauri/src/sync_client.rs b/frontend/src-tauri/src/sync_client.rs index 586c8e5..3e839c4 100644 --- a/frontend/src-tauri/src/sync_client.rs +++ b/frontend/src-tauri/src/sync_client.rs @@ -374,7 +374,9 @@ impl SyncClient { if let Some(initial) = workspace.access(|ws| ws.sync_initial_pending(&binding.id))? { let count = initial.len().min(100); for revision in initial.iter().take(count) { - if revision.operation == "put" { + if revision.operation == "put" + && workspace.access(|ws| ws.sync_path_enabled(&revision.path))? + { self.download(workspace, binding, revision).await?; } workspace.access(|ws| { @@ -423,7 +425,9 @@ impl SyncClient { if revision.sequence != cursor + index as i64 + 1 || revision.sequence > end { return Err(SyncError::new("SYNC_RESPONSE_INVALID")); } - if revision.operation == "put" { + if revision.operation == "put" + && workspace.access(|ws| ws.sync_path_enabled(&revision.path))? + { self.download(workspace, binding, &revision).await?; } workspace.access(|ws| { diff --git a/frontend/src-tauri/src/sync_commands.rs b/frontend/src-tauri/src/sync_commands.rs index c4ca07b..d4a65cb 100644 --- a/frontend/src-tauri/src/sync_commands.rs +++ b/frontend/src-tauri/src/sync_commands.rs @@ -249,6 +249,21 @@ pub fn sync_pause(host: State<'_, Host>, binding_id: String, paused: bool) -> Re Ok(()) } #[tauri::command] +pub fn sync_set_scope( + host: State<'_, Host>, + vault_id: String, + scope: notesagent_host::sync_scope::OptionalScope, +) -> Result<(), String> { + with_workspace(&host, |ws| { + if ws.vault_id != vault_id { + return Err(notesagent_host::workspace::HostError::new( + "VAULT_PERMISSION_CHANGED", + )); + } + ws.sync_set_optional_scope(scope) + }) +} +#[tauri::command] pub fn sync_status(host: State<'_, Host>) -> Result { let snapshot = with_workspace(&host, |ws| { let binding = ws.sync_binding()?; @@ -263,6 +278,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result { .transpose()? .unwrap_or_default(); Ok(( + ws.sync_optional_scope()?, ws.vault_id.clone(), binding.clone(), paused, @@ -280,7 +296,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result { .unwrap_or_default(), )) })?; - let (vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot; + let (optional_scope, vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot; let credential_state = if let Some(b) = &binding { sync_auth::available(&host.credentials, &b.endpoint, &b.account) .map(|exists| { @@ -298,7 +314,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result { let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?; let status = binding.as_ref().and_then(|b| statuses.get(&b.id)); Ok( - json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}), + json!({"optional_scope":optional_scope,"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}), ) } #[tauri::command] diff --git a/frontend/src-tauri/src/sync_discovery.rs b/frontend/src-tauri/src/sync_discovery.rs index b4575b3..eadfee2 100644 --- a/frontend/src-tauri/src/sync_discovery.rs +++ b/frontend/src-tauri/src/sync_discovery.rs @@ -75,7 +75,7 @@ impl Workspace { let resolved = ws.resolve(&path)?; if item.file_type()?.is_dir() { walk(ws, &resolved, paths)?; - } else if allowed(&path) && item.file_type()?.is_file() { + } else if ws.sync_path_enabled(&path)? && item.file_type()?.is_file() { paths.push(path); } } @@ -137,7 +137,10 @@ impl Workspace { rows }; for (file_id, path) in previous { - if seen.contains(&file_id) || !allowed(&path) || self.resolve(&path)?.exists() { + if seen.contains(&file_id) + || !self.sync_path_enabled(&path)? + || self.resolve(&path)?.exists() + { continue; } let operation = Uuid::new_v4().to_string(); diff --git a/frontend/src-tauri/src/sync_inbox.rs b/frontend/src-tauri/src/sync_inbox.rs index 3d7c67e..ef44653 100644 --- a/frontend/src-tauri/src/sync_inbox.rs +++ b/frontend/src-tauri/src/sync_inbox.rs @@ -145,8 +145,10 @@ impl Workspace { { return Err(HostError::new("SYNC_CURSOR_INVALID")); } - if let Some(digest) = &revision.hash { - crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?; + if self.sync_path_enabled(&revision.path)? { + if let Some(digest) = &revision.hash { + crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?; + } } let encoded = serde_json::to_string(revision).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?; @@ -254,6 +256,10 @@ impl Workspace { }; let revision: RemoteRevision = serde_json::from_str(&encoded).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?; + if !self.sync_path_enabled(&revision.path)? { + self.sync_finish(binding, &revision, "excluded")?; + return Ok(true); + } let own: Option = self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND operation_id=?2", params![binding,revision.operation_id], |r| { Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? }) }).optional()?; diff --git a/frontend/src-tauri/src/sync_initial.rs b/frontend/src-tauri/src/sync_initial.rs index 6a5a85e..473094c 100644 --- a/frontend/src-tauri/src/sync_initial.rs +++ b/frontend/src-tauri/src/sync_initial.rs @@ -72,7 +72,7 @@ impl Workspace { if item.sequence > snapshot.boundary { return Err(HostError::new("SYNC_RESPONSE_INVALID")); } - if item.operation == "put" { + if item.operation == "put" && self.sync_path_enabled(&item.path)? { paths.insert(item.path.clone(), "download"); } } @@ -93,8 +93,16 @@ impl Workspace { ); } let fingerprint = hash( - &serde_json::to_vec(&(&self.vault_id, endpoint, remote, account, &local, snapshot)) - .map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?, + &serde_json::to_vec(&( + &self.vault_id, + endpoint, + remote, + account, + &local, + snapshot, + self.sync_optional_scope()?, + )) + .map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?, ); Ok(Preview { fingerprint, diff --git a/frontend/src-tauri/src/sync_resolution.rs b/frontend/src-tauri/src/sync_resolution.rs index 5063597..6d53502 100644 --- a/frontend/src-tauri/src/sync_resolution.rs +++ b/frontend/src-tauri/src/sync_resolution.rs @@ -18,6 +18,19 @@ impl Workspace { expected: &str, ) -> Result<()> { self.check_binding(binding)?; + let scope_path: Option = self + .db + .query_row( + "SELECT local_path FROM sync_conflicts WHERE binding=?1 AND sequence=?2", + params![binding, sequence], + |row| row.get(0), + ) + .optional()?; + if let Some(path) = scope_path { + if !self.sync_path_enabled(&path)? { + return Err(HostError::new("SYNC_SCOPE_DISABLED")); + } + } if !matches!(choice, "local" | "remote" | "copy") { return Err(HostError::new("SYNC_RESOLUTION_INVALID")); } @@ -107,6 +120,9 @@ impl Workspace { )?; let revision: RemoteRevision = serde_json::from_str(&remote).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?; + if !self.sync_path_enabled(&revision.path)? { + return Ok(()); + } let head: i64 = self.db.query_row( "SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?2", params![binding, revision.file_id], @@ -279,6 +295,11 @@ mod tests { fn invalid_record_copy_destination_does_not_freeze_conflict_decision() { let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); + ws.sync_set_optional_scope(crate::sync_scope::OptionalScope { + persona: true, + layout: true, + }) + .unwrap(); let binding = ws .sync_bind_download("https://sync.example", "remote-vault", "account") .unwrap(); diff --git a/frontend/src-tauri/src/sync_scope.rs b/frontend/src-tauri/src/sync_scope.rs index 3906836..d3b7368 100644 --- a/frontend/src-tauri/src/sync_scope.rs +++ b/frontend/src-tauri/src/sync_scope.rs @@ -21,6 +21,10 @@ impl OptionalScope { } impl Workspace { + pub fn sync_path_enabled(&self, path: &str) -> Result { + Ok(crate::sync_discovery::allowed(path) && self.sync_optional_scope()?.includes(path)) + } + pub fn sync_optional_scope(&self) -> Result { Ok(self .db @@ -54,6 +58,104 @@ impl Workspace { mod tests { use super::*; + #[test] + fn old_queued_optional_jobs_cannot_bypass_missing_consent() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + ws.sync_set_optional_scope(OptionalScope { + persona: true, + layout: false, + }) + .unwrap(); + let data = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"local","system_prompt":"private","dialogue_pairs":[]}})).unwrap(); + let path = "opennexus-records/v1/persona/default.json"; + ws.write(path, "", &data, "local").unwrap(); + let binding = ws + .sync_bind_empty("https://sync.example", "remote", "account") + .unwrap(); + let job = ws.sync_next(&binding.id).unwrap().unwrap(); + // Models a pre-scope database's pending job after schema migration. + ws.db + .execute("DELETE FROM sync_optional_scope", []) + .unwrap(); + assert_eq!( + ws.sync_commit_payload(&job).unwrap_err().code, + "SYNC_SCOPE_DISABLED" + ); + assert!(ws.sync_next(&binding.id).unwrap().is_none()); + ws.write("note.md", "", b"still synchronized", "local") + .unwrap(); + ws.sync_capture(&binding.id).unwrap(); + assert_eq!(ws.sync_next(&binding.id).unwrap().unwrap().path, "note.md"); + assert_eq!(std::fs::read(root.path().join(path)).unwrap(), data); + } + #[test] + fn excluded_records_neither_upload_nor_require_object_bytes_to_advance_cursor() { + use crate::sync_inbox::RemoteRevision; + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let path = "opennexus-records/v1/persona/default.json"; + let local = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"Local only","system_prompt":"private","dialogue_pairs":[]}})).unwrap(); + ws.write(path, "", &local, "local").unwrap(); + let binding = ws + .sync_bind_download("https://sync.example", "remote", "account") + .unwrap(); + ws.sync_capture(&binding.id).unwrap(); + assert!(ws.sync_next(&binding.id).unwrap().is_none()); + let revision = RemoteRevision { + vault_id: "remote".into(), + sequence: 1, + file_id: uuid::Uuid::new_v4().to_string(), + base_revision: 0, + path: path.into(), + operation: "put".into(), + hash: Some("a".repeat(64)), + size: 123, + operation_id: uuid::Uuid::new_v4().to_string(), + }; + ws.sync_set_boundary(&binding.id, 1).unwrap(); + ws.sync_stage(&binding.id, &revision).unwrap(); + assert!(ws.sync_apply_pending(&binding.id).unwrap()); + assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 1); + assert_eq!(std::fs::read(root.path().join(path)).unwrap(), local); + assert!(!ws + .sync_spool(revision.hash.as_ref().unwrap()) + .unwrap() + .exists()); + assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty()); + ws.sync_unbind(&binding.id).unwrap(); + let snapshot = crate::sync_initial::Snapshot { + boundary: 1, + items: vec![revision], + }; + let before = ws + .sync_preview("https://sync.example", "remote", "account", &snapshot) + .unwrap(); + assert!(before.items.is_empty()); + ws.sync_set_optional_scope(OptionalScope { + persona: true, + layout: false, + }) + .unwrap(); + assert_eq!( + ws.sync_bind_initial( + "https://sync.example", + "remote", + "account", + &snapshot, + &before.fingerprint + ) + .err() + .unwrap() + .code, + "SYNC_PREVIEW_CHANGED" + ); + let after = ws + .sync_preview("https://sync.example", "remote", "account", &snapshot) + .unwrap(); + assert_eq!(after.items.len(), 1); + assert_eq!(after.items[0].action, "conflict"); + } #[test] fn schema_ten_upgrade_preserves_vault_and_does_not_infer_consent() { let root = tempfile::tempdir().unwrap(); diff --git a/frontend/src-tauri/src/sync_state.rs b/frontend/src-tauri/src/sync_state.rs index 8637564..41f406b 100644 --- a/frontend/src-tauri/src/sync_state.rs +++ b/frontend/src-tauri/src/sync_state.rs @@ -163,7 +163,7 @@ impl Workspace { let Some((operation_id, file_id, path, digest, operation, content)) = pending else { break; }; - if !crate::sync_discovery::allowed(&path) { + if !self.sync_path_enabled(&path)? { self.db.execute( "UPDATE outbox SET state='excluded' WHERE operation_id=?1", [&operation_id], @@ -201,19 +201,34 @@ impl Workspace { } pub fn sync_next(&self, binding: &str) -> Result> { self.check_binding(binding)?; - let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| { + loop { + let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| { Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? }) }).optional()?; - if job - .as_ref() - .is_some_and(|job| !crate::sync_discovery::allowed(&job.path)) - { - return Err(HostError::new("SYNC_CLASS_UNSUPPORTED")); + if job + .as_ref() + .is_some_and(|job| !crate::sync_discovery::allowed(&job.path)) + { + return Err(HostError::new("SYNC_CLASS_UNSUPPORTED")); + } + if let Some(ref excluded) = job { + if !self.sync_path_enabled(&excluded.path)? { + self.db.execute("UPDATE sync_jobs SET state='archived' WHERE binding=?1 AND operation_id=?2",params![binding,excluded.operation_id])?; + self.db.execute( + "UPDATE outbox SET state='excluded' WHERE operation_id=?1", + [&excluded.operation_id], + )?; + continue; + } + } + return Ok(job); } - Ok(job) } pub(crate) fn check_job(&self, job: &Job) -> Result<()> { self.check_binding(&job.binding)?; + if !self.sync_path_enabled(&job.path)? { + return Err(HostError::new("SYNC_SCOPE_DISABLED")); + } let state: String = self.db.query_row( "SELECT state FROM sync_jobs WHERE binding=?1 AND operation_id=?2", params![job.binding, job.operation_id], diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index cf2c395..7ed5964 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -75,6 +75,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate client.verify_empty(remote).await.unwrap(); let local = tempfile::tempdir().unwrap(); let workspace = Arc::new(Mutex::new(Workspace::open(local.path()).unwrap())); + workspace + .lock() + .unwrap() + .sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope { + persona: true, + layout: true, + }) + .unwrap(); let binding = { let mut ws = workspace.lock().unwrap(); let mut digest = String::new(); @@ -152,6 +160,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .unwrap(); let root_b = tempfile::tempdir().unwrap(); let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap())); + workspace_b + .lock() + .unwrap() + .sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope { + persona: true, + layout: true, + }) + .unwrap(); let binding_b = workspace_b .lock() .unwrap() @@ -660,6 +676,86 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate ); } + // A default-off device consumes history metadata without downloading either + // optional record. Rebinding after opt-in must fetch the already-seen heads. + let excluded_root = tempfile::tempdir().unwrap(); + let excluded_ws = Arc::new(Mutex::new(Workspace::open(excluded_root.path()).unwrap())); + let excluded_binding = excluded_ws + .lock() + .unwrap() + .sync_bind_download(&endpoint, remote, "rust-fixture") + .unwrap(); + while client_b + .pull_page(&excluded_ws, &excluded_binding) + .await + .unwrap() + > 0 + {} + for (kind, id) in [("persona", "default"), ("layout", "sidebars")] { + let original = workspace + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + .unwrap(); + let mut excluded = excluded_ws.lock().unwrap(); + assert!(excluded.record_get_kind(kind, id).unwrap().is_none()); + assert!(!excluded + .sync_spool(original["hash"].as_str().unwrap()) + .unwrap() + .exists()); + } + assert!(!client_b + .push_one(&excluded_ws, &excluded_binding) + .await + .unwrap()); + { + let mut excluded = excluded_ws.lock().unwrap(); + excluded.sync_unbind(&excluded_binding.id).unwrap(); + excluded + .sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope { + persona: true, + layout: true, + }) + .unwrap(); + } + let snapshot = client_b.snapshot(remote).await.unwrap(); + let opted_binding = { + let mut excluded = excluded_ws.lock().unwrap(); + let preview = excluded + .sync_preview(&endpoint, remote, "rust-fixture", &snapshot) + .unwrap(); + excluded + .sync_bind_initial( + &endpoint, + remote, + "rust-fixture", + &snapshot, + &preview.fingerprint, + ) + .unwrap() + }; + while client_b + .pull_page(&excluded_ws, &opted_binding) + .await + .unwrap() + > 0 + {} + for (kind, id) in [("persona", "default"), ("layout", "sidebars")] { + assert_eq!( + excluded_ws + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap(), + workspace.lock().unwrap().record_get_kind(kind, id).unwrap() + ); + } + assert!(!client_b + .push_one(&excluded_ws, &opted_binding) + .await + .unwrap()); + // Kill the actual client process after each durable 10 MiB server offset, // before its response reaches the client. The next process must query offset. use sha2::{Digest, Sha256}; diff --git a/frontend/src/features/chat/ChatPersonaDialog.vue b/frontend/src/features/chat/ChatPersonaDialog.vue index 47310b0..e3c71c5 100644 --- a/frontend/src/features/chat/ChatPersonaDialog.vue +++ b/frontend/src/features/chat/ChatPersonaDialog.vue @@ -95,7 +95,7 @@ async function save() {

{{ t('人设与头像', 'Persona and avatars') }}

-

{{ isDesktop() ? t('工作区人设 · 随当前 Vault 同步,应用于此工作区的对话与智能体。旧全局人设不会自动导入。', 'Workspace persona · Syncs with this Vault and applies to its chats and agents. Legacy global personas are not imported automatically.') : t('全局人设 · 应用于连接此 AI Core 的所有对话与智能体。留空的提示词和对话示例不会拼入请求。', 'Global persona · Applies to all chats and agents connected to this AI Core. Empty prompts and examples are omitted.') }}

+

{{ isDesktop() ? t('工作区人设 · 可在同步设置中选择随当前 Vault 同步,应用于此工作区的对话与智能体。旧全局人设不会自动导入。', 'Workspace persona · Optionally syncs with this Vault and applies to its chats and agents. Legacy global personas are not imported automatically.') : t('全局人设 · 应用于连接此 AI Core 的所有对话与智能体。留空的提示词和对话示例不会拼入请求。', 'Global persona · Applies to all chats and agents connected to this AI Core. Empty prompts and examples are omitted.') }}

{{ t('正在加载全局设置', 'Loading global settings') }}

diff --git a/frontend/src/features/settings/SyncSettings.spec.ts b/frontend/src/features/settings/SyncSettings.spec.ts index f33bc41..dc620a0 100644 --- a/frontend/src/features/settings/SyncSettings.spec.ts +++ b/frontend/src/features/settings/SyncSettings.spec.ts @@ -81,3 +81,15 @@ it('shows persisted per-job interruption counts without starting work from the v expect(vi.mocked(hostInvoke).mock.calls.every(([command]) => command === 'sync_status')).toBe(true) wrapper.unmount() }) + + +it('keeps optional records off until an explicit unbound scope change', async () => { + vi.mocked(hostInvoke).mockResolvedValue({ ...empty(), optional_scope: { persona: false, layout: false } }) + const wrapper = mount(SyncSettings); await flushPromises() + const options = wrapper.findAll('.sync-scope input[type=checkbox]') + expect(options).toHaveLength(2) + expect((options[0]!.element as HTMLInputElement).checked).toBe(false) + await options[0]!.setValue(true); await flushPromises() + expect(hostInvoke).toHaveBeenCalledWith('sync_set_scope', { vaultId: empty().vault_id, scope: { persona: true, layout: false } }) + wrapper.unmount() +}) diff --git a/frontend/src/features/settings/SyncSettings.vue b/frontend/src/features/settings/SyncSettings.vue index dc0d37f..fb3464f 100644 --- a/frontend/src/features/settings/SyncSettings.vue +++ b/frontend/src/features/settings/SyncSettings.vue @@ -8,7 +8,7 @@ import { t } from '@/i18n' const { actionDialog, resolveAction, askConfirm } = useActionDialog() interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number } interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } } -interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> } +interface Status { optional_scope?: { persona: boolean; layout: boolean }; vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> } interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number } const status = ref(null) const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false) @@ -42,6 +42,15 @@ async function act(action: () => Promise) { catch (error) { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' } finally { busy.value = false } } +function setScope(kind: 'persona' | 'layout', event: Event) { + const current = status.value + if (!current || current.binding) return + const input = event.target as HTMLInputElement + const scope = { persona: false, layout: false, ...current.optional_scope, [kind]: input.checked } + input.checked = current.optional_scope?.[kind] ?? false + preview.value = null + return act(async () => { await hostInvoke('sync_set_scope', { vaultId: current.vault_id, scope }) }) +} async function listVaults() { const result = await hostInvoke<{ items: RemoteVault[] }>('sync_vaults', { endpoint: endpoint.value, account: account.value }) remoteVaults.value = result.items @@ -118,6 +127,12 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
+
+ {{ t('可选同步内容', 'Optional sync content') }} + + +

{{ t('默认仅保存在本机。修改已绑定范围时,请先解除绑定,再重新预览合并;关闭选项不会删除远端内容。', 'Kept locally by default. Unbind before changing scope, then preview a new merge. Disabling an option does not delete remote content.') }}

+

{{ status.binding.endpoint }} · {{ status.binding.account }} · {{ status.binding.remote_vault }}

{{ status.paused ? t('已暂停', 'Paused') : status.running ? t('同步中', 'Syncing') : status.halted ? t('自动同步已停止,请处理错误后重试', 'Automatic sync stopped; resolve the error and retry') : t('等待下一轮同步', 'Waiting for next sync') }} · {{ t('待上传', 'Pending') }} {{ status.pending }} · cursor {{ status.binding.cursor }}