diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index f393099..d822f38 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -5,6 +5,7 @@ fn main() { "host_capabilities", "editor_capabilities", "workspace_choose", + "workspace_open", "workspace_tree", "workspace_read", "workspace_write", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 280df55..ba003a3 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -9,6 +9,7 @@ "core:default", "allow-host-capabilities", "allow-workspace-choose", + "allow-workspace-open", "allow-workspace-tree", "allow-workspace-read", "allow-workspace-write", diff --git a/frontend/src-tauri/permissions/autogenerated/workspace_open.toml b/frontend/src-tauri/permissions/autogenerated/workspace_open.toml new file mode 100644 index 0000000..9f2f8d9 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/workspace_open.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-workspace-open" +description = "Enables the workspace_open command without any pre-configured scope." +commands.allow = ["workspace_open"] + +[[permission]] +identifier = "deny-workspace-open" +description = "Denies the workspace_open command without any pre-configured scope." +commands.deny = ["workspace_open"] diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index b88862e..54aebb2 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。 +pub mod recent; pub mod workspace; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 2a372d1..1b72065 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,23 +2,20 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 +use notesagent_host::recent::{RecentVault, RecentVaultStore}; use notesagent_host::workspace::{Document, Entry, Workspace}; -use serde::Serialize; +use std::path::Path; use std::sync::Mutex; use tauri::{Emitter, Manager, State}; #[derive(Default)] -struct Host(Mutex>); - -#[derive(Serialize)] -struct VaultInfo { - vault_id: String, - path: String, - name: String, +struct Host { + workspace: Mutex>, + recent: Mutex>, } -fn info(ws: &Workspace) -> VaultInfo { - VaultInfo { +fn info(ws: &Workspace) -> RecentVault { + RecentVault { vault_id: ws.vault_id.clone(), path: ws.root.to_string_lossy().into(), name: ws @@ -34,7 +31,7 @@ fn with_workspace( host: &Host, f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result, ) -> Result { - let mut guard = host.0.lock().map_err(|_| "HOST_BUSY")?; + let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; let ws = guard.as_mut().ok_or("VAULT_NOT_OPEN")?; f(ws).map_err(|e| e.code) } @@ -52,14 +49,14 @@ fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<() } #[tauri::command] -fn workspace_choose(host: State<'_, Host>) -> Result, String> { +fn workspace_choose(host: State<'_, Host>) -> Result, String> { let Some(path) = rfd::FileDialog::new() .set_title("选择本地 Vault") .pick_folder() else { return Ok(None); }; - let mut guard = host.0.lock().map_err(|_| "HOST_BUSY")?; + let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; if guard .as_ref() .is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default()) @@ -68,19 +65,61 @@ fn workspace_choose(host: State<'_, Host>) -> Result, String> } let workspace = Workspace::open(&path).map_err(|e| e.code)?; let result = info(&workspace); + host.recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .remember(&result)?; *guard = Some(workspace); Ok(Some(result)) } #[tauri::command] -fn workspace_recent(host: State<'_, Host>) -> Result, String> { - let guard = host.0.lock().map_err(|_| "HOST_BUSY")?; - Ok(guard.as_ref().map(info).into_iter().collect()) +fn workspace_open(host: State<'_, Host>, path: String) -> Result { + let authorized = host + .recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_ref() + .ok_or("HOST_NOT_READY")? + .authorized(Path::new(&path))? + .ok_or("VAULT_NOT_AUTHORIZED")?; + let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + if guard + .as_ref() + .is_some_and(|ws| ws.root == Path::new(&authorized.path)) + { + return Ok(guard.as_ref().map(info).ok_or("VAULT_NOT_OPEN")?); + } + let workspace = Workspace::open(Path::new(&authorized.path)).map_err(|e| e.code)?; + let result = info(&workspace); + *guard = Some(workspace); + Ok(result) +} + +#[tauri::command] +fn workspace_recent(host: State<'_, Host>) -> Result, String> { + host.recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_ref() + .ok_or("HOST_NOT_READY")? + .list() } #[tauri::command] fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> { - *host.0.lock().map_err(|_| "HOST_BUSY")? = None; + let mut workspace = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + if let Some(active) = workspace.as_ref() { + host.recent + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("HOST_NOT_READY")? + .revoke(&active.root)?; + } + *workspace = None; Ok(()) } @@ -136,6 +175,12 @@ fn main() { let paragraph = Submenu::with_items(app, "段落", true, &[&import])?; app.manage(import); app.set_menu(Menu::with_items(app, &[¶graph])?)?; + let state_path = app.path().app_data_dir()?.join("host-state.sqlite3"); + *app.state::() + .recent + .lock() + .map_err(|_| std::io::Error::other("HOST_BUSY"))? = + Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?); Ok(()) }) .on_menu_event(|app, event| { @@ -154,6 +199,7 @@ fn main() { host_capabilities, editor_capabilities, workspace_choose, + workspace_open, workspace_recent, workspace_revoke, workspace_tree, diff --git a/frontend/src-tauri/src/recent.rs b/frontend/src-tauri/src/recent.rs new file mode 100644 index 0000000..ae7a5f6 --- /dev/null +++ b/frontend/src-tauri/src/recent.rs @@ -0,0 +1,168 @@ +//! 用户明确选择过的 Vault 授权记录;只保存身份和路径,不读取或复制笔记正文。 + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MAX_RECENT_VAULTS: i64 = 20; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentVault { + pub vault_id: String, + pub path: String, + pub name: String, +} + +pub struct RecentVaultStore { + db: Connection, +} + +impl RecentVaultStore { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + } + let db = Connection::open(path).map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + db.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS recent_vaults ( + path TEXT PRIMARY KEY NOT NULL, + vault_id TEXT NOT NULL, + name TEXT NOT NULL, + ordering INTEGER NOT NULL + );", + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + Ok(Self { db }) + } + + pub fn list(&self) -> Result, String> { + let mut query = self + .db + .prepare("SELECT vault_id,path,name FROM recent_vaults ORDER BY ordering DESC LIMIT ?") + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + let rows = query + .query_map([MAX_RECENT_VAULTS], |row| { + Ok(RecentVault { + vault_id: row.get(0)?, + path: row.get(1)?, + name: row.get(2)?, + }) + }) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + rows.collect::>>() + .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + } + + pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> { + let transaction = self + .db + .transaction() + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + let ordering: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(ordering),0)+1 FROM recent_vaults", + [], + |row| row.get(0), + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + transaction + .execute( + "INSERT INTO recent_vaults(path,vault_id,name,ordering) VALUES(?,?,?,?) + ON CONFLICT(path) DO UPDATE SET vault_id=excluded.vault_id,name=excluded.name,ordering=excluded.ordering", + params![vault.path, vault.vault_id, vault.name, ordering], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + transaction + .execute( + "DELETE FROM recent_vaults WHERE path IN ( + SELECT path FROM recent_vaults ORDER BY ordering DESC LIMIT -1 OFFSET ? + )", + [MAX_RECENT_VAULTS], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + transaction + .commit() + .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + } + + pub fn authorized(&self, path: &Path) -> Result, String> { + let canonical = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; + self.db + .query_row( + "SELECT vault_id,path,name FROM recent_vaults WHERE path=?", + [canonical.to_string_lossy().as_ref()], + |row| { + Ok(RecentVault { + vault_id: row.get(0)?, + path: row.get(1)?, + name: row.get(2)?, + }) + }, + ) + .optional() + .map_err(|_| "RECENT_VAULT_STORE_ERROR".into()) + } + + pub fn revoke(&mut self, path: &Path) -> Result<(), String> { + let canonical: PathBuf = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?; + self.db + .execute( + "DELETE FROM recent_vaults WHERE path=?", + [canonical.to_string_lossy().as_ref()], + ) + .map_err(|_| "RECENT_VAULT_STORE_ERROR")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vault(root: &Path, id: usize) -> RecentVault { + let path = root.join(format!("vault-{id}")); + fs::create_dir(&path).unwrap(); + RecentVault { + vault_id: format!("id-{id}"), + path: path.canonicalize().unwrap().to_string_lossy().into(), + name: format!("Vault {id}"), + } + } + + #[test] + fn persists_order_authorization_and_revocation() { + let temporary = tempfile::tempdir().unwrap(); + let database = temporary.path().join("state/host.sqlite3"); + let first = vault(temporary.path(), 1); + let second = vault(temporary.path(), 2); + let mut store = RecentVaultStore::open(&database).unwrap(); + store.remember(&first).unwrap(); + store.remember(&second).unwrap(); + store.remember(&first).unwrap(); + assert_eq!(store.list().unwrap(), vec![first.clone(), second]); + drop(store); + + let mut reopened = RecentVaultStore::open(&database).unwrap(); + assert_eq!( + reopened.authorized(Path::new(&first.path)).unwrap(), + Some(first.clone()) + ); + reopened.revoke(Path::new(&first.path)).unwrap(); + assert_eq!(reopened.authorized(Path::new(&first.path)).unwrap(), None); + } + + #[test] + fn keeps_only_the_most_recent_authorizations() { + let temporary = tempfile::tempdir().unwrap(); + let mut store = RecentVaultStore::open(&temporary.path().join("host.sqlite3")).unwrap(); + for id in 0..25 { + store.remember(&vault(temporary.path(), id)).unwrap(); + } + let items = store.list().unwrap(); + assert_eq!(items.len(), MAX_RECENT_VAULTS as usize); + assert_eq!(items[0].vault_id, "id-24"); + assert_eq!(items[19].vault_id, "id-5"); + } +} diff --git a/frontend/src/services/platform/desktop.spec.ts b/frontend/src/services/platform/desktop.spec.ts index bf097f4..f8fa2a3 100644 --- a/frontend/src/services/platform/desktop.spec.ts +++ b/frontend/src/services/platform/desktop.spec.ts @@ -28,6 +28,13 @@ describe('原生 Workspace 适配', () => { }) it('取消原生目录选择不进入空 Vault', async () => { native.enabled = true; native.invoke.mockResolvedValue(null) - await expect(workspace.openVault('ignored')).rejects.toThrow() + await expect(workspace.openVault('')).rejects.toThrow() + expect(native.invoke).toHaveBeenCalledWith('workspace_choose', undefined) + }) + it('最近 Vault 只能通过已授权路径命令重开', async () => { + native.enabled = true + native.invoke.mockResolvedValue({ vault_id: 'vault', path: 'C:\\notes', name: 'notes' }) + await expect(workspace.openVault('C:\\notes')).resolves.toMatchObject({ vault_id: 'vault' }) + expect(native.invoke).toHaveBeenCalledWith('workspace_open', { path: 'C:\\notes' }) }) }) diff --git a/frontend/src/services/workspaceService.ts b/frontend/src/services/workspaceService.ts index 698b256..b1d412f 100644 --- a/frontend/src/services/workspaceService.ts +++ b/frontend/src/services/workspaceService.ts @@ -98,7 +98,10 @@ export async function getRecentVaults(): Promise { export async function openVault(path: string): Promise { if (isDesktop()) { - const vault = await hostInvoke('workspace_choose') + // 空路径只表示用户点击“选择目录”;最近列表只能重开 Host 已持久化授权的路径。 + const vault = path + ? await hostInvoke('workspace_open', { path }) + : await hostInvoke('workspace_choose') if (!vault) throw new Error(t('已取消选择', 'Selection cancelled')) cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++ return vault