feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -10,7 +10,8 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `host_capabilities` | protocol=1,workspace=true;core/sync/credentials/extensions=false |
|
| `host_capabilities` | protocol=1,workspace=true;core/sync/credentials/extensions=false |
|
||||||
| `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name |
|
| `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name |
|
||||||
| `workspace_recent` | 当前进程已打开的 Vault;尚未持久化最近列表 |
|
| `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 |
|
||||||
|
| `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 |
|
||||||
| `workspace_revoke` | 释放当前 Vault;不删除用户内容 |
|
| `workspace_revoke` | 释放当前 Vault;不删除用户内容 |
|
||||||
| `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder |
|
| `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder |
|
||||||
| `workspace_read` | path,返回条目与 UTF-8 content |
|
| `workspace_read` | path,返回条目与 UTF-8 content |
|
||||||
@@ -27,7 +28,7 @@ OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vau
|
|||||||
|
|
||||||
保存前对比实际磁盘 SHA-256,先持久化包含内容的 journal,再同目录临时文件刷盘和替换,最后提交元数据及 outbox。文件已替换但数据库未提交时,启动恢复补齐同一 operation_id。摘要不匹配则保留 journal 冲突及外部正文。remote origin 不再次入 outbox。outbox 目前只持久化,**尚无上传/拉取循环**。
|
保存前对比实际磁盘 SHA-256,先持久化包含内容的 journal,再同目录临时文件刷盘和替换,最后提交元数据及 outbox。文件已替换但数据库未提交时,启动恢复补齐同一 operation_id。摘要不匹配则保留 journal 冲突及外部正文。remote origin 不再次入 outbox。outbox 目前只持久化,**尚无上传/拉取循环**。
|
||||||
|
|
||||||
移动和删除另有 file_ops 日志;目标或回收正文刷盘后才删除来源,数据库失败可重放。Windows reparse point(包括 junction)、越界路径、保留目录和设备名被拒绝。大小写重命名暂报 PATH_CONFLICT,UNC 不支持;附件树、多窗口共享、目录移动/删除、稳定外部重命名识别、监听去重及完整恢复 UI 尚未实现。
|
移动和删除另有 file_ops 日志;目标或回收正文刷盘后才删除来源,数据库失败可重放。Windows reparse point(包括 junction)、越界路径、保留目录和设备名被拒绝。最近 Vault 授权保存在应用数据目录的独立 SQLite 中,使用事务更新且不包含正文;任意路径不能绕过目录选择直接加入。大小写重命名暂报 PATH_CONFLICT,UNC 不支持;附件树、多窗口共享、目录移动/删除、稳定外部重命名识别、监听去重及完整恢复 UI 尚未实现。
|
||||||
|
|
||||||
本地恶意进程仍可能在路径校验与系统调用间改变文件系统;该预览不是 OS 沙箱。发布前须补句柄级路径固定及突破测试、磁盘满/掉电故障注入、真实 WebView 交互、性能门禁。
|
本地恶意进程仍可能在路径校验与系统调用间改变文件系统;该预览不是 OS 沙箱。发布前须补句柄级路径固定及突破测试、磁盘满/掉电故障注入、真实 WebView 交互、性能门禁。
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
## 已执行验证
|
## 已执行验证
|
||||||
|
|
||||||
- 后端:887 项 pytest 通过;新增 Web/桌面单写入者门禁测试。
|
- 后端:887 项 pytest 通过;新增 Web/桌面单写入者门禁测试。
|
||||||
- 前端:88 个文件、473 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capability,Web 模式不显示伪窗口按钮。
|
- 前端:88 个文件、474 项 Vitest 通过;TypeScript 检查与 Vite 生产构建通过,保留既有大 chunk 提示。桌面标题栏窗口操作使用 Tauri capability,Web 模式不显示伪窗口按钮。
|
||||||
- Sync v1:18 项隔离测试通过,Windows / Python 3.13.9 / SQLite / 磁盘对象 Fixture。包括双线程并发 CAS;不是 PostgreSQL 多 Worker 压测。
|
- Sync v1:18 项隔离测试通过,Windows / Python 3.13.9 / SQLite / 磁盘对象 Fixture。包括双线程并发 CAS;不是 PostgreSQL 多 Worker 压测。
|
||||||
- Community v1:13 项隔离测试通过;前端使用 Python 生成的真实 Ed25519 向量互验。
|
- Community v1:13 项隔离测试通过;前端使用 Python 生成的真实 Ed25519 向量互验。
|
||||||
- Rust:系统 `rustc/cargo 1.98.1`(`x86_64-pc-windows-gnu`)下,5 项文件恢复测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或实机 UI 证据。
|
- Rust:系统 `rustc/cargo 1.98.1`(`x86_64-pc-windows-gnu`)下,7 项文件恢复及最近 Vault 授权测试、rustfmt、clippy `-D warnings` 和 Tauri desktop 开发构建通过。没有安装包或新标题栏实机 UI 证据。
|
||||||
- 文档相对链接、`git diff --check` 及 AI Core / Sync / Community 隔离冒烟通过。Gitea 工作流已加入,但未推送,因此无远端 Runner 证据。
|
- 文档相对链接、`git diff --check` 及 AI Core / Sync / Community 隔离冒烟通过。Gitea 工作流已加入,但未推送,因此无远端 Runner 证据。
|
||||||
- 真实模型、个人 Vault、真实音频和凭据未作为测试输入。
|
- 真实模型、个人 Vault、真实音频和凭据未作为测试输入。
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ fn main() {
|
|||||||
"host_capabilities",
|
"host_capabilities",
|
||||||
"editor_capabilities",
|
"editor_capabilities",
|
||||||
"workspace_choose",
|
"workspace_choose",
|
||||||
|
"workspace_open",
|
||||||
"workspace_tree",
|
"workspace_tree",
|
||||||
"workspace_read",
|
"workspace_read",
|
||||||
"workspace_write",
|
"workspace_write",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"allow-host-capabilities",
|
"allow-host-capabilities",
|
||||||
"allow-workspace-choose",
|
"allow-workspace-choose",
|
||||||
|
"allow-workspace-open",
|
||||||
"allow-workspace-tree",
|
"allow-workspace-tree",
|
||||||
"allow-workspace-read",
|
"allow-workspace-read",
|
||||||
"allow-workspace-write",
|
"allow-workspace-write",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
|
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
|
||||||
|
|
||||||
|
pub mod recent;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -2,23 +2,20 @@
|
|||||||
|
|
||||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||||
|
|
||||||
|
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
||||||
use notesagent_host::workspace::{Document, Entry, Workspace};
|
use notesagent_host::workspace::{Document, Entry, Workspace};
|
||||||
use serde::Serialize;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Manager, State};
|
use tauri::{Emitter, Manager, State};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Host(Mutex<Option<Workspace>>);
|
struct Host {
|
||||||
|
workspace: Mutex<Option<Workspace>>,
|
||||||
#[derive(Serialize)]
|
recent: Mutex<Option<RecentVaultStore>>,
|
||||||
struct VaultInfo {
|
|
||||||
vault_id: String,
|
|
||||||
path: String,
|
|
||||||
name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn info(ws: &Workspace) -> VaultInfo {
|
fn info(ws: &Workspace) -> RecentVault {
|
||||||
VaultInfo {
|
RecentVault {
|
||||||
vault_id: ws.vault_id.clone(),
|
vault_id: ws.vault_id.clone(),
|
||||||
path: ws.root.to_string_lossy().into(),
|
path: ws.root.to_string_lossy().into(),
|
||||||
name: ws
|
name: ws
|
||||||
@@ -34,7 +31,7 @@ fn with_workspace<T>(
|
|||||||
host: &Host,
|
host: &Host,
|
||||||
f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result<T>,
|
f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result<T>,
|
||||||
) -> Result<T, String> {
|
) -> Result<T, String> {
|
||||||
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")?;
|
let ws = guard.as_mut().ok_or("VAULT_NOT_OPEN")?;
|
||||||
f(ws).map_err(|e| e.code)
|
f(ws).map_err(|e| e.code)
|
||||||
}
|
}
|
||||||
@@ -52,14 +49,14 @@ fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn workspace_choose(host: State<'_, Host>) -> Result<Option<VaultInfo>, String> {
|
fn workspace_choose(host: State<'_, Host>) -> Result<Option<RecentVault>, String> {
|
||||||
let Some(path) = rfd::FileDialog::new()
|
let Some(path) = rfd::FileDialog::new()
|
||||||
.set_title("选择本地 Vault")
|
.set_title("选择本地 Vault")
|
||||||
.pick_folder()
|
.pick_folder()
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
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
|
if guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default())
|
.is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default())
|
||||||
@@ -68,19 +65,61 @@ fn workspace_choose(host: State<'_, Host>) -> Result<Option<VaultInfo>, String>
|
|||||||
}
|
}
|
||||||
let workspace = Workspace::open(&path).map_err(|e| e.code)?;
|
let workspace = Workspace::open(&path).map_err(|e| e.code)?;
|
||||||
let result = info(&workspace);
|
let result = info(&workspace);
|
||||||
|
host.recent
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.as_mut()
|
||||||
|
.ok_or("HOST_NOT_READY")?
|
||||||
|
.remember(&result)?;
|
||||||
*guard = Some(workspace);
|
*guard = Some(workspace);
|
||||||
Ok(Some(result))
|
Ok(Some(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn workspace_recent(host: State<'_, Host>) -> Result<Vec<VaultInfo>, String> {
|
fn workspace_open(host: State<'_, Host>, path: String) -> Result<RecentVault, String> {
|
||||||
let guard = host.0.lock().map_err(|_| "HOST_BUSY")?;
|
let authorized = host
|
||||||
Ok(guard.as_ref().map(info).into_iter().collect())
|
.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<Vec<RecentVault>, String> {
|
||||||
|
host.recent
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("HOST_NOT_READY")?
|
||||||
|
.list()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +175,12 @@ fn main() {
|
|||||||
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
|
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
|
||||||
app.manage(import);
|
app.manage(import);
|
||||||
app.set_menu(Menu::with_items(app, &[¶graph])?)?;
|
app.set_menu(Menu::with_items(app, &[¶graph])?)?;
|
||||||
|
let state_path = app.path().app_data_dir()?.join("host-state.sqlite3");
|
||||||
|
*app.state::<Host>()
|
||||||
|
.recent
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
|
||||||
|
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
@@ -154,6 +199,7 @@ fn main() {
|
|||||||
host_capabilities,
|
host_capabilities,
|
||||||
editor_capabilities,
|
editor_capabilities,
|
||||||
workspace_choose,
|
workspace_choose,
|
||||||
|
workspace_open,
|
||||||
workspace_recent,
|
workspace_recent,
|
||||||
workspace_revoke,
|
workspace_revoke,
|
||||||
workspace_tree,
|
workspace_tree,
|
||||||
|
|||||||
@@ -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<Self, String> {
|
||||||
|
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<Vec<RecentVault>, 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::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.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<Option<RecentVault>, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ describe('原生 Workspace 适配', () => {
|
|||||||
})
|
})
|
||||||
it('取消原生目录选择不进入空 Vault', async () => {
|
it('取消原生目录选择不进入空 Vault', async () => {
|
||||||
native.enabled = true; native.invoke.mockResolvedValue(null)
|
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' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,7 +98,10 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
|
|||||||
|
|
||||||
export async function openVault(path: string): Promise<VaultInfo> {
|
export async function openVault(path: string): Promise<VaultInfo> {
|
||||||
if (isDesktop()) {
|
if (isDesktop()) {
|
||||||
const vault = await hostInvoke<HostVault | null>('workspace_choose')
|
// 空路径只表示用户点击“选择目录”;最近列表只能重开 Host 已持久化授权的路径。
|
||||||
|
const vault = path
|
||||||
|
? await hostInvoke<HostVault>('workspace_open', { path })
|
||||||
|
: await hostInvoke<HostVault | null>('workspace_choose')
|
||||||
if (!vault) throw new Error(t('已取消选择', 'Selection cancelled'))
|
if (!vault) throw new Error(t('已取消选择', 'Selection cancelled'))
|
||||||
cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++
|
cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++
|
||||||
return vault
|
return vault
|
||||||
|
|||||||
Reference in New Issue
Block a user