diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6c687d18..f4df415b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,19 @@ jobs: run: | npm run cli:build:prod -- --target=${{ matrix.target }} + - name: Prepare bundled Pi runtime + shell: bash + run: | + case "${{ matrix.target }}" in + aarch64-apple-darwin) platform=darwin; arch=arm64 ;; + x86_64-apple-darwin) platform=darwin; arch=x64 ;; + *-windows-msvc) platform=win32; arch=x64 ;; + *-linux-gnu) platform=linux; arch=x64 ;; + *) echo "Unsupported Pi release target: ${{ matrix.target }}" >&2; exit 1 ;; + esac + node scripts/prepare-pi-bundle.mjs --platform "$platform" --arch "$arch" + echo "FLOWIX_PI_TARGET_ARCH=$arch" >> "$GITHUB_ENV" + - name: Import Windows signing certificate for Tauri if: matrix.target == 'x86_64-pc-windows-msvc' shell: pwsh diff --git a/CLAUDE.md b/CLAUDE.md index 8ccafabc6..c817d2458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Flowix 是一款桌面笔记应用(**Tauri 2 + Rust 后端,React 19 + TS + T ```bash export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" -npm run tauri:dev # 推荐:独立 dev bundle ID (com.flowix.app.dev / "Flowix Dev"),可与生产 app 并存 +npm run tauri:dev # 推荐:独立 dev bundle ID (com.flowix-dev.app / "Flowix Dev"),可与生产 app 并存 npm run tauri:dev:win # Windows 开发启动:使用 app/flowix-desktop/tauri.windows.dev.conf.json npm run tauri dev # ⚠️ 走默认 tauri.conf.json,与生产同 bundle ID (com.flowix.app),已被生产占住时会立刻 exit 0 npm run dev # 仅前端 (localhost:1420) @@ -35,12 +35,12 @@ cargo test --workspace --lib # 跑全部 通过差异化 Tauri 配置,让 dev 版与已安装的生产版同时运行: -- **dev**:`npm run tauri:dev` → `app/flowix-desktop/tauri.conf.dev.json` → bundle ID `com.flowix.app.dev` / `Flowix Dev` +- **dev**:`npm run tauri:dev` → `app/flowix-desktop/tauri.conf.dev.json` → bundle ID `com.flowix-dev.app` / `Flowix Dev` - **生产**:`npm run tauri:build:prod` → `tauri.conf.json` + 平台覆盖层 + 签名覆盖层 → 平台专用 `tauri.*.production.local.json` → bundle ID `com.flowix.app` / `Flowix` `tauri:dev` 通过 `--config` 指向独立配置,**不要**改 `tauri.conf.json` 的 `identifier` / `productName` / `mainBinaryName` / `bundle.macOS.bundleName` —— 这四个字段是生产身份的锚点。`tauri.conf.production.json` 作为覆盖层被 `tauri build --config` 深合并在 `tauri.conf.json` 之上,因此 dev 配置改动不会污染生产链路。 -dev 与生产现使用不同 bundle ID(`com.flowix.app.dev` vs `com.flowix.app`),可同时运行且互不冲突(Tauri `app_data_dir` / `tauri-plugin-single-instance` lock 都按 identifier 派生)。代价:dev 首次运行需要重新授予一次 user-selected folder 授权(TCC 按 identifier 记忆授权),prod 已授予的不会带过来。视觉上仍通过 bundle name / 窗口标题区分(`Flowix Dev` vs `Flowix`)。URL scheme `flowix://` 仍共用,让浏览器深链能落到任一已装实例。 +dev 与生产现使用不同 bundle ID(`com.flowix-dev.app` vs `com.flowix.app`),可同时运行且互不冲突(Tauri `app_data_dir` / `tauri-plugin-single-instance` lock 都按 identifier 派生)。代价:dev 首次运行需要重新授予一次 user-selected folder 授权(TCC 按 identifier 记忆授权),prod 已授予的不会带过来。视觉上仍通过 bundle name / 窗口标题区分(`Flowix Dev` vs `Flowix`)。Dev 使用独立 URL scheme `flowix-dev://`,以便浏览器回调明确交给 Dev 实例。 ### macOS 本地生产包 ad-hoc 签名 diff --git a/README.md b/README.md index 3b623c6c8..2e38d5dbe 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,21 @@ git clone https://github.com/text2future/flowix.git cd flowix npm install -npm run tauri dev -npm run dev -npm run tauri build +npm run tauri:dev +npm run tauri:build:prod ``` +Run `npm run dev` separately only when you want the Vite frontend without the Tauri desktop backend. + +The desktop dev build (`npm run tauri:dev`) connects to `https://cloud.flowix.cc` by default. In Preferences → Development → Environment configuration, choose Staging or Prod; Flowix restarts to apply the change. The saved preference is authoritative and takes precedence over the build default. `--cloud-env=staging|prod` sets that default and is used until a preference is saved. The environments keep separate sign-in credentials and sync databases. + +```bash +npm run tauri:dev -- --cloud-env=staging +npm run tauri:dev -- --cloud-env=prod +``` + +`npm run tauri:dev:pi` and `npm run tauri:dev:win` accept the same option. Production packages built with `npm run tauri:build:prod` always connect to `https://cloud.flowix.cc`, and do not show the environment selector. Running `npm run dev` alone starts only the Vite frontend; it does not start desktop Cloud sync. + The development environment requires Node.js 20+, Rust 1.75+ and Tauri v2; the desktop app supports macOS 14+ and Windows 10+. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 9ab913255..b931c389c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,11 +121,21 @@ git clone https://github.com/text2future/flowix.git cd flowix npm install -npm run tauri dev -npm run dev -npm run tauri build +npm run tauri:dev +npm run tauri:build:prod ``` +单独运行 `npm run dev` 只启动 Vite 前端,不会启动 Tauri 桌面后端。 + +桌面开发版 `npm run tauri:dev` 默认连接 `https://cloud.flowix.cc`。在「偏好设置 → 开发 → 环境配置」中选择 Staging 或 Prod,Flowix 会重启以应用切换。首次启动默认使用 Prod;已保存的界面选择优先于构建默认值;`--cloud-env=staging|prod` 设置默认值,仅在尚未保存界面选择时生效: + +```bash +npm run tauri:dev -- --cloud-env=staging +npm run tauri:dev -- --cloud-env=prod +``` + +`npm run tauri:dev:pi` 和 `npm run tauri:dev:win` 也接受该参数。正式包 `npm run tauri:build:prod` 始终连接 `https://cloud.flowix.cc`,不会显示环境下拉。单独运行 `npm run dev` 只启动 Vite 前端,不会启动桌面端 Cloud 同步。 + 开发环境要求 Node.js 20+、Rust 1.75+ 与 Tauri v2;桌面应用支持 macOS 14+ 与 Windows 10+。 ## 许可协议 diff --git a/app/Cargo.lock b/app/Cargo.lock index 738c819b0..bbd1a4c27 100644 --- a/app/Cargo.lock +++ b/app/Cargo.lock @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "flowix-cli" -version = "1.5.6" +version = "1.5.8" dependencies = [ "chrono", "clap", @@ -1242,7 +1242,7 @@ dependencies = [ [[package]] name = "flowix-core" -version = "1.5.6" +version = "1.5.8" dependencies = [ "chrono", "diffy", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "flowix-desktop" -version = "1.5.6" +version = "1.5.8" dependencies = [ "async-trait", "base64 0.22.1", @@ -1287,7 +1287,6 @@ dependencies = [ "image", "libc", "machine-uid", - "mime_guess", "minisign-verify", "nanoid", "notify", @@ -1330,7 +1329,7 @@ dependencies = [ [[package]] name = "flowix-installer-helper" -version = "1.5.6" +version = "1.5.8" dependencies = [ "dunce", "windows", @@ -1338,7 +1337,7 @@ dependencies = [ [[package]] name = "flowix-plugin-runtime" -version = "1.5.6" +version = "1.5.8" dependencies = [ "chrono", "flowix-core", @@ -1353,7 +1352,7 @@ dependencies = [ [[package]] name = "flowix-sync" -version = "1.5.6" +version = "1.5.8" dependencies = [ "chrono", "flowix-core", @@ -2650,16 +2649,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minisign-verify" version = "0.2.5" @@ -5447,12 +5436,6 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicase" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/app/Cargo.toml b/app/Cargo.toml index c2da9dd36..7260c50f6 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -11,7 +11,7 @@ members = [ # 锁定共用依赖到同一版本 ── 避免 backend / cli 走不一致版本。 [workspace.package] -version = "1.5.6" +version = "1.5.8" edition = "2021" # 锁定共用依赖到同一版本 ── 避免 backend / cli 走不一致版本。 @@ -25,7 +25,6 @@ serde_yaml = "0.9" toml = "0.8" tauri-plugin-process = "2" tauri-plugin-updater = "2" -tauri-plugin-opener = "2" chrono = { version = "0.4", features = ["serde"] } dirs = "5.0" walkdir = "2.4" diff --git a/app/flowix-cli/Cargo.toml b/app/flowix-cli/Cargo.toml index 7b6d20a21..79ebd19c8 100644 --- a/app/flowix-cli/Cargo.toml +++ b/app/flowix-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "flowix-cli" -version = "1.5.6" +version = "1.5.8" description = "Flowix 命令行工具 ── 共享同一份 memo_file 存储, 跟桌面端 watcher 双向同步" authors = ["Flowix Contributors"] edition.workspace = true diff --git a/app/flowix-cli/src/collection_store.rs b/app/flowix-cli/src/collection_store.rs index 2b9b3e418..3423ed89a 100644 --- a/app/flowix-cli/src/collection_store.rs +++ b/app/flowix-cli/src/collection_store.rs @@ -1,7 +1,7 @@ //! Read-only collection dataset access shared by the CLI and MCP operation layer. use crate::{errors::CliError, store}; use flowix_core::{ - collection::{valid_id, CollectionDocument, CollectionPlacement, CollectionType}, + collection::{display_name_for_path, valid_id, CollectionDocument, CollectionPlacement, CollectionType}, memo_file::FileManagementPolicy, }; use rusqlite::Connection; @@ -23,7 +23,7 @@ pub(crate) fn read( } let memo = store::open()?; - let notebook = flowix_core::MemoService::new(&memo).resolve_notebook(notebook_key)?; + let notebook = store::resolve_notebook_config(&memo, notebook_key)?; let root = fs::canonicalize(¬ebook.path)?; let policy = FileManagementPolicy::from_notebook_root(&root); let (path, document) = if let Some(id) = collection_id { @@ -50,7 +50,7 @@ pub(crate) fn read( "collection": { "id": document.collection.id, "type": document.collection.kind.as_str(), - "name": document.collection.name, + "name": display_name_for_path(&path, document.collection.placement), "revision": document.collection.revision, "createdAt": document.collection.created_at, "updatedAt": document.collection.updated_at, diff --git a/app/flowix-cli/src/path_store.rs b/app/flowix-cli/src/path_store.rs index 6b1616172..b2727455b 100644 --- a/app/flowix-cli/src/path_store.rs +++ b/app/flowix-cli/src/path_store.rs @@ -1,6 +1,6 @@ //! Path-addressed note operations shared by the CLI, MCP and operation API. use crate::{errors::CliError, store}; -use flowix_core::{memo_file::NotebookConfig, MemoService, NoteService}; +use flowix_core::{memo_file::NotebookConfig, NoteService}; use serde_json::{json, Value}; use std::{io::Read, path::Path}; @@ -67,7 +67,7 @@ fn document_json(document: &flowix_core::service::NoteDocument) -> Value { pub(crate) fn list(notebook_key: &str) -> Result { let mf = store::open()?; - let notebook = MemoService::new(&mf).resolve_notebook(notebook_key)?; + let notebook = store::resolve_notebook_config(&mf, notebook_key)?; mf.reconcile_note_index_blocking(¬ebook.id)?; let notes = NoteService::new(&mf).list(¬ebook.id)?; Ok(json!({"notebookId": notebook.id, "notes": notes})) @@ -84,7 +84,7 @@ pub(crate) fn create(notebook_key: &str, content: &str) -> Result/ 下放 index.db / boot/preference.json / +/// 用户配置目录名 (~/./ 下放 db/local.db / boot/preference.json / /// agent-config.toml / boot/system.json 等)。 pub const USER_CONFIG_DIR_NAME: &str = ".flowix"; @@ -26,7 +26,7 @@ pub struct Resolved { /// 或 `$XDG_DATA_HOME/flowix` (Linux) #[allow(dead_code)] pub app_data: PathBuf, - /// `~/.flowix/`. 笔记本注册表就存在这里 (`/index.db`). + /// `~/.flowix/`. 笔记本注册表位于 `/db/local.db`. #[allow(dead_code)] pub config_dir: PathBuf, } diff --git a/app/flowix-cli/src/store.rs b/app/flowix-cli/src/store.rs index 4e8794d3b..3a1874802 100644 --- a/app/flowix-cli/src/store.rs +++ b/app/flowix-cli/src/store.rs @@ -1,6 +1,9 @@ //! Notebook configuration shared by the path-addressed CLI and MCP commands. use crate::{errors::CliError, fmt, paths}; -use flowix_core::{memo_file::{MemoFile, NotebookConfig}, MemoService, NoteService}; +use flowix_core::{ + memo_file::{MemoFile, NotebookConfig}, + NoteService, +}; use std::collections::HashMap; pub fn open() -> Result { @@ -10,12 +13,24 @@ pub fn open() -> Result { pub(crate) fn notebooks_list_data() -> Result<(Vec, Option), CliError> { let mf = open()?; - let notebooks = MemoService::new(&mf).list_notebooks()?; + let notebooks = mf.read_notebook_configs()?; let selected = mf.read_selected_notebook_id()?; Ok((notebooks, selected)) } -pub(crate) fn notebook_note_counts(configs: &[NotebookConfig]) -> Result, CliError> { +pub(crate) fn resolve_notebook_config( + mf: &MemoFile, + key: &str, +) -> Result { + let configs = mf.read_notebook_configs()?; + find_notebook(&configs, key) + .cloned() + .ok_or_else(|| CliError::NotFound(format!("notebook not found: {key}"))) +} + +pub(crate) fn notebook_note_counts( + configs: &[NotebookConfig], +) -> Result, CliError> { let mf = open()?; let mut counts = HashMap::new(); for config in configs { @@ -24,20 +39,35 @@ pub(crate) fn notebook_note_counts(configs: &[NotebookConfig]) -> Result Result, CliError> { +pub(crate) fn notebook_tag_counts( + configs: &[NotebookConfig], +) -> Result, CliError> { let mf = open()?; - Ok(configs.iter().map(|config| { - (config.id.clone(), mf.read_notebook_tag_paths(Some(&config.id)).unwrap_or_default().len()) - }).collect()) + Ok(configs + .iter() + .map(|config| { + ( + config.id.clone(), + mf.read_notebook_tag_paths(Some(&config.id)) + .unwrap_or_default() + .len(), + ) + }) + .collect()) } pub fn find_notebook<'a>(configs: &'a [NotebookConfig], key: &str) -> Option<&'a NotebookConfig> { - configs.iter().find(|config| config.id == key) + configs + .iter() + .find(|config| config.id == key) .or_else(|| configs.iter().find(|config| config.name == key)) } @@ -46,9 +76,14 @@ pub(crate) fn resolve_notebook_key(notebook: Option<&str>) -> Result) -> Result) -> Result { let key = resolve_notebook_key(notebook)?; let mf = open()?; - let config = MemoService::new(&mf).resolve_notebook(&key)?; + let config = resolve_notebook_config(&mf, &key)?; mf.reconcile_note_index_blocking(&config.id)?; let tags = mf.read_used_tag_ids_for_notebook_id(Some(&config.id))?; - Ok(serde_json::json!({"ok": true, "action": "tags", "notebook": config.name, - "notebookId": config.id, "total": tags.len(), "tags": tags})) + Ok( + serde_json::json!({"ok": true, "action": "tags", "notebook": config.name, + "notebookId": config.id, "total": tags.len(), "tags": tags}), + ) } pub fn cmd_notebooks_json() -> Result<(), CliError> { let (configs, selected) = notebooks_list_data()?; - fmt::print_notebooks_json(&configs, ¬ebook_note_counts(&configs)?, - ¬ebook_tag_counts(&configs)?, selected.as_deref()); + fmt::print_notebooks_json( + &configs, + ¬ebook_note_counts(&configs)?, + ¬ebook_tag_counts(&configs)?, + selected.as_deref(), + ); Ok(()) } pub fn cmd_notebooks() -> Result<(), CliError> { let (configs, selected) = notebooks_list_data()?; - fmt::print_notebooks(&configs, ¬ebook_note_counts(&configs)?, - ¬ebook_tag_counts(&configs)?, selected.as_deref()); + fmt::print_notebooks( + &configs, + ¬ebook_note_counts(&configs)?, + ¬ebook_tag_counts(&configs)?, + selected.as_deref(), + ); Ok(()) } pub fn cmd_tags(notebook: Option<&str>, json: bool) -> Result<(), CliError> { let payload = notebook_tags(notebook)?; - if json { println!("{}", serde_json::to_string_pretty(&payload).map_err(|error| CliError::Other(error.to_string()))?); } - else if let Some(tags) = payload["tags"].as_array() { - for tag in tags { if let Some(name) = tag.as_str() { println!("{name}"); } } + if json { + println!( + "{}", + serde_json::to_string_pretty(&payload) + .map_err(|error| CliError::Other(error.to_string()))? + ); + } else if let Some(tags) = payload["tags"].as_array() { + for tag in tags { + if let Some(name) = tag.as_str() { + println!("{name}"); + } + } } Ok(()) } @@ -90,9 +144,24 @@ pub fn cmd_completion(shell: &str) -> Result<(), CliError> { let mut command = crate::cli::cli_command(); let mut output = std::io::stdout(); match shell { - "bash" => clap_complete::generate(clap_complete::shells::Bash, &mut command, "flowix", &mut output), - "zsh" => clap_complete::generate(clap_complete::shells::Zsh, &mut command, "flowix", &mut output), - "fish" => clap_complete::generate(clap_complete::shells::Fish, &mut command, "flowix", &mut output), + "bash" => clap_complete::generate( + clap_complete::shells::Bash, + &mut command, + "flowix", + &mut output, + ), + "zsh" => clap_complete::generate( + clap_complete::shells::Zsh, + &mut command, + "flowix", + &mut output, + ), + "fish" => clap_complete::generate( + clap_complete::shells::Fish, + &mut command, + "flowix", + &mut output, + ), other => return Err(CliError::Usage(format!("unknown shell: {other}"))), } Ok(()) diff --git a/app/flowix-core/src/collection.rs b/app/flowix-core/src/collection.rs index 1401693e1..57feec1e9 100644 --- a/app/flowix-core/src/collection.rs +++ b/app/flowix-core/src/collection.rs @@ -43,6 +43,8 @@ pub struct CollectionMetadata { pub kind: CollectionType, #[serde(default, skip_serializing_if = "is_standalone")] pub placement: CollectionPlacement, + /// Deprecated compatibility field. Names are derived from the collection path. + #[serde(default, skip_serializing)] pub name: String, pub revision: i64, pub created_at: String, @@ -52,6 +54,24 @@ pub struct CollectionMetadata { fn is_standalone(value: &CollectionPlacement) -> bool { *value == CollectionPlacement::Standalone } + +pub fn display_name_for_path(path: &std::path::Path, placement: CollectionPlacement) -> String { + let display_path = if placement == CollectionPlacement::Folder { + path.parent().unwrap_or(path) + } else { + path + }; + let filename = display_path.file_name().unwrap_or_default().to_string_lossy(); + if placement == CollectionPlacement::Folder { + return filename.into_owned(); + } + [".index.yaml", ".index.yml", ".table.yaml", ".table.yml", ".lib.yaml", ".lib.yml"] + .iter() + .find_map(|suffix| filename.get(..filename.len().checked_sub(suffix.len())?) + .filter(|_| filename.to_ascii_lowercase().ends_with(suffix))) + .unwrap_or(&filename) + .to_owned() +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CollectionDocument { @@ -115,8 +135,6 @@ impl CollectionDocument { if self.format != "flowix.collection" || self.schema_version < 1 || !valid_id(&metadata.id, "col_") - || metadata.name.trim().is_empty() - || metadata.name != metadata.name.trim() || !(0..=9_007_199_254_740_991).contains(&metadata.revision) || chrono::DateTime::parse_from_rfc3339(&metadata.created_at).is_err() || chrono::DateTime::parse_from_rfc3339(&metadata.updated_at).is_err() @@ -244,6 +262,55 @@ impl CollectionDocument { Ok(()) } + /// Set media resource references to the canonical notebook-and-path ID. + /// Collection row IDs remain collection-local record identities. + pub fn normalize_media_resource_ids(&mut self, notebook_id: &str) -> Result { + if self.collection.kind != CollectionType::MediaLibrary { + return Ok(false); + } + let Some(records) = self + .payload + .get_mut("dataset") + .and_then(|dataset| dataset.get_mut("records")) + else { + return Err("INVALID_MEDIA_LIBRARY_RECORDS".into()); + }; + let mut changed = false; + for key in ["include", "exclude"] { + let entries = records + .get_mut(key) + .and_then(Value::as_array_mut) + .ok_or("INVALID_MEDIA_LIBRARY_RECORDS")?; + for record in entries { + let path = record + .get("relative_path") + .and_then(Value::as_str) + .ok_or("INVALID_MEDIA_LIBRARY_PATH")?; + let id = crate::memo_file::media_resource_id(notebook_id, path) + .map_err(|error| error.to_string())?; + if record.get("resource_id").and_then(Value::as_str) != Some(id.as_str()) { + record["resource_id"] = Value::String(id); + changed = true; + } + } + } + Ok(changed) + } + + /// Rebase file references and update media resource identities in one edit. + pub fn rebase_paths_for_notebook( + &mut self, + notebook_id: &str, + previous: &str, + next: &str, + ) -> Result { + let changed = self.rebase_paths(previous, next); + if changed { + self.normalize_media_resource_ids(notebook_id)?; + } + Ok(changed) + } + /// Rebase notebook-relative references after a confirmed file or directory move. /// The caller owns revision updates and persistence. pub fn rebase_paths(&mut self, previous: &str, next: &str) -> bool { @@ -304,7 +371,15 @@ impl CollectionDocument { changed = true; } } - if let Some(filter) = dataset.get_mut("filter") { changed |= rules(filter, previous, next); } + if let Some(filter) = dataset.get_mut("filter") { + if let Some(path) = filter.get("path").and_then(Value::as_str) { + if let Some(path) = rebase(path, previous, next) { + filter["path"] = Value::String(path); + changed = true; + } + } + changed |= rules(filter, previous, next); + } if let Some(views) = self.payload.get_mut("views").and_then(|views| views.get_mut("items")).and_then(Value::as_array_mut) { for view in views { if let Some(filter) = view.get_mut("filter") { changed |= rules(filter, previous, next); } @@ -506,7 +581,7 @@ fn validate_collection_v2(document: &CollectionDocument) -> Result<(), String> { } if let Some(filter) = dataset.get("filter") { if !filter.is_null() { - validate_rule_filter(filter, document.collection.kind)?; + validate_dataset_filter(filter, document.collection.kind)?; } } let views = payload.get("views").ok_or("MISSING_COLLECTION_VIEWS")?; @@ -582,7 +657,31 @@ fn validate_collection_v2(document: &CollectionDocument) -> Result<(), String> { Ok(()) } fn validate_rule_filter(filter: &Value, collection_type: CollectionType) -> Result<(), String> { - validate_rule_filter_depth(filter, collection_type, 0) + validate_rule_filter_depth(filter, collection_type, 0) +} +fn validate_dataset_filter(filter: &Value, collection_type: CollectionType) -> Result<(), String> { + if filter.get("conditions").is_none() && filter.get("match").is_none() { + return validate_rule_filter(filter, collection_type); + } + only_keys(filter, &["path", "match", "conditions"])?; + match filter.get("path") { + Some(Value::Null) => {} + Some(Value::String(path)) if path.is_empty() => {} + Some(Value::String(path)) => validate_relative_resource_path(Some(path), "filter.path")?, + _ => return Err("INVALID_COLLECTION_FILTER_PATH".into()), + } + if !matches!(filter["match"].as_str(), Some("all" | "any")) { + return Err("INVALID_COLLECTION_FILTER_MATCH".into()); + } + if collection_type == CollectionType::MediaLibrary && filter["match"] != "all" { + return Err("INVALID_COLLECTION_FILTER_MATCH".into()); + } + for rule in array(filter, "conditions")? { + if rule.get("all").is_some() || rule.get("any").is_some() || rule["field"] == "path" { + return Err("INVALID_COLLECTION_FILTER_CONDITION".into()); + } + } + validate_rule_filter(&serde_json::json!({"all":array(filter, "conditions")?,"any":[]}), collection_type) } fn validate_rule_filter_depth(filter: &Value, collection_type: CollectionType, depth: usize) -> Result<(), String> { only_keys(filter, &["all", "any"])?; diff --git a/app/flowix-core/src/lib.rs b/app/flowix-core/src/lib.rs index b8874d0a3..623b0cf37 100644 --- a/app/flowix-core/src/lib.rs +++ b/app/flowix-core/src/lib.rs @@ -17,6 +17,6 @@ pub mod secret; pub mod service; pub mod text_merge; -pub use service::{FlowixError, MemoPage, MemoService, NotePage, NoteSaveOutcome, NoteService}; +pub use service::{FlowixError, NotePage, NoteSaveOutcome, NoteService}; pub mod collection; diff --git a/app/flowix-core/src/memo_file/content.rs b/app/flowix-core/src/memo_file/content.rs index 4c5e2540f..2c4d5d9d7 100644 --- a/app/flowix-core/src/memo_file/content.rs +++ b/app/flowix-core/src/memo_file/content.rs @@ -1,8 +1,6 @@ //! Markdown reads, memo list queries, and filters. //! -//! Current-notebook methods support watcher and legacy reconciliation flows. -//! Global memo-ID lookups remain for compatibility callers. Normal notebook -//! lists and path-based document operations use the Note projection instead. +//! Current-notebook methods support watcher and path-based document flows. use std::collections::HashSet; use std::fs; @@ -17,17 +15,19 @@ impl MemoFile { Self::derive_tags_from_memos(self.read_all_memos()) } - pub fn derived_tags_for_notebook_id(&self, notebook_id: Option<&str>) -> Vec { + pub fn derived_tags_for_notebook_id( + &self, + notebook_id: Option<&str>, + ) -> std::io::Result> { let now = chrono::Utc::now().timestamp_millis(); - self.read_notebook_tag_paths(notebook_id) - .unwrap_or_default() + Ok(self.read_used_tag_ids_for_notebook_id(notebook_id)? .into_iter() .map(|name| MemoTag { id: name.clone(), name, created_at: now, }) - .collect() + .collect()) } fn derive_tags_from_memos(memos: Vec) -> Vec { @@ -56,13 +56,6 @@ impl MemoFile { let notebook_id = self.current_notebook_id_for_index(); let policy = self.file_management_policy(¬ebook_id); let root = self.get_memo_base(); - if let Ok(Some(entries)) = self.list_note_entries_with_legacy_ids(¬ebook_id) { - return Self::memos_from_index(super::types::MemoIndexFile { - version: 2, - last_updated: chrono::Utc::now().timestamp_millis(), - memos: entries, - }, &policy, &root); - } let list = match self.read_index() { Some(l) => l, None => return Vec::new(), @@ -76,13 +69,6 @@ impl MemoFile { let root = self.get_notebook_config_by_id(&resolved_id) .map(|config| std::path::PathBuf::from(config.path)) .unwrap_or_default(); - if let Ok(Some(entries)) = self.list_note_entries_with_legacy_ids(&resolved_id) { - return Self::memos_from_index(super::types::MemoIndexFile { - version: 2, - last_updated: chrono::Utc::now().timestamp_millis(), - memos: entries, - }, &policy, &root); - } let list = match self.read_index_for_notebook_id(notebook_id) { Ok(Some(list)) => list, _ => return Vec::new(), @@ -94,7 +80,7 @@ impl MemoFile { let mut memos: Vec = list .memos .iter() - .filter(|entry| !entry.id.is_empty()) + .filter(|entry| !entry.relative_path.is_empty()) .filter(|entry| { let relative_path = if entry.relative_path.is_empty() { &entry.filename @@ -195,7 +181,7 @@ impl MemoFile { .to_lowercase() .cmp(&b.filename.to_lowercase()) .then_with(|| a.filename.cmp(&b.filename)) - .then_with(|| a.id.cmp(&b.id)); + .then_with(|| a.relative_path.cmp(&b.relative_path)); b.favorited.cmp(&a.favorited).then_with(|| { if descending { filename_order.reverse() @@ -216,7 +202,7 @@ impl MemoFile { .then_with(|| b.updated_at.cmp(&a.updated_at)) // 同一毫秒创建/更新的 memo 也必须有确定顺序, 否则 // 分页时会出现重复或漏项。 - .then_with(|| b.id.cmp(&a.id)) + .then_with(|| b.relative_path.cmp(&a.relative_path)) }); sorted } @@ -226,7 +212,7 @@ impl MemoFile { b.favorited .cmp(&a.favorited) .then_with(|| b.created_at.cmp(&a.created_at)) - .then_with(|| b.id.cmp(&a.id)) + .then_with(|| b.relative_path.cmp(&a.relative_path)) }); sorted } @@ -234,24 +220,16 @@ impl MemoFile { } /// Read one memo from the current notebook index. Body is not loaded. - pub fn read_current_memo(&self, id: &str) -> Option { + pub fn read_current_memo(&self, relative_path: &str) -> Option { let list = self.read_index()?; list.memos .iter() - .find(|e| e.id == id) + .find(|e| e.relative_path == relative_path) .map(MemoFile::index_entry_to_memo) } - pub fn read_memo(&self, id: &str) -> Option { - self.read_current_memo(id) - } - - /// Resolve one memo by globally unique id across all notebooks. - pub fn read_memo_global(&self, id: &str) -> Option { - self.resolve_memo_location(id) - .ok() - .flatten() - .map(|location| MemoFile::index_entry_to_memo(&location.memo)) + pub fn read_memo(&self, relative_path: &str) -> Option { + self.read_current_memo(relative_path) } /// Read metadata and full markdown body for all memos in the current notebook. @@ -276,7 +254,7 @@ impl MemoFile { let policy = super::FileManagementPolicy::from_notebook_root(&base); list.memos .into_iter() - .filter(|e| !e.id.is_empty()) + .filter(|e| !e.relative_path.is_empty()) .filter(|entry| !policy.is_index_ignored_at(&base, std::path::Path::new(&entry.relative_path))) .map(|entry| { let path = super::notebook_path_from_relative(&base, &entry.relative_path) @@ -288,9 +266,9 @@ impl MemoFile { } /// Read one memo and markdown body from the current notebook only. - pub fn read_current_memo_with_body(&self, id: &str) -> Option<(MemoIndexEntry, String)> { + pub fn read_current_memo_with_body(&self, relative_path: &str) -> Option<(MemoIndexEntry, String)> { let list = self.read_index()?; - let entry = list.memos.iter().find(|e| e.id == id)?.clone(); + let entry = list.memos.iter().find(|e| e.relative_path == relative_path)?.clone(); let base = self.get_memo_base(); let path = super::notebook_path_from_relative(&base, &entry.relative_path) .unwrap_or_else(|_| base.join(&entry.filename)); @@ -298,18 +276,7 @@ impl MemoFile { Some((entry, body)) } - pub fn read_memo_with_body(&self, id: &str) -> Option<(MemoIndexEntry, String)> { - self.read_current_memo_with_body(id) - } - - /// Resolve one memo globally and read its markdown body from its owning notebook. - pub fn read_memo_with_body_global(&self, id: &str) -> Option<(MemoIndexEntry, String)> { - let location = self.resolve_memo_location(id).ok().flatten()?; - let entry = location.memo; - let base = std::path::PathBuf::from(location.notebook.path); - let path = super::notebook_path_from_relative(&base, &entry.relative_path) - .unwrap_or_else(|_| base.join(&entry.filename)); - let body = fs::read_to_string(&path).ok()?; - Some((entry, body)) + pub fn read_memo_with_body(&self, relative_path: &str) -> Option<(MemoIndexEntry, String)> { + self.read_current_memo_with_body(relative_path) } } diff --git a/app/flowix-core/src/memo_file/database_layout.rs b/app/flowix-core/src/memo_file/database_layout.rs new file mode 100644 index 000000000..d0c217787 --- /dev/null +++ b/app/flowix-core/src/memo_file/database_layout.rs @@ -0,0 +1,103 @@ +//! User-level SQLite database layout helpers. +//! +//! Databases used to live directly under `~/.flowix/`. They now live under +//! `~/.flowix/db/`. Publish a complete SQLite snapshot under a layout lock; +//! interruption leaves the original database and its WAL together for retry. + +use std::fs::{self, OpenOptions}; +use std::io; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; + +pub const DATABASE_DIRECTORY_NAME: &str = "db"; + +/// Create `db/` and snapshot existing named databases from the config root. +/// The source is retained with its sidecars. An existing canonical database +/// wins, leaving the complete legacy copy untouched. +pub fn prepare_database_directory( + config_dir: &Path, + database_names: &[&str], +) -> io::Result { + super::ensure_storage_directory(config_dir)?; + let lock_path = config_dir.join(".database-layout.lock"); + if fs::symlink_metadata(&lock_path).is_ok_and(|metadata| !metadata.file_type().is_file()) { + super::backup_storage_file(&lock_path)?; + } + let lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(lock_path)?; + FileExt::lock_exclusive(&lock)?; + + let result = (|| { + let database_dir = config_dir.join(DATABASE_DIRECTORY_NAME); + super::ensure_storage_directory(&database_dir)?; + for name in database_names { + validate_database_name(name)?; + move_database_with_sidecars(config_dir, &database_dir, name)?; + } + Ok(database_dir) + })(); + + let unlock_result = FileExt::unlock(&lock); + match (result, unlock_result) { + (Ok(path), Ok(())) => Ok(path), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } +} + +fn validate_database_name(name: &str) -> io::Result<()> { + let path = Path::new(name); + if name.is_empty() + || path.components().count() != 1 + || !path.file_name().is_some_and(|n| n == name) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid database filename: {name}"), + )); + } + Ok(()) +} + +fn move_database_with_sidecars(root: &Path, database_dir: &Path, name: &str) -> io::Result<()> { + let source = root.join(name); + let destination = database_dir.join(name); + + // The canonical database wins as a complete group. Never attach sidecars + // from an older copy, even when the two main files happen to be identical. + if fs::symlink_metadata(&destination).is_ok() { + return Ok(()); + } + // Orphan WAL files cannot belong to a new main database. + super::backup_database(&destination)?; + if !source.is_file() { + return Ok(()); + } + // VACUUM INTO includes committed WAL contents in a standalone database. + // Only publish after SQLite has closed the snapshot. Never split the + // source main file from its WAL, even if the process exits mid-migration. + let temporary = database_dir.join(format!(".{name}.{}.tmp", uuid::Uuid::new_v4())); + let snapshot = (|| -> io::Result<()> { + let conn = rusqlite::Connection::open_with_flags( + &source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ).map_err(io::Error::other)?; + conn.execute("VACUUM INTO ?1", [temporary.to_string_lossy().as_ref()]) + .map_err(io::Error::other)?; + drop(conn); + fs::File::open(&temporary)?.sync_all()?; + fs::rename(&temporary, &destination) + })(); + if let Err(error) = snapshot { + let _ = fs::remove_file(&temporary); + if super::is_rebuildable_storage_error(&error) { + super::backup_database(&source)?; + return Ok(()); // The normal initializer creates an empty canonical DB. + } + return Err(error); + } + Ok(()) +} diff --git a/app/flowix-core/src/memo_file/derivation.rs b/app/flowix-core/src/memo_file/derivation.rs index 4f7c1908f..19be9afd3 100644 --- a/app/flowix-core/src/memo_file/derivation.rs +++ b/app/flowix-core/src/memo_file/derivation.rs @@ -847,7 +847,7 @@ pub fn apply_derived_memo_fields(memo: &mut Memo, full_content: &str) { Some(_) => None, None => { tracing::warn!( - memo_id = %memo.id, + note_path = %memo.relative_path, tag = %raw, "ignoring invalid legacy frontmatter tag path while deriving memo metadata" ); diff --git a/app/flowix-core/src/memo_file/file_management.rs b/app/flowix-core/src/memo_file/file_management.rs index ce6b26071..dc77028cb 100644 --- a/app/flowix-core/src/memo_file/file_management.rs +++ b/app/flowix-core/src/memo_file/file_management.rs @@ -17,35 +17,9 @@ pub struct FileManagementPolicy { /// the Markdown note index. #[serde(default)] pub excluded_index_paths: Vec, - #[serde(default)] - pub legacy_skip_dirs: Vec, - #[serde(default)] - pub legacy_skip_files: Vec, - #[serde(default)] - pub legacy_watcher_migrated: bool, -} - -fn wildcard_match(pattern: &str, name: &str) -> bool { - let (pattern, name) = (pattern.as_bytes(), name.as_bytes()); - let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0); - while n < name.len() { - if p < pattern.len() && pattern[p] == name[n] { p += 1; n += 1; } - else if p < pattern.len() && pattern[p] == b'*' { star = Some(p); p += 1; retry = n; } - else if let Some(position) = star { p = position + 1; retry += 1; n = retry; } - else { return false; } - } - while p < pattern.len() && pattern[p] == b'*' { p += 1; } - p == pattern.len() } impl FileManagementPolicy { - pub fn matches_legacy_entry(&self, absolute: &Path) -> bool { - let Some(name) = absolute.file_name().map(|name| name.to_string_lossy()) else { return false; }; - self.legacy_skip_dirs.iter().any(|skip| skip == name.as_ref()) - || std::fs::symlink_metadata(absolute).is_ok_and(|metadata| metadata.is_file()) - && self.legacy_skip_files.iter().any(|pattern| wildcard_match(pattern, &name)) - } - pub fn is_locked_name(name: &str) -> bool { matches!(name, ".flowix" | ".plugin-output") || name.eq_ignore_ascii_case(".index.yaml") } @@ -89,7 +63,6 @@ impl FileManagementPolicy { let ignored_name = notebook_agents_file || name.starts_with('.') || matches!(name.as_ref(), "attachments" | "attachments-cache" | "node_modules" | "bower_components" | "__pycache__") - || self.legacy_skip_dirs.iter().any(|skip| skip == name.as_ref()) || legacy_media_sidecar || (is_last && (matches!(name.as_ref(), "Thumbs.db" | "ehthumbs.db" | "desktop.ini") || name.ends_with(".tmp") || name.ends_with(".swp") || name.ends_with(".bak") @@ -105,14 +78,6 @@ impl FileManagementPolicy { /// as names. The relative path is always scoped to `root`. pub fn is_ignored_at(&self, root: &Path, relative: &Path) -> bool { if self.is_ignored(relative) { return true; } - let key = relative.to_string_lossy().replace('\\', "/"); - let file = root.join(relative); - if !self.included_paths.iter().any(|included| included == &key) - && std::fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file()) - { - let name = relative.file_name().unwrap_or_default().to_string_lossy(); - if self.legacy_skip_files.iter().any(|pattern| wildcard_match(pattern, &name)) { return true; } - } let mut current = root.to_path_buf(); let mut key = String::new(); for component in relative.components() { @@ -144,8 +109,7 @@ impl FileManagementPolicy { && relative.file_name().is_some_and(|name| name.to_string_lossy() == "AGENTS.md"); let default_policy = FileManagementPolicy::default(); if !is_root_agents - && (default_policy.is_ignored_at(root, relative) - || self.matches_legacy_entry(&root.join(relative))) + && default_policy.is_ignored_at(root, relative) { return true; } diff --git a/app/flowix-core/src/memo_file/frontmatter.rs b/app/flowix-core/src/memo_file/frontmatter.rs index 08c602e83..0dba58569 100644 --- a/app/flowix-core/src/memo_file/frontmatter.rs +++ b/app/flowix-core/src/memo_file/frontmatter.rs @@ -1,14 +1,12 @@ //! Markdown frontmatter 解析与编辑。 //! //! Notes are addressed by notebook-relative path. Supplied Markdown is kept -//! intact, including legacy `flowix_key`. Existing identity fields remain reserved, and the -//! merge helper can read them for legacy frontmatter compatibility. File -//! registration never derives a note identity from those fields. +//! intact; frontmatter fields are not parsed as note identities. //! //! ## 工具 //! //! - [`extract_body_content`] — 切掉 `---\n...\n---` 块, 返回 body 切片。 -//! - [`build_md_content`] — 新建时剔除模板遗留的身份字段。 +//! - [`extract_document_metadata`] — 解析 frontmatter 属性与标签。 //! - [`merge_frontmatter`] — 就地编辑 frontmatter 块, 见下文。 //! //! ## `merge_frontmatter` 行为 @@ -19,7 +17,6 @@ //! **就地替换** value; //! - 其它行 (注释 / 空行 / 列表 / 多行值起点 / 其它 map) → **字节级保留**。 //! 3. `overrides` 里有但 frontmatter 找不到对应行的新字段: -//! - 显式传入的旧 `flowix_key` 字段 → **头部**追加; //! - 其它字段 → **末尾**追加。 //! 4. 无 frontmatter 块 → 在文件头插入包含 `overrides` 的完整块。 //! 5. 规范化文件边界的 UTF-8 BOM; 其它 body 内容保持原样。 @@ -79,15 +76,6 @@ pub fn normalize_markdown_encoding_boundaries(content: &str) -> Cow<'_, str> { /// 3. value (含引号, 替换时由 caller 决定是否重新加引号) static FM_LINE_RE: Lazy = Lazy::new(|| Regex::new(r"^(\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$").unwrap()); -pub const CANONICAL_FRONTMATTER_KEY: &str = "flowix_key"; -pub const LEGACY_FRONTMATTER_KEY: &str = "key"; -/// Reserved legacy identity fields. They are not note identities in the path -/// index and must not appear as user-defined properties. -pub const SYSTEM_FRONTMATTER_KEYS: [&str; 2] = [CANONICAL_FRONTMATTER_KEY, LEGACY_FRONTMATTER_KEY]; - -pub fn is_system_frontmatter_key(key: &str) -> bool { - SYSTEM_FRONTMATTER_KEYS.contains(&key) -} static TAGS_LINE_RE: Lazy = Lazy::new(|| Regex::new(r#"^(?:tags|'tags'|"tags")\s*:"#).unwrap()); static LEGACY_TAG_LINE_RE: Lazy = @@ -115,9 +103,7 @@ pub enum FrontmatterMetadataError { /// 注入指令集: key → 新 value。**就地替换**语义。 /// -/// The merge helper accepts an old `key` override for compatibility and maps -/// it to `flowix_key`. Current note creation and indexing do not supply an -/// identity override. Other field names are merged as provided. +/// Frontmatter overrides are merged by field name. No field carries note identity. pub type MergeOverrides = BTreeMap; /// 切掉 YAML frontmatter 块, 返回剩余 body。 @@ -125,7 +111,7 @@ pub type MergeOverrides = BTreeMap; /// 输入示例: /// ```text /// --- -/// flowix_key: abc123 +/// status: draft /// --- /// # Hello /// body @@ -139,66 +125,6 @@ pub fn extract_body_content(content: &str) -> &str { } } -/// Read a legacy `flowix_key` value for frontmatter compatibility. File -/// registration resolves existing notes by path and does not call this parser. -/// -/// 行为: -/// - 无 frontmatter 块 → None -/// - 有 frontmatter 但没 `flowix_key:` 行 → None -/// - `flowix_key:` value 不在 `[0-9a-z]{6|8}` 字符集 → None (防止用户手写垃圾值) -pub fn extract_frontmatter_key(content: &str) -> Option { - let caps = FRONTMATTER_RE.captures(content)?; - let inner = caps.get(1)?.as_str(); - for line in inner.split('\n') { - if let Some(c) = FM_LINE_RE.captures(line) { - let name = c.get(2).map(|m| m.as_str())?; - if name == CANONICAL_FRONTMATTER_KEY { - let value = c - .get(3) - .map(|m| m.as_str())? - .trim() - .trim_matches(|ch| ch == '"' || ch == '\''); - if matches!(value.len(), 6 | super::MEMO_ID_LENGTH) - && value.chars().all(|c| { - c.is_ascii_alphanumeric() && (c.is_ascii_digit() || c.is_ascii_lowercase()) - }) - { - return Some(value.to_string()); - } - return None; - } - } - } - None -} - -fn extract_legacy_frontmatter_key(content: &str) -> Option { - let caps = FRONTMATTER_RE.captures(content)?; - let inner = caps.get(1)?.as_str(); - for line in inner.split('\n') { - if let Some(c) = FM_LINE_RE.captures(line) { - let name = c.get(2).map(|m| m.as_str())?; - if name != LEGACY_FRONTMATTER_KEY { - continue; - } - let value = c - .get(3) - .map(|m| m.as_str())? - .trim() - .trim_matches(|ch| ch == '"' || ch == '\''); - if matches!(value.len(), 6 | 8) - && value.chars().all(|c| { - c.is_ascii_alphanumeric() && (c.is_ascii_digit() || c.is_ascii_lowercase()) - }) - { - return Some(value.to_string()); - } - return None; - } - } - None -} - pub fn extract_frontmatter_properties(content: &str) -> Value { extract_document_metadata_tolerant(content) .map(|metadata| metadata.properties) @@ -500,15 +426,7 @@ pub(crate) fn replace_frontmatter_tags_preserving_invalid_paths( .map_err(|error| FrontmatterMetadataError::InvalidYaml(error.to_string()))?; lines = yaml.trim_end().lines().map(str::to_string).collect(); } else { - let insert_at = lines - .iter() - .position(|line| { - line.trim_start() - .starts_with(&format!("{}:", CANONICAL_FRONTMATTER_KEY)) - }) - .map(|index| index + 1) - .unwrap_or(lines.len()); - lines.splice(insert_at..insert_at, replacement); + lines.extend(replacement); } Ok(format!("---\n{}\n---\n{}", lines.join("\n"), body)) @@ -516,34 +434,7 @@ pub(crate) fn replace_frontmatter_tags_preserving_invalid_paths( /// 就地编辑 frontmatter 块, 见模块 doc 详述行为契约。 pub fn merge_frontmatter(content: &str, overrides: &MergeOverrides) -> String { - let mut canonical_overrides = overrides.clone(); - if let Some(legacy_value) = canonical_overrides.remove(LEGACY_FRONTMATTER_KEY) { - // A legacy override must not replace an existing canonical field. - // Explicit canonical overrides remain available to compatibility callers. - if extract_frontmatter_key(content).is_none() { - // Existing legacy-only notes migrate by copying their stored - // legacy value. The override is only a fallback when no legacy - // key line exists at all. - let legacy_value = extract_legacy_frontmatter_key(content).unwrap_or(legacy_value); - canonical_overrides - .entry(CANONICAL_FRONTMATTER_KEY.to_string()) - .or_insert(legacy_value); - } - } - let overrides = &canonical_overrides; - if overrides.is_empty() { - // Preserve an existing legacy field when collapsing an adjacent - // system-only frontmatter block into the user's block. - if let Some(canonical_key) = extract_frontmatter_key(content) { - let mut structural_overrides = MergeOverrides::new(); - structural_overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), canonical_key); - if let Some(collapsed) = - collapse_adjacent_override_frontmatter(content, &structural_overrides) - { - return merge_frontmatter(&collapsed, &structural_overrides); - } - } return content.to_string(); } @@ -580,16 +471,10 @@ pub fn merge_frontmatter(content: &str, overrides: &MergeOverrides) -> String { } None => { // 无 frontmatter 块 → 从显式传入的 overrides 创建完整块。 - let mut block_lines: Vec = Vec::new(); - let mut tail_lines: Vec = Vec::new(); - for (k, v) in overrides { - if k == CANONICAL_FRONTMATTER_KEY { - block_lines.push(format!("{}: {}", k, v)); - } else { - tail_lines.push(format!("{}: {}", k, v)); - } - } - block_lines.extend(tail_lines); + let block_lines: Vec = overrides + .iter() + .map(|(key, value)| format!("{}: {}", key, value)) + .collect(); let block = block_lines.join("\n"); // body 前补一个换行: 文件头插入 `---\n...\n---\n\n` format!("---\n{}\n---\n\n{}", block, content) @@ -648,7 +533,6 @@ fn frontmatter_contains_only_override_fields(inner: &str, overrides: &MergeOverr /// - 对 `key: value` 单行, 命中 overrides 内的 key 时**就地替换** value; /// **不**命中时**保留原行** (含其原引号风格 / 缩进)。 /// - overrides 内出现但 frontmatter 找不到对应行的字段: -/// - 显式传入的 `flowix_key` → 准备头部追加; /// - 其它字段 → 准备末尾追加。 fn merge_inner(inner: &str, overrides: &MergeOverrides) -> String { // 1. 行级扫描, 拆出 (key 名, 替换后 value 或 None) 与保留行 @@ -677,22 +561,15 @@ fn merge_inner(inner: &str, overrides: &MergeOverrides) -> String { } } - // 2. 显式旧身份字段在头部追加,其它字段在末尾追加。 - let mut head_lines: Vec = Vec::new(); + // 2. Append newly supplied fields in their original order. let mut tail_lines: Vec = Vec::new(); for (k, v) in &pending { // pending 里剩下的就是 overrides 中未在 frontmatter 找到对应行的 key - if k == CANONICAL_FRONTMATTER_KEY { - head_lines.push(format!("{}: {}", k, v)); - } else { - tail_lines.push(format!("{}: {}", k, v)); - } + tail_lines.push(format!("{}: {}", k, v)); } // 3. 拼接 - let mut final_lines: Vec = - Vec::with_capacity(head_lines.len() + preserved_lines.len() + tail_lines.len()); - final_lines.extend(head_lines); + let mut final_lines: Vec = Vec::with_capacity(preserved_lines.len() + tail_lines.len()); final_lines.extend(preserved_lines); final_lines.extend(tail_lines); @@ -707,19 +584,22 @@ mod tests { #[test] fn extracts_body_after_frontmatter() { - let md = "---\nflowix_key: x\n---\n# Title\nbody\n"; + let md = "---\nstatus: draft\n---\n# Title\nbody\n"; assert_eq!(extract_body_content(md), "# Title\nbody\n"); } #[test] fn extracts_frontmatter_after_leading_blank_lines() { let md = "\n \n---\nflowix_key: abc12345\ntags: [inbox]\n---\n# Title\n"; - assert_eq!(extract_frontmatter_key(md), Some("abc12345".to_string())); assert_eq!(extract_body_content(md), "# Title\n"); assert_eq!( extract_document_metadata(md).unwrap().tags, vec!["inbox".to_string()] ); + assert_eq!( + extract_document_metadata(md).unwrap().properties["flowix_key"], + "abc12345" + ); } #[test] @@ -735,57 +615,45 @@ mod tests { } #[test] - fn extracts_quoted_frontmatter_keys() { - assert_eq!( - extract_frontmatter_key("---\nflowix_key: \"abc12345\"\n---\nbody\n"), - Some("abc12345".to_string()) - ); - assert_eq!( - extract_frontmatter_key("---\nflowix_key: 'abc123'\n---\nbody\n"), - Some("abc123".to_string()) - ); - } - - #[test] - fn ignores_legacy_key_as_memo_identity() { - assert_eq!( - extract_frontmatter_key("---\nkey: abc12345\n---\nbody\n"), - None - ); - } - - #[test] - fn rejects_invalid_canonical_key_even_when_legacy_key_is_valid() { - assert_eq!( - extract_frontmatter_key("---\nflowix_key: not-a-valid-id\nkey: abc12345\n---\nbody\n"), - None - ); + fn historical_flowix_key_is_an_ordinary_yaml_property() { + let metadata = extract_document_metadata( + "---\nflowix_key: \"abc12345\"\nkey: legacy-value\n---\nbody\n", + ) + .unwrap(); + assert_eq!(metadata.properties["flowix_key"], "abc12345"); + assert_eq!(metadata.properties["key"], "legacy-value"); } #[test] - fn copies_legacy_key_into_canonical_field_without_removing_legacy_field() { + fn ordinary_properties_can_be_merged_independently() { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "abc123".to_string()); let out = merge_frontmatter("---\nkey: old999\n---\nbody\n", &overrides); - assert_eq!(out, "---\nflowix_key: old999\nkey: old999\n---\nbody\n"); + assert_eq!(out, "---\nkey: abc123\n---\nbody\n"); } #[test] - fn canonical_merge_override_wins_over_legacy_override() { + fn historical_identity_named_fields_use_generic_merge_behavior() { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "legacy123".to_string()); - overrides.insert("flowix_key".to_string(), "canonical1".to_string()); + overrides.insert("flowix_key".to_string(), "ordinary1".to_string()); let out = merge_frontmatter("body\n", &overrides); - assert_eq!(out, "---\nflowix_key: canonical1\n---\n\nbody\n"); + assert_eq!( + out, + "---\nflowix_key: ordinary1\nkey: legacy123\n---\n\nbody\n" + ); } #[test] - fn legacy_merge_override_does_not_replace_valid_canonical_key() { + fn merge_adds_an_ordinary_property_alongside_historical_identity_field() { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "legacy-new-id".to_string()); let input = "---\nflowix_key: abc12345\n---\nbody\n"; - assert_eq!(merge_frontmatter(input, &overrides), input); + assert_eq!( + merge_frontmatter(input, &overrides), + "---\nflowix_key: abc12345\nkey: legacy-new-id\n---\nbody\n" + ); } // ============== merge_frontmatter: no frontmatter ============== @@ -795,7 +663,7 @@ mod tests { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "abc123".to_string()); let out = merge_frontmatter("body\n", &overrides); - assert_eq!(out, "---\nflowix_key: abc123\n---\n\nbody\n"); + assert_eq!(out, "---\nkey: abc123\n---\n\nbody\n"); } #[test] @@ -804,19 +672,19 @@ mod tests { overrides.insert("key".to_string(), "abc123".to_string()); assert_eq!( merge_frontmatter("", &overrides), - "---\nflowix_key: abc123\n---\n\n" + "---\nkey: abc123\n---\n\n" ); } // ============== merge_frontmatter: replace existing key ============== #[test] - fn explicit_canonical_override_replaces_existing_key_in_place() { + fn merge_replaces_historical_identity_named_property_in_place() { let mut overrides = MergeOverrides::new(); - overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), "newid1".to_string()); + overrides.insert("flowix_key".to_string(), "new-value".to_string()); let input = "---\nflowix_key: oldid9\ntags: [a]\n---\nbody\n"; let out = merge_frontmatter(input, &overrides); - assert_eq!(out, "---\nflowix_key: newid1\ntags: [a]\n---\nbody\n"); + assert_eq!(out, "---\nflowix_key: new-value\ntags: [a]\n---\nbody\n"); } #[test] @@ -827,40 +695,40 @@ mod tests { let out = merge_frontmatter(input, &overrides); assert_eq!( out, - "---\nflowix_key: abc123\ntags: [a, b]\n# comment\ndescription: |\n multi\n line\n\nauthor: x\n---\nbody\n" + "---\ntags: [a, b]\n# comment\ndescription: |\n multi\n line\n\nauthor: x\nkey: abc123\n---\nbody\n" ); } - // ============== merge_frontmatter: prepend key, append others ============== + // ============== merge_frontmatter: append fields ============== #[test] - fn merge_prepends_missing_key() { + fn merge_appends_missing_key_property() { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "abc123".to_string()); let input = "---\ntags: [a]\n---\nbody\n"; let out = merge_frontmatter(input, &overrides); - assert_eq!(out, "---\nflowix_key: abc123\ntags: [a]\n---\nbody\n"); + assert_eq!(out, "---\ntags: [a]\nkey: abc123\n---\nbody\n"); } #[test] - fn merge_prepends_missing_key_in_crlf_frontmatter() { + fn merge_appends_missing_key_property_in_crlf_frontmatter() { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "abc123".to_string()); let input = "---\r\nname: imported\r\n---\r\nbody\r\n"; let out = merge_frontmatter(input, &overrides); assert_eq!( out, - "---\nflowix_key: abc123\nname: imported\n---\nbody\r\n" + "---\nname: imported\nkey: abc123\n---\nbody\r\n" ); } #[test] - fn merge_collapses_adjacent_key_block_into_user_frontmatter() { + fn merge_collapses_adjacent_frontmatter_for_an_ordinary_property() { let mut overrides = MergeOverrides::new(); - overrides.insert("key".to_string(), "sg8qgwdq".to_string()); + overrides.insert("name".to_string(), "new-name".to_string()); let input = concat!( "---\n", - "flowix_key: sg8qgwdq\n", + "name: old-name\n", "---\n", "---\n", "name: guizang-ppt-skill\n", @@ -873,8 +741,7 @@ mod tests { out, concat!( "---\n", - "flowix_key: sg8qgwdq\n", - "name: guizang-ppt-skill\n", + "name: new-name\n", "description: deck generator\n", "---\n", "body\n" @@ -886,16 +753,16 @@ mod tests { fn merge_appends_missing_non_key_field() { let mut overrides = MergeOverrides::new(); overrides.insert("extra".to_string(), "x".to_string()); - let input = "---\nflowix_key: abc123\ntags: [a]\n---\nbody\n"; + let input = "---\nstatus: draft\ntags: [a]\n---\nbody\n"; let out = merge_frontmatter(input, &overrides); assert_eq!( out, - "---\nflowix_key: abc123\ntags: [a]\nextra: x\n---\nbody\n" + "---\nstatus: draft\ntags: [a]\nextra: x\n---\nbody\n" ); } #[test] - fn merge_prepends_key_and_appends_others_together() { + fn merge_appends_multiple_fields() { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "abc123".to_string()); overrides.insert("extra".to_string(), "y".to_string()); @@ -903,7 +770,7 @@ mod tests { let out = merge_frontmatter(input, &overrides); assert_eq!( out, - "---\nflowix_key: abc123\ntags: [a]\nextra: y\n---\nbody\n" + "---\ntags: [a]\nextra: y\nkey: abc123\n---\nbody\n" ); } @@ -914,40 +781,40 @@ mod tests { // 同一字段名出现两次: 都替换 (YAML 语义上重复 key 是非法的, // 工具做防御性替换避免用户 frontmatter 包含重复行时丢改)。 let mut overrides = MergeOverrides::new(); - overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), "newid1".to_string()); - let input = "---\nflowix_key: oldid9\nother: x\nflowix_key: anotherold\n---\nbody\n"; + overrides.insert("status".to_string(), "new-value".to_string()); + let input = "---\nstatus: old\nother: x\nstatus: another\n---\nbody\n"; let out = merge_frontmatter(input, &overrides); assert_eq!( out, - "---\nflowix_key: newid1\nother: x\nflowix_key: newid1\n---\nbody\n" + "---\nstatus: new-value\nother: x\nstatus: new-value\n---\nbody\n" ); } #[test] fn merge_empty_overrides_returns_content_unchanged() { let overrides: MergeOverrides = MergeOverrides::new(); - let input = "---\nflowix_key: abc123\n---\nbody\n"; + let input = "---\nstatus: draft\n---\nbody\n"; assert_eq!(merge_frontmatter(input, &overrides), input); } #[test] fn merge_strips_leading_bom_before_inserting_frontmatter() { let mut overrides = MergeOverrides::new(); - overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), "abc123".to_string()); + overrides.insert("status".to_string(), "draft".to_string()); let out = merge_frontmatter("\u{FEFF}# Imported\n", &overrides); - assert_eq!(out, "---\nflowix_key: abc123\n---\n\n# Imported\n"); + assert_eq!(out, "---\nstatus: draft\n---\n\n# Imported\n"); } #[test] fn encoding_boundary_normalization_is_idempotent_and_scoped() { - let legacy = "---\nflowix_key: abc123\n---\n\u{FEFF}body \u{FEFF} text\n"; + let legacy = "---\nstatus: draft\n---\n\u{FEFF}body \u{FEFF} text\n"; let normalized = normalize_markdown_encoding_boundaries(legacy); assert_eq!( normalized, - "---\nflowix_key: abc123\n---\nbody \u{FEFF} text\n" + "---\nstatus: draft\n---\nbody \u{FEFF} text\n" ); assert!(matches!( normalize_markdown_encoding_boundaries(normalized.as_ref()), @@ -958,24 +825,24 @@ mod tests { #[test] fn merge_strips_leading_bom_before_updating_frontmatter() { let mut overrides = MergeOverrides::new(); - overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), "abc123".to_string()); + overrides.insert("status".to_string(), "draft".to_string()); - let out = merge_frontmatter("\u{FEFF}---\nflowix_key: old999\n---\nbody\n", &overrides); + let out = merge_frontmatter("\u{FEFF}---\nstatus: old\n---\nbody\n", &overrides); - assert_eq!(out, "---\nflowix_key: abc123\n---\nbody\n"); + assert_eq!(out, "---\nstatus: draft\n---\nbody\n"); } #[test] fn merge_strips_legacy_bom_at_frontmatter_body_boundary() { let mut overrides = MergeOverrides::new(); - overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), "abc123".to_string()); + overrides.insert("status".to_string(), "draft".to_string()); let out = merge_frontmatter( - "---\nflowix_key: old999\n---\n\u{FEFF}

Imported

\n", + "---\nstatus: old\n---\n\u{FEFF}

Imported

\n", &overrides, ); - assert_eq!(out, "---\nflowix_key: abc123\n---\n

Imported

\n"); + assert_eq!(out, "---\nstatus: draft\n---\n

Imported

\n"); } #[test] @@ -985,17 +852,17 @@ mod tests { let out = merge_frontmatter("body \u{FEFF} text\n", &overrides); - assert_eq!(out, "---\nflowix_key: abc123\n---\n\nbody \u{FEFF} text\n"); + assert_eq!(out, "---\nkey: abc123\n---\n\nbody \u{FEFF} text\n"); } #[test] fn merge_preserves_value_with_quotes() { // 替换时按 YAML 标准不引号, 工具统一输出 `key: ` 无引号 let mut overrides = MergeOverrides::new(); - overrides.insert(CANONICAL_FRONTMATTER_KEY.to_string(), "abc123".to_string()); - let input = "---\nflowix_key: \"old999\"\n---\nbody\n"; + overrides.insert("status".to_string(), "draft".to_string()); + let input = "---\nstatus: \"old\"\n---\nbody\n"; let out = merge_frontmatter(input, &overrides); - assert_eq!(out, "---\nflowix_key: abc123\n---\nbody\n"); + assert_eq!(out, "---\nstatus: draft\n---\nbody\n"); } #[test] @@ -1004,14 +871,14 @@ mod tests { let mut overrides = MergeOverrides::new(); overrides.insert("key".to_string(), "z9y8x7".to_string()); let out = merge_frontmatter("---\n---\nx", &overrides); - assert_eq!(out, "---\nflowix_key: z9y8x7\n---\nx"); + assert_eq!(out, "---\nkey: z9y8x7\n---\nx"); } #[test] fn document_metadata_uses_only_frontmatter_tags() { let content = concat!( "---\n", - "flowix_key: abc12345\n", + "status: draft\n", "tags: [product, product/design, product]\n", "status: draft\n", "---\n", @@ -1037,7 +904,7 @@ mod tests { #[test] fn lenient_tag_replacement_preserves_unrelated_invalid_paths() { - let input = "---\nflowix_key: abc12345\ntags: [\"legacy tag\", remove]\n---\nbody\n"; + let input = "---\nstatus: draft\ntags: [\"legacy tag\", remove]\n---\nbody\n"; let metadata = extract_document_metadata_preserving_invalid_tag_paths(input).unwrap(); assert_eq!(metadata.tags, vec!["legacy tag", "remove"]); @@ -1051,13 +918,13 @@ mod tests { #[test] fn document_metadata_does_not_classify_body_tags() { let metadata = - extract_document_metadata("---\nflowix_key: abc12345\n---\n# body-only\n").unwrap(); + extract_document_metadata("---\nstatus: draft\n---\n# body-only\n").unwrap(); assert!(metadata.tags.is_empty()); } #[test] fn legacy_singular_tag_is_read_and_rewritten_as_tags() { - let input = "---\nflowix_key: abc12345\ntag: [legacy]\n---\nbody\n"; + let input = "---\nstatus: draft\ntag: [legacy]\n---\nbody\n"; let metadata = extract_document_metadata(input).unwrap(); assert_eq!(metadata.tags, vec!["legacy"]); assert!(metadata.properties.get("tag").is_none()); @@ -1101,7 +968,7 @@ mod tests { fn replace_tags_preserves_unrelated_yaml_and_body() { let input = concat!( "---\n", - "flowix_key: abc12345\n", + "status: draft\n", "# keep this comment\n", "tags:\n", " - old/path\n", @@ -1116,7 +983,7 @@ mod tests { output, concat!( "---\n", - "flowix_key: abc12345\n", + "status: draft\n", "# keep this comment\n", "tags:\n", " - \"new/path\"\n", @@ -1129,15 +996,15 @@ mod tests { } #[test] - fn replace_tags_inserts_after_key_and_keeps_body() { + fn replace_tags_appends_a_frontmatter_property_and_keeps_body() { let output = replace_frontmatter_tags( - "---\nflowix_key: abc12345\nstatus: draft\n---\nbody\n", + "---\nsource: archive\nstatus: draft\n---\nbody\n", &["product".to_string()], ) .unwrap(); assert_eq!( output, - "---\nflowix_key: abc12345\ntags:\n - \"product\"\nstatus: draft\n---\nbody\n" + "---\nsource: archive\nstatus: draft\ntags:\n - \"product\"\n---\nbody\n" ); } } diff --git a/app/flowix-core/src/memo_file/index_store.rs b/app/flowix-core/src/memo_file/index_store.rs index b17c3957c..9b9ef6821 100644 --- a/app/flowix-core/src/memo_file/index_store.rs +++ b/app/flowix-core/src/memo_file/index_store.rs @@ -1,8 +1,7 @@ -//! Legacy memo-ID records and metadata stored in the notebook-local database. +//! Notebook-relative-path projection stored in the notebook-local database. //! -//! The Note projection is path-keyed in `note_index`; this module remains -//! for callers and records that still require memo IDs. Older global rows are -//! imported from the user-level registry database during notebook setup. +//! The separate Note catalog in `note_index` tracks filesystem metadata. This +//! module stores the content-derived listing fields used by memo surfaces. use std::fs; use std::path::PathBuf; @@ -13,34 +12,12 @@ use super::derivation::{extract_agent_threads_from_body, extract_thumbnail}; use super::frontmatter::extract_frontmatter_properties; use super::notebook::sqlite_to_io; use super::types::{ - AgentThreadItem, Memo, MemoColor, MemoIndexEntry, MemoIndexFile, MemoLocation, MemoTodoEntry, - NotebookConfig, TodoItem, + AgentThreadItem, Memo, NoteColor, MemoIndexEntry, MemoIndexFile, MemoLocation, NotebookConfig, }; use super::MemoFile; -/// Durable identity of the latest committed bytes for one memo. -/// -/// `revision` is a local, monotonically increasing counter. `change_id` -/// identifies one concrete content transition and is stable when duplicate -/// filesystem notifications observe the same bytes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MemoContentRevision { - pub memo_id: String, - pub notebook_id: String, - pub content_hash: String, - pub revision: i64, - pub change_id: String, - pub updated_at: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MemoContentCommit { - pub state: MemoContentRevision, - pub changed: bool, -} - -/// Path-keyed successor of [`MemoContentRevision`]. The revision stream is -/// addressed by `(notebook_id, relative_path)` instead of a legacy memo ID, +/// Path-keyed content revision for a note. The revision stream is +/// addressed by `(notebook_id, relative_path)`, /// so renames must carry the row over (see rename/move flows). #[derive(Debug, Clone, PartialEq, Eq)] pub struct NoteContentRevision { @@ -58,43 +35,31 @@ pub struct NoteContentCommit { pub changed: bool, } -// Pending markers only bridge a live Desktop watcher and a concurrent CLI/MCP -// process. Expiry prevents an offline create from being mistaken for a create -// when that document is edited much later. -const EXTERNAL_CREATE_MARKER_TTL_MS: i64 = 60_000; - -fn color_to_str(color: MemoColor) -> &'static str { +fn color_to_str(color: NoteColor) -> &'static str { match color { - MemoColor::Red => "red", - MemoColor::Orange => "orange", - MemoColor::Yellow => "yellow", - MemoColor::Green => "green", - MemoColor::Cyan => "cyan", - MemoColor::Blue => "blue", - MemoColor::Gray => "gray", + NoteColor::Red => "red", + NoteColor::Orange => "orange", + NoteColor::Yellow => "yellow", + NoteColor::Green => "green", + NoteColor::Cyan => "cyan", + NoteColor::Blue => "blue", + NoteColor::Gray => "gray", } } -fn color_from_str(value: &str) -> Option { +fn color_from_str(value: &str) -> Option { match value { - "red" => Some(MemoColor::Red), - "orange" => Some(MemoColor::Orange), - "yellow" => Some(MemoColor::Yellow), - "green" => Some(MemoColor::Green), - "cyan" => Some(MemoColor::Cyan), - "blue" => Some(MemoColor::Blue), - "gray" => Some(MemoColor::Gray), + "red" => Some(NoteColor::Red), + "orange" => Some(NoteColor::Orange), + "yellow" => Some(NoteColor::Yellow), + "green" => Some(NoteColor::Green), + "cyan" => Some(NoteColor::Cyan), + "blue" => Some(NoteColor::Blue), + "gray" => Some(NoteColor::Gray), _ => None, } } -fn tag_path_prefixes(path: &str) -> Vec { - let segments: Vec<&str> = path.split('/').collect(); - (1..=segments.len()) - .map(|end| segments[..end].join("/")) - .collect() -} - mod persistence; mod repository; mod schema; diff --git a/app/flowix-core/src/memo_file/index_store/persistence.rs b/app/flowix-core/src/memo_file/index_store/persistence.rs index 8416c7ee5..ea14abd80 100644 --- a/app/flowix-core/src/memo_file/index_store/persistence.rs +++ b/app/flowix-core/src/memo_file/index_store/persistence.rs @@ -29,9 +29,9 @@ impl MemoFile { tx.execute( r#" INSERT INTO memos - (id, notebook_id, filename, relative_path, preview, thumbnail, thumbnail_checked, agents_checked, created_at, updated_at, favorited, icon, properties) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(id) DO UPDATE SET + (notebook_id, filename, relative_path, preview, thumbnail, thumbnail_checked, agents_checked, created_at, updated_at, favorited, icon, properties) + VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(relative_path) DO UPDATE SET notebook_id = excluded.notebook_id, filename = excluded.filename, relative_path = excluded.relative_path, @@ -46,7 +46,6 @@ impl MemoFile { properties = excluded.properties "#, params![ - entry.id, notebook_id, entry.filename, entry.relative_path, @@ -65,93 +64,24 @@ impl MemoFile { fn replace_entry_children_in_tx( tx: &rusqlite::Transaction<'_>, - notebook_id: &str, + _notebook_id: &str, entry: &MemoIndexEntry, ) -> std::io::Result<()> { - // Todo metadata is user-visible state kept in the local index. Read it - // before deleting the derived rows so a content refresh can preserve - // priority/owner/assignee and timestamps. - let existing_todos = Self::read_existing_todo_metadata_in_tx(tx, &entry.id)?; - - tx.execute( - "DELETE FROM memo_tags WHERE memo_id = ?1", - params![entry.id], - ) - .map_err(sqlite_to_io)?; - tx.execute( - "DELETE FROM memo_colors WHERE memo_id = ?1", - params![entry.id], - ) - .map_err(sqlite_to_io)?; tx.execute( - "DELETE FROM memo_todos WHERE memo_id = ?1", - params![entry.id], + "DELETE FROM memo_colors WHERE relative_path = ?1", + params![entry.relative_path], ) .map_err(sqlite_to_io)?; tx.execute( - "DELETE FROM memo_agents WHERE memo_id = ?1", - params![entry.id], + "DELETE FROM memo_agents WHERE relative_path = ?1", + params![entry.relative_path], ) .map_err(sqlite_to_io)?; - for tag in &entry.tags { - tx.execute( - "INSERT OR IGNORE INTO memo_tags (memo_id, tag) VALUES (?1, ?2)", - params![entry.id, tag], - ) - .map_err(sqlite_to_io)?; - let now = chrono::Utc::now().timestamp_millis(); - for prefix in tag_path_prefixes(tag) { - tx.execute( - "INSERT OR IGNORE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - VALUES (?1, ?2, ?3, ?3)", - params![notebook_id, prefix, now], - ) - .map_err(sqlite_to_io)?; - } - } for (position, color) in entry.colors.iter().enumerate() { tx.execute( - "INSERT OR REPLACE INTO memo_colors (memo_id, color, position) VALUES (?1, ?2, ?3)", - params![entry.id, color_to_str(*color), position as i64], - ) - .map_err(sqlite_to_io)?; - } - let now = chrono::Utc::now().timestamp_millis(); - - for (position, todo) in entry.todos.iter().enumerate() { - let existing = existing_todos - .iter() - .find(|entry| entry.content == todo.content); - let created_at = existing - .map(|entry| entry.created_at) - .filter(|value| *value > 0) - .unwrap_or(entry.created_at); - let updated_at = existing - .filter(|entry| entry.status == todo.status) - .map(|entry| entry.updated_at) - .filter(|value| *value > 0) - .unwrap_or(now); - tx.execute( - r#" - INSERT OR REPLACE INTO memo_todos - (memo_id, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - "#, - params![ - entry.id, - if todo.id.is_empty() { format!("todo-{}", position) } else { todo.id.clone() }, - todo.content, - todo.status, - existing.map(|entry| entry.priority.as_str()).unwrap_or(""), - existing.map(|entry| entry.time_range.as_str()).unwrap_or(""), - existing.map(|entry| entry.owner.as_str()).unwrap_or(""), - existing.map(|entry| entry.assignee.as_str()).unwrap_or(""), - created_at, - updated_at, - position as i64, - ], + "INSERT OR REPLACE INTO memo_colors (relative_path, color, position) VALUES (?1, ?2, ?3)", + params![entry.relative_path, color_to_str(*color), position as i64], ) .map_err(sqlite_to_io)?; } @@ -159,11 +89,11 @@ impl MemoFile { tx.execute( r#" INSERT OR REPLACE INTO memo_agents - (memo_id, thread_id, title, agent_type, position) + (relative_path, thread_id, title, agent_type, position) VALUES (?1, ?2, ?3, ?4, ?5) "#, params![ - entry.id, + entry.relative_path, agent.thread_id, agent.title, agent.agent_type, @@ -175,38 +105,6 @@ impl MemoFile { Ok(()) } - fn read_existing_todo_metadata_in_tx( - tx: &rusqlite::Transaction<'_>, - memo_id: &str, - ) -> std::io::Result> { - let mut stmt = tx - .prepare( - r#" - SELECT todo_id, content, status, memo_id, priority, time_range, owner, assignee, created_at, updated_at - FROM memo_todos - WHERE memo_id = ?1 - "#, - ) - .map_err(sqlite_to_io)?; - let rows = stmt - .query_map(params![memo_id], |row| { - Ok(MemoTodoEntry { - todo_id: row.get(0)?, - content: row.get(1)?, - status: row.get(2)?, - memo_id: row.get(3)?, - priority: row.get(4)?, - time_range: row.get(5)?, - owner: row.get(6)?, - assignee: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, - }) - }) - .map_err(sqlite_to_io)?; - rows.collect::, _>>().map_err(sqlite_to_io) - } - pub(super) fn read_index_from_db( &self, conn: &Connection, @@ -227,7 +125,7 @@ impl MemoFile { let mut stmt = conn .prepare( r#" - SELECT id, filename, relative_path, preview, thumbnail, created_at, updated_at, favorited, icon, properties + SELECT filename, relative_path, preview, thumbnail, created_at, updated_at, favorited, icon, properties FROM memos WHERE notebook_id = ?1 ORDER BY created_at ASC, rowid ASC @@ -236,23 +134,21 @@ impl MemoFile { .map_err(sqlite_to_io)?; let rows = stmt .query_map(params![notebook_id], |row| { - let id: String = row.get(0)?; Ok(MemoIndexEntry { - id, - filename: row.get(1)?, - relative_path: row.get(2)?, - preview: row.get(3)?, - thumbnail: row.get(4)?, + filename: row.get(0)?, + relative_path: row.get(1)?, + preview: row.get(2)?, + thumbnail: row.get(3)?, tags: Vec::new(), todos: Vec::new(), agents: Vec::new(), - created_at: row.get(5)?, - updated_at: row.get(6)?, - favorited: row.get::<_, i64>(7)? != 0, - icon: row.get(8)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + favorited: row.get::<_, i64>(6)? != 0, + icon: row.get(7)?, colors: Vec::new(), properties: serde_json::from_str::( - &row.get::<_, String>(9)?, + &row.get::<_, String>(8)?, ) .unwrap_or_else(|_| serde_json::json!({})), }) @@ -261,10 +157,8 @@ impl MemoFile { let mut memos = rows.collect::, _>>().map_err(sqlite_to_io)?; for entry in &mut memos { - entry.tags = self.read_entry_tags(conn, &entry.id)?; - entry.colors = self.read_entry_colors(conn, &entry.id)?; - entry.todos = self.read_entry_todos(conn, &entry.id)?; - entry.agents = self.read_entry_agents(conn, &entry.id)?; + entry.colors = self.read_entry_colors(conn, &entry.relative_path)?; + entry.agents = self.read_entry_agents(conn, &entry.relative_path)?; } let memo_base = self.memo_base_for_notebook_id(notebook_id); self.backfill_missing_properties(conn, notebook_id, &memo_base, &mut memos)?; @@ -334,11 +228,11 @@ impl MemoFile { } conn.execute( - "UPDATE memos SET properties = ?1 WHERE notebook_id = ?2 AND id = ?3", + "UPDATE memos SET properties = ?1 WHERE notebook_id = ?2 AND relative_path = ?3", params![ serde_json::to_string(&properties).unwrap_or_else(|_| "{}".to_string()), notebook_id, - entry.id, + entry.relative_path, ], ) .map_err(sqlite_to_io)?; @@ -358,8 +252,8 @@ impl MemoFile { if entry.thumbnail.as_deref().unwrap_or("").trim().is_empty() { let checked = conn .query_row( - "SELECT thumbnail_checked FROM memos WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, entry.id], + "SELECT thumbnail_checked FROM memos WHERE notebook_id = ?1 AND relative_path = ?2", + params![notebook_id, entry.relative_path], |row| row.get::<_, i64>(0), ) .optional() @@ -379,8 +273,8 @@ impl MemoFile { .and_then(|content| extract_thumbnail(&content)); conn.execute( - "UPDATE memos SET thumbnail = ?1, thumbnail_checked = 1 WHERE notebook_id = ?2 AND id = ?3", - params![thumbnail, notebook_id, entry.id], + "UPDATE memos SET thumbnail = ?1, thumbnail_checked = 1 WHERE notebook_id = ?2 AND relative_path = ?3", + params![thumbnail, notebook_id, entry.relative_path], ) .map_err(sqlite_to_io)?; entry.thumbnail = thumbnail; @@ -402,8 +296,8 @@ impl MemoFile { let checked = conn .query_row( - "SELECT agents_checked FROM memos WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, entry.id], + "SELECT agents_checked FROM memos WHERE notebook_id = ?1 AND relative_path = ?2", + params![notebook_id, entry.relative_path], |row| row.get::<_, i64>(0), ) .optional() @@ -422,19 +316,19 @@ impl MemoFile { let tx = conn.unchecked_transaction().map_err(sqlite_to_io)?; tx.execute( - "DELETE FROM memo_agents WHERE memo_id = ?1", - params![entry.id], + "DELETE FROM memo_agents WHERE relative_path = ?1", + params![entry.relative_path], ) .map_err(sqlite_to_io)?; for (position, agent) in agents.iter().enumerate() { tx.execute( r#" INSERT OR REPLACE INTO memo_agents - (memo_id, thread_id, title, agent_type, position) + (relative_path, thread_id, title, agent_type, position) VALUES (?1, ?2, ?3, ?4, ?5) "#, params![ - entry.id, + entry.relative_path, agent.thread_id, agent.title, agent.agent_type, @@ -444,8 +338,8 @@ impl MemoFile { .map_err(sqlite_to_io)?; } tx.execute( - "UPDATE memos SET agents_checked = 1 WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, entry.id], + "UPDATE memos SET agents_checked = 1 WHERE notebook_id = ?1 AND relative_path = ?2", + params![notebook_id, entry.relative_path], ) .map_err(sqlite_to_io)?; tx.commit().map_err(sqlite_to_io)?; @@ -454,30 +348,16 @@ impl MemoFile { Ok(()) } - pub(super) fn read_entry_tags( - &self, - conn: &Connection, - memo_id: &str, - ) -> std::io::Result> { - let mut stmt = conn - .prepare("SELECT tag FROM memo_tags WHERE memo_id = ?1 ORDER BY rowid ASC") - .map_err(sqlite_to_io)?; - let rows = stmt - .query_map(params![memo_id], |row| row.get(0)) - .map_err(sqlite_to_io)?; - rows.collect::, _>>().map_err(sqlite_to_io) - } - pub(super) fn read_entry_colors( &self, conn: &Connection, - memo_id: &str, - ) -> std::io::Result> { + relative_path: &str, + ) -> std::io::Result> { let mut stmt = conn - .prepare("SELECT color FROM memo_colors WHERE memo_id = ?1 ORDER BY position ASC") + .prepare("SELECT color FROM memo_colors WHERE relative_path = ?1 ORDER BY position ASC") .map_err(sqlite_to_io)?; let rows = stmt - .query_map(params![memo_id], |row| row.get::<_, String>(0)) + .query_map(params![relative_path], |row| row.get::<_, String>(0)) .map_err(sqlite_to_io)?; Ok(rows .collect::, _>>() @@ -487,40 +367,18 @@ impl MemoFile { .collect()) } - pub(super) fn read_entry_todos( - &self, - conn: &Connection, - memo_id: &str, - ) -> std::io::Result> { - let mut stmt = conn - .prepare( - "SELECT todo_id, content, status FROM memo_todos WHERE memo_id = ?1 ORDER BY position ASC", - ) - .map_err(sqlite_to_io)?; - let rows = stmt - .query_map(params![memo_id], |row| { - Ok(TodoItem { - id: row.get(0)?, - content: row.get(1)?, - status: row.get(2)?, - }) - }) - .map_err(sqlite_to_io)?; - rows.collect::, _>>().map_err(sqlite_to_io) - } - pub(super) fn read_entry_agents( &self, conn: &Connection, - memo_id: &str, + relative_path: &str, ) -> std::io::Result> { let mut stmt = conn .prepare( - "SELECT thread_id, title, agent_type FROM memo_agents WHERE memo_id = ?1 ORDER BY position ASC", + "SELECT thread_id, title, agent_type FROM memo_agents WHERE relative_path = ?1 ORDER BY position ASC", ) .map_err(sqlite_to_io)?; let rows = stmt - .query_map(params![memo_id], |row| { + .query_map(params![relative_path], |row| { Ok(AgentThreadItem { thread_id: row.get(0)?, title: row.get(1)?, diff --git a/app/flowix-core/src/memo_file/index_store/repository.rs b/app/flowix-core/src/memo_file/index_store/repository.rs index 74ed1fe87..09a058191 100644 --- a/app/flowix-core/src/memo_file/index_store/repository.rs +++ b/app/flowix-core/src/memo_file/index_store/repository.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use super::*; impl MemoFile { @@ -8,43 +6,11 @@ impl MemoFile { notebook_id: &str, parent: Option<&str>, ) -> std::io::Result> { - if self.note_index_is_ready(notebook_id)? { - return self.note_occupied_filenames(notebook_id, parent); + if !self.note_index_is_ready(notebook_id)? { + self.reconcile_note_index_blocking(notebook_id)?; } - let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let mut stmt = conn - .prepare( - "SELECT filename FROM memos WHERE notebook_id = ?1 AND \ - CASE WHEN relative_path = filename THEN '' \ - ELSE substr(relative_path, 1, length(relative_path) - length(filename) - 1) END = ?2", - ) - .map_err(sqlite_to_io)?; - let rows = stmt - .query_map(params![notebook_id, parent.unwrap_or("")], |row| row.get(0)) - .map_err(sqlite_to_io)?; - rows.collect::, _>>().map_err(sqlite_to_io) + self.note_occupied_filenames(notebook_id, parent) } - /// Read every notebook's memo count from its notebook-local index. - pub fn memo_counts_by_notebook(&self) -> std::io::Result> { - let mut counts = HashMap::new(); - for notebook in self.read_notebook_configs()? { - if self.note_index_is_ready(¬ebook.id)? { - counts.insert(notebook.id.clone(), self.note_count(¬ebook.id)?); - continue; - } - let conn = self.open_memo_index_db_for_notebook_id(¬ebook.id)?; - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM memos WHERE notebook_id = ?1", - params![notebook.id], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - counts.insert(notebook.id, usize::try_from(count).unwrap_or(0)); - } - Ok(counts) - } - pub fn read_index(&self) -> Option { let notebook_id = self.current_notebook_id_for_index(); if let Ok(Some(cached)) = self.current_cached_index(¬ebook_id) { @@ -82,14 +48,19 @@ impl MemoFile { self.read_index_from_db(&conn, ¬ebook_id) } - pub fn resolve_memo_location(&self, memo_id: &str) -> std::io::Result> { - for notebook_config in self.read_notebook_configs()? { - let conn = self.open_memo_index_db_for_notebook_id(¬ebook_config.id)?; - let row = conn + pub fn resolve_note_location_for_notebook_id( + &self, + notebook_id: &str, + relative_path: &str, + ) -> std::io::Result> { + let notebook_config = self.get_notebook_config_by_id(notebook_id).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, format!("notebook not found: {notebook_id}")) + })?; + let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; + let row = conn .query_row( r#" SELECT - m.id, m.filename, m.relative_path, m.preview, @@ -108,42 +79,40 @@ impl MemoFile { n.updated_at FROM memos m JOIN notebooks n ON n.id = m.notebook_id - WHERE m.id = ?1 + WHERE m.notebook_id = ?1 AND m.relative_path = ?2 LIMIT 1 "#, - params![memo_id], + params![notebook_id, relative_path], |row| { - let memo_id: String = row.get(0)?; - let is_default: i64 = row.get(14)?; + let is_default: i64 = row.get(13)?; Ok(( MemoIndexEntry { - id: memo_id, - filename: row.get(1)?, - relative_path: row.get(2)?, - preview: row.get(3)?, - thumbnail: row.get(4)?, + filename: row.get(0)?, + relative_path: row.get(1)?, + preview: row.get(2)?, + thumbnail: row.get(3)?, tags: Vec::new(), todos: Vec::new(), agents: Vec::new(), - created_at: row.get(5)?, - updated_at: row.get(6)?, - favorited: row.get::<_, i64>(7)? != 0, - icon: row.get(8)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + favorited: row.get::<_, i64>(6)? != 0, + icon: row.get(7)?, colors: Vec::new(), properties: serde_json::from_str::( - &row.get::<_, String>(9)?, + &row.get::<_, String>(8)?, ) .unwrap_or_else(|_| serde_json::json!({})), }, NotebookConfig { - id: row.get(10)?, - name: row.get(11)?, - icon: row.get(12)?, - path: row.get(13)?, + id: notebook_config.id.clone(), + name: notebook_config.name.clone(), + icon: notebook_config.icon.clone(), + path: notebook_config.path.clone(), is_default: is_default != 0, - sort: 0, - created_at: row.get(15)?, - updated_at: row.get(16)?, + sort: notebook_config.sort, + created_at: notebook_config.created_at, + updated_at: notebook_config.updated_at, }, )) }, @@ -151,13 +120,11 @@ impl MemoFile { .optional() .map_err(sqlite_to_io)?; - let Some((mut memo, notebook)) = row else { - continue; - }; - memo.tags = self.read_entry_tags(&conn, &memo.id)?; - memo.colors = self.read_entry_colors(&conn, &memo.id)?; - memo.todos = self.read_entry_todos(&conn, &memo.id)?; - memo.agents = self.read_entry_agents(&conn, &memo.id)?; + let Some((mut memo, notebook)) = row else { + return Ok(None); + }; + memo.colors = self.read_entry_colors(&conn, &memo.relative_path)?; + memo.agents = self.read_entry_agents(&conn, &memo.relative_path)?; self.backfill_missing_properties( &conn, ¬ebook.id, @@ -177,10 +144,7 @@ impl MemoFile { std::slice::from_mut(&mut memo), )?; - return Ok(Some(MemoLocation { memo, notebook })); - } - - Ok(None) + Ok(Some(MemoLocation { memo, notebook })) } pub fn read_index_result(&self) -> std::io::Result> { @@ -216,7 +180,6 @@ impl MemoFile { pub fn memo_to_index_entry(memo: &Memo) -> MemoIndexEntry { MemoIndexEntry { - id: memo.id.clone(), filename: memo.filename.clone(), relative_path: memo.relative_path.clone(), preview: memo.preview.clone(), @@ -235,7 +198,6 @@ impl MemoFile { pub fn index_entry_to_memo(entry: &MemoIndexEntry) -> Memo { Memo { - id: entry.id.clone(), filename: entry.filename.clone(), relative_path: entry.relative_path.clone(), preview: entry.preview.clone(), @@ -267,28 +229,19 @@ impl MemoFile { notebook_id: &str, memo: &Memo, ) -> std::io::Result<()> { - self.sync_index_on_write_for_notebook_id_locked_inner(notebook_id, memo, true) - } - - pub(crate) fn sync_index_on_write_for_notebook_id_locked_without_projection( - &self, - notebook_id: &str, - memo: &Memo, - ) -> std::io::Result<()> { - self.sync_index_on_write_for_notebook_id_locked_inner(notebook_id, memo, false) + self.sync_index_on_write_for_notebook_id_locked_inner(notebook_id, memo) } fn sync_index_on_write_for_notebook_id_locked_inner( &self, notebook_id: &str, memo: &Memo, - refresh_projection: bool, ) -> std::io::Result<()> { let mut conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; let old_path: Option = conn .query_row( - "SELECT relative_path FROM memos WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, memo.id], + "SELECT relative_path FROM memos WHERE notebook_id = ?1 AND relative_path = ?2", + params![notebook_id, memo.relative_path], |row| row.get(0), ) .optional() @@ -305,17 +258,15 @@ impl MemoFile { updated_at, )?; tx.commit().map_err(sqlite_to_io)?; - if refresh_projection { - if let Some(path) = old_path.filter(|path| path != &memo.relative_path) { - self.refresh_note_path(notebook_id, &path)?; - } - self.refresh_note_path(notebook_id, &memo.relative_path)?; + if let Some(path) = old_path.filter(|path| path != &memo.relative_path) { + self.refresh_note_path(notebook_id, &path)?; } + self.refresh_note_path(notebook_id, &memo.relative_path)?; if self.current_notebook_id_for_index() == notebook_id { let mut cache = self.index_cache.write().expect("index_cache poisoned"); if let Some(index) = cache.as_mut() { let entry = Self::memo_to_index_entry(memo); - if let Some(existing) = index.memos.iter_mut().find(|item| item.id == memo.id) { + if let Some(existing) = index.memos.iter_mut().find(|item| item.relative_path == memo.relative_path) { *existing = entry; } else { index.memos.push(entry); @@ -334,26 +285,26 @@ impl MemoFile { self.sync_index_on_write(memo) } - pub fn sync_index_on_delete(&self, memo_id: &str) -> std::io::Result<()> { + pub fn sync_index_on_delete(&self, relative_path: &str) -> std::io::Result<()> { let _guard = self.current_index_io.lock().expect("index_io poisoned"); - self.sync_index_on_delete_locked(memo_id) + self.sync_index_on_delete_locked(relative_path) } - pub fn sync_index_on_delete_locked(&self, memo_id: &str) -> std::io::Result<()> { + pub fn sync_index_on_delete_locked(&self, relative_path: &str) -> std::io::Result<()> { let notebook_id = self.current_notebook_id_for_index(); - self.sync_index_on_delete_for_notebook_id_locked(¬ebook_id, memo_id) + self.sync_index_on_delete_for_notebook_id_locked(¬ebook_id, relative_path) } pub fn sync_index_on_delete_for_notebook_id_locked( &self, notebook_id: &str, - memo_id: &str, + relative_path: &str, ) -> std::io::Result<()> { let mut conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; let old_path: Option = conn .query_row( - "SELECT relative_path FROM memos WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, memo_id], + "SELECT relative_path FROM memos WHERE notebook_id = ?1 AND relative_path = ?2", + params![notebook_id, relative_path], |row| row.get(0), ) .optional() @@ -362,8 +313,8 @@ impl MemoFile { .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(sqlite_to_io)?; tx.execute( - "DELETE FROM memos WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, memo_id], + "DELETE FROM memos WHERE notebook_id = ?1 AND relative_path = ?2", + params![notebook_id, relative_path], ) .map_err(sqlite_to_io)?; self.mark_index_state( diff --git a/app/flowix-core/src/memo_file/index_store/schema.rs b/app/flowix-core/src/memo_file/index_store/schema.rs index e33fc28ec..610fcf102 100644 --- a/app/flowix-core/src/memo_file/index_store/schema.rs +++ b/app/flowix-core/src/memo_file/index_store/schema.rs @@ -1,20 +1,4 @@ use super::*; -use std::sync::{LazyLock, Mutex}; - -static MEMO_RELATIVE_PATH_MIGRATION_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - -/// Legacy global indexes predate several optional tables and columns. -/// Importing from an absent source is a no-op, not an error; anything else -/// still fails. -fn tolerate_missing_legacy_table(error: std::io::Error) -> std::io::Result { - if error.to_string().contains("no such table") - || error.to_string().contains("no such column") - { - Ok(0) - } else { - Err(error) - } -} impl MemoFile { pub fn storage_title_from_filename(filename: &str) -> String { @@ -53,6 +37,51 @@ impl MemoFile { } fn ensure_memo_tables(&self, conn: &Connection) -> std::io::Result<()> { + // Remove retired ID-backed state even if an earlier upgrade already + // replaced `memos.id` but left these tables behind. + let (has_legacy_id, has_retired_id_tables): (bool, bool) = conn + .query_row( + "SELECT + EXISTS(SELECT 1 FROM pragma_table_info('memos') WHERE name = 'id') + OR EXISTS(SELECT 1 FROM pragma_table_info('memo_agents') WHERE name = 'memo_id') + OR EXISTS(SELECT 1 FROM pragma_table_info('memo_colors') WHERE name = 'memo_id'), + EXISTS( + SELECT 1 FROM sqlite_master + WHERE type = 'table' + AND name IN ( + 'memo_content_revisions', + 'pending_external_memo_creates', + 'memo_lifecycles', + 'schema_migrations' + ) + )", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(sqlite_to_io)?; + if has_retired_id_tables { + conn.execute_batch( + "DROP TABLE IF EXISTS memo_content_revisions; + DROP TABLE IF EXISTS pending_external_memo_creates; + DROP TABLE IF EXISTS memo_lifecycles; + DROP TABLE IF EXISTS schema_migrations;", + ) + .map_err(sqlite_to_io)?; + } + + // Rebuild the older cache if any of its tables still use memo IDs. + // Markdown remains the source of truth for every dropped projection. + if has_legacy_id { + conn.execute_batch( + "DROP TABLE IF EXISTS memo_todos; + DROP TABLE IF EXISTS memo_tags; + DROP TABLE IF EXISTS memo_agents; + DROP TABLE IF EXISTS memo_colors; + DROP TABLE IF EXISTS memos; + DROP TABLE IF EXISTS memo_index_state;", + ) + .map_err(sqlite_to_io)?; + } conn.execute_batch( r#" PRAGMA foreign_keys = ON; @@ -79,12 +108,7 @@ impl MemoFile { completed_at INTEGER NOT NULL, PRIMARY KEY(notebook_id, migration_key) ); - CREATE TABLE IF NOT EXISTS schema_migrations ( - migration_key TEXT PRIMARY KEY, - completed_at INTEGER NOT NULL - ); CREATE TABLE IF NOT EXISTS memos ( - id TEXT PRIMARY KEY, notebook_id TEXT NOT NULL, filename TEXT NOT NULL, relative_path TEXT NOT NULL, @@ -97,47 +121,13 @@ impl MemoFile { favorited INTEGER NOT NULL, icon TEXT, properties TEXT NOT NULL DEFAULT '{}', - FOREIGN KEY(notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE, - UNIQUE(notebook_id, relative_path) + PRIMARY KEY(relative_path), + FOREIGN KEY(notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_memos_notebook_created ON memos(notebook_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_memos_notebook_updated ON memos(notebook_id, updated_at DESC); - CREATE TABLE IF NOT EXISTS pending_external_memo_creates ( - memo_id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS memo_lifecycles ( - memo_id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - relative_path TEXT NOT NULL DEFAULT '', - generation INTEGER NOT NULL DEFAULT 1, - is_deleted INTEGER NOT NULL DEFAULT 0 CHECK(is_deleted IN (0, 1)), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - deleted_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_memo_lifecycles_notebook - ON memo_lifecycles(notebook_id, is_deleted); - CREATE TABLE IF NOT EXISTS memo_content_revisions ( - memo_id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - content_hash TEXT NOT NULL, - local_revision INTEGER NOT NULL, - change_id TEXT NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY(memo_id) REFERENCES memo_lifecycles(memo_id) ON DELETE RESTRICT - ); - CREATE INDEX IF NOT EXISTS idx_memo_content_revisions_notebook - ON memo_content_revisions(notebook_id); - CREATE TABLE IF NOT EXISTS memo_tags ( - memo_id TEXT NOT NULL, - tag TEXT NOT NULL, - PRIMARY KEY(memo_id, tag), - FOREIGN KEY(memo_id) REFERENCES memos(id) ON DELETE CASCADE - ); CREATE TABLE IF NOT EXISTS notebook_tags ( notebook_id TEXT NOT NULL, path TEXT NOT NULL, @@ -148,651 +138,36 @@ impl MemoFile { ); CREATE INDEX IF NOT EXISTS idx_notebook_tags_notebook ON notebook_tags(notebook_id); - CREATE TRIGGER IF NOT EXISTS trg_memo_tags_register_notebook_tag - AFTER INSERT ON memo_tags - BEGIN - INSERT OR IGNORE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - SELECT - m.notebook_id, - NEW.tag, - CAST(strftime('%s', 'now') AS INTEGER) * 1000, - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - FROM memos m - WHERE m.id = NEW.memo_id; - END; CREATE TABLE IF NOT EXISTS memo_colors ( - memo_id TEXT NOT NULL, + relative_path TEXT NOT NULL, color TEXT NOT NULL, position INTEGER NOT NULL, - PRIMARY KEY(memo_id, color), - FOREIGN KEY(memo_id) REFERENCES memos(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS memo_todos ( - memo_id TEXT NOT NULL, - todo_id TEXT NOT NULL DEFAULT '', - content TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL DEFAULT '', - time_range TEXT NOT NULL DEFAULT '', - owner TEXT NOT NULL DEFAULT '', - assignee TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0, - position INTEGER NOT NULL, - PRIMARY KEY(memo_id, todo_id), - FOREIGN KEY(memo_id) REFERENCES memos(id) ON DELETE CASCADE + PRIMARY KEY(relative_path, color), + FOREIGN KEY(relative_path) REFERENCES memos(relative_path) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS memo_agents ( - memo_id TEXT NOT NULL, + relative_path TEXT NOT NULL, thread_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', agent_type TEXT NOT NULL DEFAULT '', position INTEGER NOT NULL, - PRIMARY KEY(memo_id, thread_id), - FOREIGN KEY(memo_id) REFERENCES memos(id) ON DELETE CASCADE + PRIMARY KEY(relative_path, thread_id), + FOREIGN KEY(relative_path) REFERENCES memos(relative_path) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_memo_agents_memo_id - ON memo_agents(memo_id); + CREATE INDEX IF NOT EXISTS idx_memo_agents_relative_path + ON memo_agents(relative_path); "#, ) .map_err(sqlite_to_io)?; - self.migrate_memo_relative_paths(conn)?; - self.migrate_memo_todo_ids(conn)?; - self.migrate_memo_lifecycles(conn)?; conn.execute_batch( - r#" - INSERT OR IGNORE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - SELECT DISTINCT - m.notebook_id, - mt.tag, - CAST(strftime('%s', 'now') AS INTEGER) * 1000, - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - FROM memo_tags mt - JOIN memos m ON m.id = mt.memo_id - WHERE NOT EXISTS ( - SELECT 1 - FROM schema_migrations - WHERE migration_key = 'notebook_tags_v1' - ); - - INSERT OR IGNORE INTO schema_migrations (migration_key, completed_at) - VALUES ( - 'notebook_tags_v1', - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - ); - "#, + "DROP TRIGGER IF EXISTS trg_memo_tags_register_notebook_tag; \ + DROP TABLE IF EXISTS memo_tags; \ + DROP TABLE IF EXISTS memo_todos;", ) .map_err(sqlite_to_io)?; Ok(()) } - /// Upgrade the v3 index where filename was the notebook-relative - /// location. Keep filename as the basename and make relative_path the - /// durable location identity so same-named notes can coexist in folders. - fn migrate_memo_relative_paths(&self, conn: &Connection) -> std::io::Result<()> { - // Multiple MemoFile instances can open the same index during startup. - // The per-instance RMW mutex cannot protect schema setup, so combine a - // process-wide guard with SQLite's write transaction. The second - // connection re-checks the marker after it acquires the database lock. - let _migration_guard = MEMO_RELATIVE_PATH_MIGRATION_LOCK - .lock() - .expect("memo migration lock poisoned"); - - // Existing installations have the old UNIQUE(notebook_id, filename) - // constraint, which cannot be dropped in place. Rebuild the metadata - // table while preserving child tables and memo ids. SQLite DDL is - // transactional, so a crash must leave either the old or new schema. - conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;") - .map_err(sqlite_to_io)?; - let result = (|| -> std::io::Result<()> { - let done: Option = conn - .query_row( - "SELECT migration_key FROM schema_migrations WHERE migration_key = 'memo_relative_paths_v1'", - [], - |row| row.get(0), - ) - .optional() - .map_err(sqlite_to_io)?; - if done.is_some() { - return Ok(()); - } - - let has_relative_path = conn - .prepare("PRAGMA table_info(memos)") - .and_then(|mut statement| { - let rows = statement.query_map([], |row| row.get::<_, String>(1))?; - rows.collect::, _>>() - }) - .map_err(sqlite_to_io)? - .into_iter() - .any(|name| name == "relative_path"); - - let recovery_table_exists: bool = conn - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memos_relative_paths_v1')", - [], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - - // Recover databases left by the earlier non-transactional - // migration. It could copy every memo into the replacement table - // and leave the live table empty without writing the marker. - if has_relative_path { - if recovery_table_exists { - let live_count: i64 = conn - .query_row("SELECT COUNT(*) FROM memos", [], |row| row.get(0)) - .map_err(sqlite_to_io)?; - let recovery_count: i64 = conn - .query_row("SELECT COUNT(*) FROM memos_relative_paths_v1", [], |row| { - row.get(0) - }) - .map_err(sqlite_to_io)?; - if live_count == 0 && recovery_count > 0 { - conn.execute_batch( - r#" - DROP TRIGGER IF EXISTS trg_memo_tags_register_notebook_tag; - DROP TABLE memos; - ALTER TABLE memos_relative_paths_v1 RENAME TO memos; - "#, - ) - .map_err(sqlite_to_io)?; - } else { - conn.execute_batch("DROP TABLE memos_relative_paths_v1;") - .map_err(sqlite_to_io)?; - } - } - Self::finish_memo_relative_path_migration(conn)?; - return Ok(()); - } - - conn.execute_batch("DROP TABLE IF EXISTS memos_relative_paths_v1;") - .map_err(sqlite_to_io)?; - conn.execute("ALTER TABLE memos ADD COLUMN relative_path TEXT", []) - .map_err(sqlite_to_io)?; - conn.execute_batch( - r#" - CREATE TABLE memos_relative_paths_v1 ( - id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - filename TEXT NOT NULL, - relative_path TEXT NOT NULL, - preview TEXT NOT NULL, - thumbnail TEXT, - thumbnail_checked INTEGER NOT NULL DEFAULT 0, - agents_checked INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - favorited INTEGER NOT NULL, - icon TEXT, - properties TEXT NOT NULL DEFAULT '{}', - FOREIGN KEY(notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE, - UNIQUE(notebook_id, relative_path) - ); - INSERT INTO memos_relative_paths_v1 - (id, notebook_id, filename, relative_path, preview, thumbnail, - thumbnail_checked, agents_checked, created_at, updated_at, - favorited, icon, properties) - SELECT id, notebook_id, filename, - COALESCE(NULLIF(relative_path, ''), filename), - preview, thumbnail, thumbnail_checked, agents_checked, - created_at, updated_at, favorited, icon, properties - FROM memos; - DROP TRIGGER IF EXISTS trg_memo_tags_register_notebook_tag; - DROP TABLE memos; - ALTER TABLE memos_relative_paths_v1 RENAME TO memos; - "#, - ) - .map_err(sqlite_to_io)?; - Self::finish_memo_relative_path_migration(conn)?; - Ok(()) - })(); - let transaction = match result { - Ok(()) => conn.execute_batch("COMMIT;").map_err(sqlite_to_io), - Err(error) => { - let _ = conn.execute_batch("ROLLBACK;"); - Err(error) - } - }; - let restore = conn - .execute_batch("PRAGMA foreign_keys = ON;") - .map_err(sqlite_to_io); - transaction.and(restore) - } - - fn finish_memo_relative_path_migration(conn: &Connection) -> std::io::Result<()> { - conn.execute_batch( - r#" - CREATE INDEX IF NOT EXISTS idx_memos_notebook_created - ON memos(notebook_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_memos_notebook_updated - ON memos(notebook_id, updated_at DESC); - CREATE TRIGGER IF NOT EXISTS trg_memo_tags_register_notebook_tag - AFTER INSERT ON memo_tags - BEGIN - INSERT OR IGNORE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - SELECT - m.notebook_id, - NEW.tag, - CAST(strftime('%s', 'now') AS INTEGER) * 1000, - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - FROM memos m - WHERE m.id = NEW.memo_id; - END; - INSERT OR IGNORE INTO schema_migrations (migration_key, completed_at) - VALUES ('memo_relative_paths_v1', CAST(strftime('%s', 'now') AS INTEGER) * 1000); - UPDATE memo_index_state - SET version = version + 1, - last_updated = MAX( - last_updated + 1, - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - ); - "#, - ) - .map_err(sqlite_to_io) - } - - /// Upgrade the original `(memo_id, content)` todo key. Content is mutable - /// and is not unique (two checklist rows may intentionally have the same - /// text), so the durable child key must be `(memo_id, todo_id)`. - fn migrate_memo_todo_ids(&self, conn: &Connection) -> std::io::Result<()> { - let columns = conn - .prepare("PRAGMA table_info(memo_todos)") - .and_then(|mut statement| { - let rows = statement.query_map([], |row| { - Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?)) - })?; - rows.collect::, _>>() - }) - .map_err(sqlite_to_io)?; - if columns.is_empty() { - return Ok(()); - } - let has_todo_id = columns.iter().any(|(name, _)| name == "todo_id"); - if !has_todo_id { - conn.execute( - "ALTER TABLE memo_todos ADD COLUMN todo_id TEXT NOT NULL DEFAULT ''", - [], - ) - .map_err(sqlite_to_io)?; - } - let todo_id_is_primary_key = columns - .iter() - .any(|(name, primary_key)| name == "todo_id" && *primary_key > 0); - if todo_id_is_primary_key { - return Ok(()); - } - - conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;") - .map_err(sqlite_to_io)?; - let result = (|| -> std::io::Result<()> { - conn.execute_batch( - r#" - DROP TABLE IF EXISTS memo_todos_v2; - CREATE TABLE memo_todos_v2 ( - memo_id TEXT NOT NULL, - todo_id TEXT NOT NULL, - content TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL DEFAULT '', - time_range TEXT NOT NULL DEFAULT '', - owner TEXT NOT NULL DEFAULT '', - assignee TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0, - position INTEGER NOT NULL, - PRIMARY KEY(memo_id, todo_id), - FOREIGN KEY(memo_id) REFERENCES memos(id) ON DELETE CASCADE - ); - "#, - ) - .map_err(sqlite_to_io)?; - let mut statement = conn - .prepare( - "SELECT rowid, memo_id, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position FROM memo_todos ORDER BY memo_id, position, rowid", - ) - .map_err(sqlite_to_io)?; - let rows = statement - .query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, String>(7)?, - row.get::<_, String>(8)?, - row.get::<_, i64>(9)?, - row.get::<_, i64>(10)?, - row.get::<_, i64>(11)?, - )) - }) - .map_err(sqlite_to_io)?; - let rows = rows.collect::, _>>().map_err(sqlite_to_io)?; - drop(statement); - for ( - rowid, - memo_id, - _legacy_todo_id, - content, - status, - priority, - time_range, - owner, - assignee, - created_at, - updated_at, - position, - ) in rows - { - // rowid is only used for migrating legacy rows. New and - // imported rows receive their marker-derived id before they - // reach this table. - let todo_id = format!("todo-legacy-{rowid}"); - conn.execute( - r#"INSERT INTO memo_todos_v2 - (memo_id, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, - params![memo_id, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position], - ) - .map_err(sqlite_to_io)?; - } - conn.execute_batch( - "DROP TABLE memo_todos; ALTER TABLE memo_todos_v2 RENAME TO memo_todos; CREATE INDEX IF NOT EXISTS idx_memo_todos_memo_id ON memo_todos(memo_id);", - ) - .map_err(sqlite_to_io)?; - Ok(()) - })(); - let transaction = match result { - Ok(()) => conn.execute_batch("COMMIT;").map_err(sqlite_to_io), - Err(error) => { - let _ = conn.execute_batch("ROLLBACK;"); - Err(error) - } - }; - let restore = conn - .execute_batch("PRAGMA foreign_keys = ON;") - .map_err(sqlite_to_io); - transaction.and(restore) - } - - /// Keep revisions after a memo row is deleted, but make their owner an - /// explicit lifecycle record. This gives revision history a real foreign - /// key without cascading away the state needed to publish a tombstone. - fn migrate_memo_lifecycles(&self, conn: &Connection) -> std::io::Result<()> { - let has_fk = conn - .prepare("PRAGMA foreign_key_list(memo_content_revisions)") - .and_then(|mut statement| { - let rows = statement.query_map([], |row| row.get::<_, String>(2))?; - rows.collect::, _>>() - }) - .map_err(sqlite_to_io)? - .into_iter() - .any(|table| table == "memo_lifecycles"); - - conn.execute_batch( - r#" - INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at) - SELECT id, notebook_id, relative_path, 1, 0, created_at, updated_at FROM memos; - INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at) - SELECT memo_id, notebook_id, '', 1, 1, updated_at, updated_at - FROM memo_content_revisions; - "#, - ) - .map_err(sqlite_to_io)?; - if has_fk { - return Ok(()); - } - - conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;") - .map_err(sqlite_to_io)?; - let result = (|| -> std::io::Result<()> { - conn.execute_batch( - r#" - CREATE TABLE memo_content_revisions_v2 ( - memo_id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - content_hash TEXT NOT NULL, - local_revision INTEGER NOT NULL, - change_id TEXT NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY(memo_id) REFERENCES memo_lifecycles(memo_id) ON DELETE RESTRICT - ); - INSERT INTO memo_content_revisions_v2 - (memo_id, notebook_id, content_hash, local_revision, change_id, updated_at) - SELECT memo_id, notebook_id, content_hash, local_revision, change_id, updated_at - FROM memo_content_revisions; - DROP TABLE memo_content_revisions; - ALTER TABLE memo_content_revisions_v2 RENAME TO memo_content_revisions; - CREATE INDEX IF NOT EXISTS idx_memo_content_revisions_notebook - ON memo_content_revisions(notebook_id); - "#, - ) - .map_err(sqlite_to_io) - })(); - let transaction = match result { - Ok(()) => conn.execute_batch("COMMIT;").map_err(sqlite_to_io), - Err(error) => { - let _ = conn.execute_batch("ROLLBACK;"); - Err(error) - } - }; - let restore = conn - .execute_batch("PRAGMA foreign_keys = ON;") - .map_err(sqlite_to_io); - transaction.and(restore) - } - - fn import_legacy_global_memo_index( - &self, - conn: &Connection, - notebook_id: &str, - ) -> std::io::Result<()> { - let already_imported: Option = conn - .query_row( - "SELECT value FROM notebook_index_meta WHERE key = 'legacy_global_index_import_v1'", - [], - |row| row.get(0), - ) - .optional() - .map_err(sqlite_to_io)?; - if already_imported.is_some() { - return Ok(()); - } - - let legacy_path = self.registry_db_path(); - if !legacy_path.is_file() { - conn.execute( - "INSERT OR REPLACE INTO notebook_index_meta (key, value) VALUES ('legacy_global_index_import_v1', ?1)", - ["missing"], - ) - .map_err(sqlite_to_io)?; - return Ok(()); - } - - let legacy = self.open_registry_db()?; - let has_memos: bool = legacy - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memos')", - [], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - if !has_memos { - conn.execute( - "INSERT OR REPLACE INTO notebook_index_meta (key, value) VALUES ('legacy_global_index_import_v1', ?1)", - ["empty"], - ) - .map_err(sqlite_to_io)?; - return Ok(()); - } - - let legacy_path_string = legacy_path.to_string_lossy().to_string(); - conn.execute("ATTACH DATABASE ?1 AS legacy_index", [&legacy_path_string]) - .map_err(sqlite_to_io)?; - // Pre-relative-path global indexes kept the notebook-relative location - // in `filename` and had no `relative_path` column; pre-todo-id todo - // tables had no stable `todo_id`. Detect both and adapt the import. - let legacy_has = |table: &str, column: &str| -> std::io::Result { - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info(?1, 'legacy_index') WHERE name = ?2", - [table, column], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - Ok(count > 0) - }; - let legacy_relative_path_expr = if legacy_has("memos", "relative_path")? { - "relative_path" - } else { - "filename" - }; - let legacy_todo_id_expr = if legacy_has("memo_todos", "todo_id")? { - "mt.todo_id" - } else { - "'todo-legacy-' || mt.rowid" - }; - let result = (|| -> std::io::Result<()> { - let tx = conn.unchecked_transaction().map_err(sqlite_to_io)?; - - // Global indexes written before the lifecycle refactor predate the - // memo_lifecycles table. Skipping the import is safe: the primary - // copy stays valid and migrate_memo_lifecycles backfills lifecycle - // rows from the imported memos. - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at, deleted_at) - SELECT memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at, deleted_at - FROM legacy_index.memo_lifecycles WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at, deleted_at) - SELECT memo_id, notebook_id, '', 1, 1, updated_at, updated_at, NULL - FROM legacy_index.memo_content_revisions - WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - // Older global indexes did not have revisions yet. The - // primary copy remains valid when that optional table is absent. - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - &format!( - "INSERT OR IGNORE INTO memos - (id, notebook_id, filename, relative_path, preview, thumbnail, thumbnail_checked, agents_checked, created_at, updated_at, favorited, icon, properties) - SELECT id, notebook_id, filename, {legacy_relative_path_expr}, COALESCE(preview, ''), thumbnail, thumbnail_checked, agents_checked, created_at, updated_at, favorited, icon, COALESCE(properties, '{{}}') - FROM legacy_index.memos WHERE notebook_id = ?1" - ), - [notebook_id], - ) - .map_err(sqlite_to_io)?; - // The ensure_memo_tables lifecycle backfill ran before this import, - // so imported memos have no lifecycle rows yet. Backfill now: later - // revision imports reference these rows under FK RESTRICT. - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at) - SELECT id, notebook_id, relative_path, 1, 0, created_at, updated_at - FROM memos WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io)?; - tx.execute( - "INSERT OR IGNORE INTO memo_content_revisions - (memo_id, notebook_id, content_hash, local_revision, change_id, updated_at) - SELECT memo_id, notebook_id, content_hash, local_revision, change_id, updated_at - FROM legacy_index.memo_content_revisions WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_tags (memo_id, tag) - SELECT mt.memo_id, mt.tag FROM legacy_index.memo_tags mt - JOIN legacy_index.memos m ON m.id = mt.memo_id - WHERE m.notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_colors (memo_id, color, position) - SELECT mc.memo_id, mc.color, mc.position FROM legacy_index.memo_colors mc - JOIN legacy_index.memos m ON m.id = mc.memo_id - WHERE m.notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - &format!( - "INSERT OR IGNORE INTO memo_todos - (memo_id, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position) - SELECT mt.memo_id, {legacy_todo_id_expr}, mt.content, mt.status, mt.priority, mt.time_range, mt.owner, mt.assignee, mt.created_at, mt.updated_at, mt.position - FROM legacy_index.memo_todos mt - JOIN legacy_index.memos m ON m.id = mt.memo_id - WHERE m.notebook_id = ?1" - ), - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_agents (memo_id, thread_id, title, agent_type, position) - SELECT ma.memo_id, ma.thread_id, ma.title, ma.agent_type, ma.position - FROM legacy_index.memo_agents ma - JOIN legacy_index.memos m ON m.id = ma.memo_id - WHERE m.notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO notebook_tags (notebook_id, path, created_at, updated_at) - SELECT notebook_id, path, created_at, updated_at - FROM legacy_index.notebook_tags WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_index_state (notebook_id, version, last_updated, migrated_at) - SELECT notebook_id, version, last_updated, migrated_at - FROM legacy_index.memo_index_state WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO notebook_data_migrations (notebook_id, migration_key, version, completed_at) - SELECT notebook_id, migration_key, version, completed_at - FROM legacy_index.notebook_data_migrations WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR REPLACE INTO notebook_index_meta (key, value) VALUES ('legacy_global_index_import_v1', ?1)", - ["complete"], - ) - .map_err(sqlite_to_io)?; - tx.commit().map_err(sqlite_to_io) - })(); - let detach = conn.execute_batch("DETACH DATABASE legacy_index;"); - result.and(detach.map_err(sqlite_to_io)) - } - fn ensure_local_notebook_row( &self, conn: &Connection, @@ -833,7 +208,6 @@ impl MemoFile { let conn = self.open_notebook_db(notebook_id)?; self.ensure_memo_tables(&conn)?; self.ensure_local_notebook_row(&conn, notebook_id)?; - self.import_legacy_global_memo_index(&conn, notebook_id)?; Ok(conn) } @@ -842,327 +216,6 @@ impl MemoFile { self.open_memo_index_db_for_notebook_id(¬ebook_id) } - /// Record an external-process create before its markdown file becomes visible. - /// The Desktop watcher consumes this marker when it observes the filesystem event. - pub fn mark_pending_external_memo_create( - &self, - memo_id: &str, - notebook_id: &str, - ) -> std::io::Result<()> { - let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let now = chrono::Utc::now().timestamp_millis(); - conn.execute( - "DELETE FROM pending_external_memo_creates WHERE created_at < ?1", - params![now - EXTERNAL_CREATE_MARKER_TTL_MS], - ) - .map_err(sqlite_to_io)?; - conn.execute( - r#" - INSERT INTO pending_external_memo_creates (memo_id, notebook_id, created_at) - VALUES (?1, ?2, ?3) - ON CONFLICT(memo_id) DO UPDATE SET - notebook_id = excluded.notebook_id, - created_at = excluded.created_at - "#, - params![memo_id, notebook_id, now], - ) - .map_err(sqlite_to_io)?; - Ok(()) - } - - /// Atomically claim an external create marker. A marker can produce at most one - /// `Created` event even when the platform reports several filesystem events. - pub fn consume_pending_external_memo_create( - &self, - memo_id: &str, - notebook_id: &str, - ) -> std::io::Result { - let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let cutoff = chrono::Utc::now().timestamp_millis() - EXTERNAL_CREATE_MARKER_TTL_MS; - let changed = conn - .execute( - "DELETE FROM pending_external_memo_creates WHERE memo_id = ?1 AND notebook_id = ?2 AND created_at >= ?3", - params![memo_id, notebook_id, cutoff], - ) - .map_err(sqlite_to_io)?; - Ok(changed > 0) - } - - pub fn pending_external_memo_creates_for_notebook( - &self, - notebook_id: &str, - ) -> std::io::Result> { - let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let cutoff = chrono::Utc::now().timestamp_millis() - EXTERNAL_CREATE_MARKER_TTL_MS; - let mut statement = conn - .prepare( - "SELECT memo_id FROM pending_external_memo_creates WHERE notebook_id = ?1 AND created_at >= ?2", - ) - .map_err(sqlite_to_io)?; - let rows = statement - .query_map(params![notebook_id, cutoff], |row| row.get::<_, String>(0)) - .map_err(sqlite_to_io)?; - let mut memo_ids = std::collections::HashSet::new(); - for row in rows { - memo_ids.insert(row.map_err(sqlite_to_io)?); - } - Ok(memo_ids) - } - - pub fn has_pending_external_memo_create( - &self, - memo_id: &str, - notebook_id: &str, - ) -> std::io::Result { - let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let cutoff = chrono::Utc::now().timestamp_millis() - EXTERNAL_CREATE_MARKER_TTL_MS; - conn.query_row( - "SELECT 1 FROM pending_external_memo_creates WHERE memo_id = ?1 AND notebook_id = ?2 AND created_at >= ?3", - params![memo_id, notebook_id, cutoff], - |_| Ok(()), - ) - .optional() - .map(|row| row.is_some()) - .map_err(sqlite_to_io) - } - - pub fn clear_pending_external_memo_create(&self, memo_id: &str) -> std::io::Result<()> { - let conn = self.open_memo_index_db()?; - conn.execute( - "DELETE FROM pending_external_memo_creates WHERE memo_id = ?1", - params![memo_id], - ) - .map_err(sqlite_to_io)?; - Ok(()) - } - - /// Atomically records a stable content revision for a memo. - /// - /// Re-observing identical bytes returns the existing revision/change id. - /// Returning to an older hash after another commit is a new transition and - /// therefore advances the counter as well. - pub fn commit_memo_content_revision( - &self, - memo_id: &str, - notebook_id: &str, - content_hash: &str, - next_change_id: &str, - ) -> std::io::Result { - self.commit_memo_content_revision_internal( - memo_id, - notebook_id, - content_hash, - next_change_id, - None, - )? - .ok_or_else(|| std::io::Error::other("unconditional revision commit was rejected")) - } - - pub fn commit_memo_content_revision_if_current( - &self, - memo_id: &str, - notebook_id: &str, - content_hash: &str, - next_change_id: &str, - expected: Option<&MemoContentRevision>, - ) -> std::io::Result> { - self.commit_memo_content_revision_internal( - memo_id, - notebook_id, - content_hash, - next_change_id, - Some(expected), - ) - } - - fn commit_memo_content_revision_internal( - &self, - memo_id: &str, - notebook_id: &str, - content_hash: &str, - next_change_id: &str, - expected: Option>, - ) -> std::io::Result> { - let mut conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let tx = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .map_err(sqlite_to_io)?; - // Revision rows may be created before the corresponding Markdown file - // is indexed (for example while an external writer is committing a - // save). Keep the lifecycle anchor in the same local index so the FK - // remains valid without making the revision stream depend on a prior - // memo-list refresh. - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at) - VALUES (?1, ?2, '', 1, 0, ?3, ?3)", - params![memo_id, notebook_id, chrono::Utc::now().timestamp_millis()], - ) - .map_err(sqlite_to_io)?; - let current = tx - .query_row( - "SELECT content_hash, local_revision, change_id, updated_at - FROM memo_content_revisions WHERE memo_id = ?1", - params![memo_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - )) - }, - ) - .optional() - .map_err(sqlite_to_io)?; - - if let Some(expected) = expected { - let matches = match (expected, current.as_ref()) { - (None, None) => true, - (Some(expected), Some((hash, revision, change_id, _))) => { - expected.memo_id == memo_id - && expected.content_hash == *hash - && expected.revision == *revision - && expected.change_id == *change_id - } - _ => false, - }; - if !matches { - return Ok(None); - } - } - - if let Some((existing_hash, revision, change_id, updated_at)) = current.as_ref() { - if existing_hash == content_hash { - tx.commit().map_err(sqlite_to_io)?; - return Ok(Some(MemoContentCommit { - state: MemoContentRevision { - memo_id: memo_id.to_string(), - notebook_id: notebook_id.to_string(), - content_hash: existing_hash.clone(), - revision: *revision, - change_id: change_id.clone(), - updated_at: *updated_at, - }, - changed: false, - })); - } - } - - let revision = current - .as_ref() - .map(|(_, revision, _, _)| revision.saturating_add(1)) - .unwrap_or(1); - let updated_at = chrono::Utc::now().timestamp_millis(); - tx.execute( - r#" - INSERT INTO memo_content_revisions - (memo_id, notebook_id, content_hash, local_revision, change_id, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(memo_id) DO UPDATE SET - notebook_id = excluded.notebook_id, - content_hash = excluded.content_hash, - local_revision = excluded.local_revision, - change_id = excluded.change_id, - updated_at = excluded.updated_at - "#, - params![ - memo_id, - notebook_id, - content_hash, - revision, - next_change_id, - updated_at, - ], - ) - .map_err(sqlite_to_io)?; - tx.commit().map_err(sqlite_to_io)?; - - Ok(Some(MemoContentCommit { - state: MemoContentRevision { - memo_id: memo_id.to_string(), - notebook_id: notebook_id.to_string(), - content_hash: content_hash.to_string(), - revision, - change_id: next_change_id.to_string(), - updated_at, - }, - changed: true, - })) - } - - pub fn read_memo_content_revision( - &self, - memo_id: &str, - ) -> std::io::Result> { - for notebook in self.read_notebook_configs()? { - let revision = self.read_memo_content_revision_for_notebook(¬ebook.id, memo_id)?; - if revision.is_some() { - return Ok(revision); - } - } - Ok(None) - } - - /// Mutation callers already know the notebook. Avoid opening every local - /// database, especially for newly created notes with no revision yet. - pub fn read_memo_content_revision_for_notebook( - &self, - notebook_id: &str, - memo_id: &str, - ) -> std::io::Result> { - let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - conn.query_row( - "SELECT notebook_id, content_hash, local_revision, change_id, updated_at - FROM memo_content_revisions WHERE memo_id = ?1 AND notebook_id = ?2", - params![memo_id, notebook_id], - |row| { - Ok(MemoContentRevision { - memo_id: memo_id.to_string(), - notebook_id: row.get(0)?, - content_hash: row.get(1)?, - revision: row.get(2)?, - change_id: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ) - .optional() - .map_err(sqlite_to_io) - } - - /// Backfill the path-keyed revision table from the legacy ID-keyed one. - /// Runs only when the legacy tables exist (they are never created here, so - /// path-only notebooks stay free of them). Idempotent: existing path-keyed - /// rows (which may already be newer after a rename cascade) always win. - fn backfill_note_content_revisions(conn: &Connection) -> std::io::Result { - let legacy_present: bool = conn - .query_row( - "SELECT COUNT(*) = 2 FROM sqlite_master - WHERE type = 'table' AND name IN ('memo_content_revisions', 'memos')", - [], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - if !legacy_present { - return Ok(false); - } - let migrated = conn - .execute( - r#" - INSERT OR IGNORE INTO note_content_revisions - (notebook_id, relative_path, content_hash, local_revision, change_id, updated_at) - SELECT m.notebook_id, m.relative_path, r.content_hash, r.local_revision, r.change_id, r.updated_at - FROM memo_content_revisions r - JOIN memos m ON m.id = r.memo_id AND m.notebook_id = r.notebook_id - WHERE m.relative_path != '' - "#, - [], - ) - .map_err(sqlite_to_io)?; - Ok(migrated > 0) - } - /// Atomically records a stable content revision for one note, addressed by /// `(notebook_id, relative_path)`. /// @@ -1212,7 +265,6 @@ impl MemoFile { expected: Option>, ) -> std::io::Result> { let mut conn = self.open_note_index_connection(notebook_id)?; - Self::backfill_note_content_revisions(&conn)?; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(sqlite_to_io)?; @@ -1315,7 +367,6 @@ impl MemoFile { relative_path: &str, ) -> std::io::Result> { let conn = self.open_note_index_connection(notebook_id)?; - Self::backfill_note_content_revisions(&conn)?; conn.query_row( "SELECT content_hash, local_revision, change_id, updated_at FROM note_content_revisions WHERE notebook_id = ?1 AND relative_path = ?2", @@ -1348,7 +399,6 @@ impl MemoFile { return Ok(()); } let mut conn = self.open_note_index_connection(notebook_id)?; - Self::backfill_note_content_revisions(&conn)?; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(sqlite_to_io)?; diff --git a/app/flowix-core/src/memo_file/index_store/tags.rs b/app/flowix-core/src/memo_file/index_store/tags.rs index fe6e8740b..6a1d33888 100644 --- a/app/flowix-core/src/memo_file/index_store/tags.rs +++ b/app/flowix-core/src/memo_file/index_store/tags.rs @@ -19,65 +19,19 @@ impl MemoFile { notebook_id: Option<&str>, ) -> std::io::Result> { let notebook_id = self.notebook_id_for_index(notebook_id); - let conn = self.open_memo_index_db_for_notebook_id(¬ebook_id)?; - let mut stmt = conn - .prepare( - "SELECT path - FROM notebook_tags - WHERE notebook_id = ?1 - ORDER BY path COLLATE NOCASE ASC, path ASC", - ) - .map_err(sqlite_to_io)?; - let rows = stmt - .query_map(params![notebook_id], |row| row.get::<_, String>(0)) - .map_err(sqlite_to_io)?; - rows.collect::, _>>().map_err(sqlite_to_io) - } - - pub fn create_notebook_tag(&self, notebook_id: &str, path: &str) -> std::io::Result { - let path = super::super::derivation::normalize_tag_path(path).ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("invalid tag path: {path}"), - ) - })?; - if self.get_notebook_config_by_id(notebook_id).is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("notebook not found: {notebook_id}"), - )); - } - let mut conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - let now = chrono::Utc::now().timestamp_millis(); - let exists = conn - .query_row( - "SELECT 1 FROM notebook_tags - WHERE notebook_id = ?1 AND path = ?2 - LIMIT 1", - params![notebook_id, path], - |_| Ok(true), - ) - .optional() - .map_err(sqlite_to_io)? - .unwrap_or(false); - if exists { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("tag already exists in notebook: {path}"), - )); - } - let tx = conn.transaction().map_err(sqlite_to_io)?; - for prefix in tag_path_prefixes(&path) { - tx.execute( - "INSERT OR IGNORE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - VALUES (?1, ?2, ?3, ?3)", - params![notebook_id, prefix, now], - ) - .map_err(sqlite_to_io)?; + let used = self.read_used_tag_ids_for_notebook_id(Some(¬ebook_id))?; + let mut paths = std::collections::BTreeSet::new(); + for tag in used { + let mut prefix = String::new(); + for segment in tag.split('/') { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(segment); + paths.insert(prefix.clone()); + } } - tx.commit().map_err(sqlite_to_io)?; - Ok(path) + Ok(paths.into_iter().collect()) } pub fn read_used_tag_ids_for_notebook_id( @@ -98,27 +52,24 @@ impl MemoFile { ) -> std::io::Result { let notebook_id = self.notebook_id_for_index(notebook_id); if !self.note_index_is_ready(¬ebook_id)? { - // Cold start: rebuild the rebuildable projection instead of - // reading the legacy ID-keyed cache. The summary is an active - // request, so wait for the maintenance lock rather than skipping. - let _ = self.reconcile_note_index_blocking(¬ebook_id); + return Err(std::io::Error::new(std::io::ErrorKind::WouldBlock, "Note index is not ready")); } self.note_tag_usage_summary(¬ebook_id) } - /// 路径式 tag 的 prefix → 去重 memo 数。每个真实 tag `T` 拆出 + /// 路径式 tag 的 prefix → 去重笔记数。每个真实 tag `T` 拆出 /// 所有前缀 (`T` 自身 + `T` 的每级祖先 fullPath), 然后每个 prefix - /// 收集所有"挂载了 T 的 memo id", 取 set 长度 (即有任意 tag - /// 落在 prefix 之下的 distinct memo 数)。 + /// 收集所有"挂载了 T 的笔记路径", 取 set 长度 (即有任意 tag + /// 落在 prefix 之下的不同笔记数)。 /// - /// **为什么需要**: 之前侧栏 tree 用 `tagCounts` 累加, 一个 memo + /// **为什么需要**: 之前侧栏 tree 用 `tagCounts` 累加, 一篇笔记 /// 既有 `#中国/湖南` 又有 `#中国/广东` 会被 `中国` 节点算两次。 - /// 现在用 distinct memo_id, 1 个 memo 即使挂了多个子 tag, 父节点 + /// 现在按 distinct note path 去重;1 篇笔记即使挂了多个子 tag,父节点 /// 也只算 1。 /// - /// O(N×L) where N = (tag, memo) pairs, L = 平均路径深度。典型 - /// 库 (10K memos × 3 tags × depth 2) ~ 60K HashMap insert, 远低于 + /// O(N×L) where N = (tag, note) pairs, L = 平均路径深度。典型 + /// 库 (10K notes × 3 tags × depth 2) ~ 60K HashMap insert, 远低于 /// 1ms, 不需要 SQL 聚合优化。 pub fn read_tag_prefix_counts_for_notebook_id( &self, @@ -127,27 +78,10 @@ impl MemoFile { use std::collections::{HashMap, HashSet}; let notebook_id = self.notebook_id_for_index(notebook_id); - let pairs: Vec<(String, String)> = if self.note_index_is_ready(¬ebook_id)? { - self.note_tag_path_pairs(¬ebook_id)? - } else { - let conn = self.open_memo_index_db_for_notebook_id(¬ebook_id)?; - let mut stmt = conn - .prepare( - "SELECT mt.tag, mt.memo_id - FROM memo_tags mt - JOIN memos m ON m.id = mt.memo_id - WHERE m.notebook_id = ?1", - ) - .map_err(sqlite_to_io)?; - let collected = stmt - .query_map(rusqlite::params![¬ebook_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(sqlite_to_io)? - .collect::, _>>() - .map_err(sqlite_to_io)?; - collected - }; + if !self.note_index_is_ready(¬ebook_id)? { + return Err(std::io::Error::new(std::io::ErrorKind::WouldBlock, "Note index is not ready")); + } + let pairs = self.note_tag_path_pairs(¬ebook_id)?; let mut prefix_to_memos: HashMap> = HashMap::new(); for (tag, note_key) in &pairs { diff --git a/app/flowix-core/src/memo_file/internal_migration.rs b/app/flowix-core/src/memo_file/internal_migration.rs index 55b7afd9e..98576efb8 100644 --- a/app/flowix-core/src/memo_file/internal_migration.rs +++ b/app/flowix-core/src/memo_file/internal_migration.rs @@ -29,6 +29,29 @@ impl NotebookInternalMigrationReport { } impl MemoFile { + /// Whether a registered notebook still has the old plugin artifact paths. + /// New notebooks use `.flowix/plugin` from creation and do not need this + /// migration. + pub fn has_legacy_notebook_internal_data(&self, notebook_id: &str) -> io::Result { + let base = self + .get_notebook_config_by_id(notebook_id) + .map(|config| PathBuf::from(config.path)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("notebook not found: {notebook_id}"), + ) + })?; + if !base.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("notebook directory missing: {}", base.display()), + )); + } + Ok(has_legacy_content(&base.join(".plugin-output"))? + || has_legacy_content(&base.join(".flowix/artifacts"))?) + } + /// Move legacy notebook-local data to `.flowix/`. /// /// This method is safe to call on every notebook open/switch. The marker @@ -108,6 +131,18 @@ impl MemoFile { } } +fn has_legacy_content(path: &Path) -> io::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Ok(true); + } + Ok(fs::read_dir(path)?.next().is_some()) +} + fn migrate_legacy_artifacts( source: &Path, plugin_root: &Path, diff --git a/app/flowix-core/src/memo_file/local_migrations.rs b/app/flowix-core/src/memo_file/local_migrations.rs new file mode 100644 index 000000000..993f6cd50 --- /dev/null +++ b/app/flowix-core/src/memo_file/local_migrations.rs @@ -0,0 +1,61 @@ +//! Shared device-local migration journal. Both historical journal layouts are accepted. +use rusqlite::Connection; +use std::io; + +pub fn initialize_local_migration_table(conn: &mut Connection) -> io::Result<()> { + let columns = { + let mut statement = conn + .prepare("PRAGMA table_info(local_migrations)") + .map_err(io::Error::other)?; + let rows = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(io::Error::other)?; + rows.collect::, _>>() + .map_err(io::Error::other)? + }; + if columns.is_empty() { + conn.execute_batch("CREATE TABLE local_migrations (migration_key TEXT PRIMARY KEY, completed_at INTEGER NOT NULL DEFAULT 0);").map_err(io::Error::other)?; + } else if columns.iter().any(|name| name == "key") + && !columns.iter().any(|name| name == "migration_key") + { + let tx = conn.transaction().map_err(io::Error::other)?; + tx.execute_batch("ALTER TABLE local_migrations RENAME TO local_migrations_old; + CREATE TABLE local_migrations (migration_key TEXT PRIMARY KEY, completed_at INTEGER NOT NULL DEFAULT 0); + INSERT INTO local_migrations (migration_key, completed_at) SELECT key, 0 FROM local_migrations_old; + DROP TABLE local_migrations_old;").map_err(io::Error::other)?; + tx.commit().map_err(io::Error::other)?; + } else if !columns.iter().any(|name| name == "migration_key") + || !columns.iter().any(|name| name == "completed_at") + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported local_migrations schema", + )); + } + Ok(()) +} + +/// Notebook jobs shared by execution and startup pending detection. +#[derive(Clone, Copy)] +pub enum NotebookInitializationJob { + ResetLegacyStorage, + InitializeSchema, + MigrateMediaResourceIdentity, +} + +impl NotebookInitializationJob { + pub const ALL: [Self; 3] = [ + Self::ResetLegacyStorage, + Self::InitializeSchema, + Self::MigrateMediaResourceIdentity, + ]; + + pub fn key(self, notebook_id: &str) -> String { + let prefix = match self { + Self::ResetLegacyStorage => "storage_reset_v1", + Self::InitializeSchema => "storage_schema_v1", + Self::MigrateMediaResourceIdentity => "media_resource_identity_v1", + }; + format!("{prefix}:notebook:{notebook_id}") + } +} diff --git a/app/flowix-core/src/memo_file/media_resource.rs b/app/flowix-core/src/memo_file/media_resource.rs index ad3f95d6a..ac2d8b5c4 100644 --- a/app/flowix-core/src/memo_file/media_resource.rs +++ b/app/flowix-core/src/memo_file/media_resource.rs @@ -3,7 +3,7 @@ //! Media files remain on disk, while their user properties and resource //! identity are stored in the notebook-local database at //! `/.flowix/notebook.db`. It is deliberately separate from the -//! global notebook registry database (`~/.flowix/index.db`). +//! global notebook registry database (`~/.flowix/db/local.db`). use std::collections::{HashMap, HashSet}; use std::fs; @@ -64,6 +64,28 @@ struct MediaResourceRecord { const MEDIA_MISSING_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1000; +/// Normalize a notebook-relative media path and derive its stable resource ID. +/// The path is deliberately part of identity: a rename produces a new ID. +pub fn media_resource_id(notebook_id: &str, relative_path: &str) -> std::io::Result { + let normalized = normalize_media_relative_path(relative_path)?; + if notebook_id.is_empty() || notebook_id.contains("::") || notebook_id.contains('\0') { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid notebook id")); + } + Ok(format!("{notebook_id}::{normalized}")) +} + +fn normalize_media_relative_path(relative_path: &str) -> std::io::Result { + let normalized = relative_path.replace('\\', "/"); + if normalized.is_empty() + || normalized.starts_with('/') + || normalized.contains('\0') + || normalized.split('/').any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid media relative path")); + } + Ok(normalized) +} + fn now_ms() -> i64 { chrono::Utc::now().timestamp_millis() } @@ -158,6 +180,35 @@ pub fn media_kind_for_path(path: &Path) -> Option { impl MemoFile { pub(crate) fn open_notebook_db(&self, notebook_id: &str) -> std::io::Result { + self.open_notebook_db_inner(notebook_id, false) + } + + /// Upgrade the notebook media catalog during version initialization. + pub fn initialize_media_resource_schema(&self, notebook_id: &str) -> std::io::Result<()> { + self.open_notebook_db_inner(notebook_id, true)?; + Ok(()) + } + + fn open_notebook_db_inner( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> std::io::Result { + let path = self.notebook_db_path(notebook_id)?; + if run_legacy_migrations { + super::with_database_rebuild(&path, || { + self.open_notebook_db_unchecked(notebook_id, true) + }) + } else { + self.open_notebook_db_unchecked(notebook_id, false) + } + } + + fn open_notebook_db_unchecked( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> std::io::Result { let path = self.notebook_db_path(notebook_id)?; let mut conn = Connection::open(path).map_err(sqlite_to_io)?; conn.busy_timeout(std::time::Duration::from_secs(10)) @@ -196,6 +247,9 @@ impl MemoFile { "#, ) .map_err(sqlite_to_io)?; + if !run_legacy_migrations { + return Ok(conn); + } let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(sqlite_to_io)?; @@ -406,6 +460,8 @@ impl MemoFile { kind: MediaResourceKind, absolute_path: &Path, ) -> std::io::Result { + let relative_path = normalize_media_relative_path(relative_path)?; + let id = media_resource_id(notebook_id, &relative_path)?; let metadata = fs::metadata(absolute_path)?; if !metadata.is_file() { return Err(std::io::Error::new( @@ -427,10 +483,10 @@ impl MemoFile { .optional() .map_err(sqlite_to_io)? .is_none(); - let mut existing = self.read_media_resource_record(notebook_id, relative_path)?; + let mut existing = self.read_media_resource_record(notebook_id, &relative_path)?; - // Preserve the resource id and its database properties when a file was - // renamed outside Flowix. Only associate an old row when its previous + // Preserve database properties when a file was renamed outside Flowix. + // Only associate an old row when its previous // media path is gone and the fingerprint is unique; identical copies // must not steal one another's metadata. if existing.is_none() { @@ -456,20 +512,16 @@ impl MemoFile { .map_err(std::io::Error::other)?; if !old_absolute.is_file() { conn.execute( - "UPDATE media_resources SET relative_path = ?1, updated_at = ?2, + "UPDATE media_resources SET id = ?1, relative_path = ?2, updated_at = ?3, missing_since = NULL, deleted_at = NULL - WHERE notebook_id = ?3 AND id = ?4", - params![relative_path, now_ms(), notebook_id, candidate_id], + WHERE notebook_id = ?4 AND id = ?5", + params![id, relative_path, now_ms(), notebook_id, candidate_id], ) .map_err(sqlite_to_io)?; - existing = self.read_media_resource_record(notebook_id, relative_path)?; + existing = self.read_media_resource_record(notebook_id, &relative_path)?; } } } - let id = existing - .as_ref() - .map(|record| record.resource.id.clone()) - .unwrap_or_else(|| format!("media_{}", uuid::Uuid::now_v7())); let created_at = existing .as_ref() .map(|record| record.resource.created_at) @@ -502,6 +554,7 @@ impl MemoFile { created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, NULL, NULL, ?9, ?10) ON CONFLICT(notebook_id, relative_path) DO UPDATE SET + id = excluded.id, kind = excluded.kind, size_bytes = excluded.size_bytes, modified_ms = excluded.modified_ms, @@ -527,7 +580,7 @@ impl MemoFile { Ok(MediaResource { id, notebook_id: notebook_id.to_string(), - relative_path: relative_path.to_string(), + relative_path: relative_path.clone(), kind, size_bytes, modified_ms: modified, @@ -709,7 +762,8 @@ impl MemoFile { Ok(()) } - /// Preserve identity for a confirmed same-notebook filesystem rename. + /// Move a catalog row after a confirmed same-notebook filesystem rename. + /// The resource ID changes with the path while user properties remain on the row. /// Returns false when the old path is unknown or the destination already /// has a catalog record; callers can then use ordinary path refresh. pub fn move_media_resource_path( @@ -718,6 +772,8 @@ impl MemoFile { old_relative_path: &str, new_relative_path: &str, ) -> std::io::Result { + let old_relative_path = normalize_media_relative_path(old_relative_path)?; + let new_relative_path = normalize_media_relative_path(new_relative_path)?; if old_relative_path == new_relative_path { return Ok(false); } @@ -725,12 +781,12 @@ impl MemoFile { .memo_base_for_notebook_id_result(notebook_id) .map_err(std::io::Error::other)?; let policy = self.file_management_policy(notebook_id); - if policy.is_ignored_at(&root, Path::new(old_relative_path)) - || policy.is_ignored_at(&root, Path::new(new_relative_path)) + if policy.is_ignored_at(&root, Path::new(&old_relative_path)) + || policy.is_ignored_at(&root, Path::new(&new_relative_path)) { return Ok(false); } - let new_absolute = super::notebook_path_from_relative(&root, new_relative_path) + let new_absolute = super::notebook_path_from_relative(&root, &new_relative_path) .map_err(std::io::Error::other)?; let metadata = fs::symlink_metadata(&new_absolute)?; if metadata.file_type().is_symlink() || !metadata.is_file() @@ -747,6 +803,7 @@ impl MemoFile { let size = metadata.len(); let modified = modified_ms(&new_absolute); let fingerprint = stable_fingerprint(&new_absolute, size); + let new_id = media_resource_id(notebook_id, &new_relative_path)?; let mut conn = self.open_notebook_db(notebook_id)?; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) @@ -765,11 +822,12 @@ impl MemoFile { } let changed = tx .execute( - "UPDATE media_resources SET relative_path=?1, kind=?2, size_bytes=?3, - modified_ms=?4, fingerprint=?5, missing_since=NULL, deleted_at=NULL, - updated_at=?6 - WHERE notebook_id=?7 AND relative_path=?8", + "UPDATE media_resources SET id=?1, relative_path=?2, kind=?3, size_bytes=?4, + modified_ms=?5, fingerprint=?6, missing_since=NULL, deleted_at=NULL, + updated_at=?7 + WHERE notebook_id=?8 AND relative_path=?9", params![ + new_id, new_relative_path, kind.as_str(), size as i64, @@ -785,10 +843,108 @@ impl MemoFile { Ok(changed > 0) } + /// Replace historical generated media IDs and normalize media library + /// references. Database and document updates are idempotent so startup can + /// safely retry after an interrupted run. + pub fn migrate_media_resource_identities(&self, notebook_id: &str) -> std::io::Result { + let root = self + .memo_base_for_notebook_id_result(notebook_id) + .map_err(std::io::Error::other)?; + let mut conn = self.open_notebook_db(notebook_id)?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sqlite_to_io)?; + let rows = { + let mut statement = tx + .prepare("SELECT id, relative_path FROM media_resources WHERE notebook_id=?1 ORDER BY relative_path") + .map_err(sqlite_to_io)?; + let rows = statement + .query_map([notebook_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + let mut updates = Vec::new(); + let mut targets = HashSet::new(); + for (old_id, old_path) in rows { + let relative_path = normalize_media_relative_path(&old_path)?; + let id = media_resource_id(notebook_id, &relative_path)?; + if !targets.insert(id.clone()) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "duplicate canonical media resource identity")); + } + if old_id != id || old_path != relative_path { + let temporary_id = format!("__flowix_media_migration__{}", uuid::Uuid::now_v7().simple()); + updates.push((old_id, temporary_id, id, relative_path)); + } + } + for (old_id, temporary_id, _, _) in &updates { + tx.execute( + "UPDATE media_resources SET id=?1 WHERE notebook_id=?2 AND id=?3", + params![temporary_id, notebook_id, old_id], + ).map_err(sqlite_to_io)?; + } + for (_, temporary_id, id, relative_path) in &updates { + tx.execute( + "UPDATE media_resources SET id=?1, relative_path=?2 WHERE notebook_id=?3 AND id=?4", + params![id, relative_path, notebook_id, temporary_id], + ).map_err(sqlite_to_io)?; + } + tx.commit().map_err(sqlite_to_io)?; + drop(conn); + + let mut changed = updates.len(); + let walker = walkdir::WalkDir::new(&root).follow_links(false).into_iter().filter_entry(|entry| { + if entry.depth() == 0 || !entry.file_type().is_dir() { return true; } + let name = entry.file_name().to_string_lossy(); + if name == ".flowix" || name == ".plugin-output" { return false; } + let internal = entry.path().join(".flowix"); + !(internal.join("notebook.json").is_file() || internal.join("notebook.db").is_file()) + }); + for entry in walker { + let entry = entry.map_err(std::io::Error::other)?; + if !entry.file_type().is_file() { continue; } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if !(name == ".index.yaml" || name.ends_with(".lib.yaml") || name.ends_with(".lib.yml")) { continue; } + let path = entry.path(); + let source = fs::read_to_string(path)?; + let Ok(mut document) = crate::collection::CollectionDocument::parse(&source) else { + // Do not block startup on a damaged collection. The explicit + // collection recovery flow rewrites its media IDs when repaired. + tracing::warn!(path = %path.display(), "skip malformed collection during media identity migration"); + continue; + }; + if document.collection.kind != crate::collection::CollectionType::MediaLibrary { continue; } + document = match document.normalized_v2() { + Ok(document) => document, + Err(error) => { + tracing::warn!(path = %path.display(), %error, + "skip invalid collection during media identity migration"); + continue; + } + }; + let changed_ids = document + .normalize_media_resource_ids(notebook_id) + .map_err(std::io::Error::other)?; + if !changed_ids { continue; } + document.revise().map_err(std::io::Error::other)?; + let next = document.serialize().map_err(std::io::Error::other)?; + match self.write_file_if_matches_for_notebook(notebook_id, path, &next, Some(&source))? { + super::FileWriteOutcome::Saved => changed += 1, + super::FileWriteOutcome::Conflict { .. } => { + return Err(std::io::Error::new(std::io::ErrorKind::WouldBlock, format!("media library changed during identity migration: {}", path.display()))); + } + } + } + Ok(changed) + } + /// Rebuild the media catalog from notebook files. The media file remains /// on disk, while resource properties are restored from the notebook /// database and are not regenerated from sibling files. pub fn reconcile_media_resources(&self, notebook_id: &str) -> std::io::Result { + self.reconcile_media_resources_background(notebook_id, &|| true) + } + + pub fn reconcile_media_resources_background( + &self, notebook_id: &str, keep_running: &dyn Fn() -> bool, + ) -> std::io::Result { let root = self .memo_base_for_notebook_id_result(notebook_id) .map_err(std::io::Error::other)?; @@ -829,11 +985,12 @@ impl MemoFile { .is_ok_and(|relative| relative.as_os_str().is_empty() || !policy.is_ignored_at(&root, relative)) }) { + if !keep_running() { return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); } let entry = match entry { Ok(entry) => entry, Err(error) => { - tracing::debug!(notebook = %notebook_id, error = %error, "skip unreadable media index entry"); - continue; + // An incomplete directory walk is not evidence of deletion. + return Err(std::io::Error::other(error)); } }; if !entry.file_type().is_file() { @@ -888,17 +1045,32 @@ impl MemoFile { drop(statement); let now = now_ms(); for (id, relative, missing_since, deleted_at) in stale { + if !keep_running() { return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); } + if !root.is_dir() { return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "notebook unavailable")); } if !seen.contains(&relative) { if policy.is_ignored_at(&root, Path::new(&relative)) { continue; } + // A file can reappear after its directory was enumerated. A + // stale scan must not retire its catalog entry or user properties. + let candidate = super::notebook_path_from_relative(&root, &relative) + .map_err(std::io::Error::other)?; + match fs::symlink_metadata(&candidate) { + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + if !root.is_dir() { + return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "notebook unavailable")); + } let first_missing_at = missing_since.unwrap_or(now); conn.execute( "UPDATE media_resources SET missing_since = COALESCE(missing_since, ?1), updated_at = CASE WHEN missing_since IS NULL THEN ?1 ELSE updated_at END - WHERE notebook_id = ?2 AND id = ?3", - params![first_missing_at, notebook_id, id], + WHERE notebook_id = ?2 AND id = ?3 AND relative_path = ?4 + AND missing_since IS ?5 AND deleted_at IS ?6", + params![first_missing_at, notebook_id, id, relative, missing_since, deleted_at], ) .map_err(sqlite_to_io)?; if first_missing_at < now - MEDIA_MISSING_RETENTION_MS @@ -907,8 +1079,9 @@ impl MemoFile { { conn.execute( "DELETE FROM media_resources - WHERE notebook_id = ?1 AND id = ?2", - params![notebook_id, id], + WHERE notebook_id = ?1 AND id = ?2 AND relative_path = ?3 + AND missing_since = ?4 AND deleted_at IS ?5", + params![notebook_id, id, relative, first_missing_at, deleted_at], ) .map_err(sqlite_to_io)?; } @@ -930,7 +1103,85 @@ mod tests { use super::*; #[test] - fn directory_index_rebase_preserves_media_identity_and_note_projections_and_is_repeatable() { + fn background_note_preparation_preserves_shared_media_properties() { + let (memo, _temp, root) = fixture(); + fs::write(root.join("kept.png"), b"image").unwrap(); + fs::write(root.join("note.md"), "# Note").unwrap(); + let media = memo.ensure_media_resource("nb_media_test", "kept.png", MediaResourceKind::Image, &root.join("kept.png")).unwrap(); + memo.update_media_resource_properties("nb_media_test", &media.id, &serde_json::json!({"caption":"user data"})).unwrap(); + memo.prepare_background_note_projection("nb_media_test").unwrap(); + memo.reconcile_note_index_background("nb_media_test", &|| true).unwrap(); + let after = memo.read_media_resource("nb_media_test", "kept.png").unwrap().unwrap(); + assert_eq!(after.id, media.id); + assert_eq!(after.properties["caption"], "user data"); + } + + #[test] + fn media_reappearing_after_enumeration_keeps_its_catalog_entry() { + let (memo, _temp, root) = fixture(); + let path = root.join("returned.png"); + fs::write(&path, b"image").unwrap(); + memo.reconcile_media_resources("nb_media_test").unwrap(); + let before = memo.read_media_resource("nb_media_test", "returned.png").unwrap().unwrap(); + fs::remove_file(&path).unwrap(); + let checks = std::cell::Cell::new(0); + memo.reconcile_media_resources_background("nb_media_test", &|| { + checks.set(checks.get() + 1); + // The empty root is visited first, then the stale catalog row. + if checks.get() == 2 { fs::write(&path, b"image").unwrap(); } + true + }).unwrap(); + assert!(path.is_file()); + let conn = memo.open_notebook_db("nb_media_test").unwrap(); + let missing: Option = conn.query_row("SELECT missing_since FROM media_resources WHERE id=?1", [&before.id], |row| row.get(0)).unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn stale_media_cleanup_cannot_delete_a_concurrently_restored_catalog_row() { + let (memo, _temp, root) = fixture(); + let old_path = root.join("old.png"); + fs::write(&old_path, b"image").unwrap(); + memo.reconcile_media_resources("nb_media_test").unwrap(); + let before = memo.read_media_resource("nb_media_test", "old.png").unwrap().unwrap(); + let conn = memo.open_notebook_db("nb_media_test").unwrap(); + conn.execute("UPDATE media_resources SET missing_since=1, deleted_at=1 WHERE id=?1", [&before.id]).unwrap(); + fs::remove_file(&old_path).unwrap(); + let checks = std::cell::Cell::new(0); + memo.reconcile_media_resources_background("nb_media_test", &|| { + checks.set(checks.get() + 1); + if checks.get() == 2 { + fs::write(root.join("new.png"), b"image").unwrap(); + conn.execute( + "UPDATE media_resources SET id=?1, relative_path='new.png', missing_since=NULL, deleted_at=NULL WHERE id=?2", + params![media_resource_id("nb_media_test", "new.png").unwrap(), before.id], + ).unwrap(); + } + true + }).unwrap(); + let after = memo.read_media_resource("nb_media_test", "new.png").unwrap().unwrap(); + assert_eq!(after.id, media_resource_id("nb_media_test", "new.png").unwrap()); + assert_ne!(after.id, before.id); + let missing: Option = conn.query_row("SELECT missing_since FROM media_resources WHERE id=?1", [&after.id], |row| row.get(0)).unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn cancelled_media_scan_never_marks_unvisited_resources_missing() { + let (memo, _temp, root) = fixture(); + fs::write(root.join("kept.png"), b"image").unwrap(); + memo.reconcile_media_resources("nb_media_test").unwrap(); + let before = memo.read_media_resource("nb_media_test", "kept.png").unwrap().unwrap(); + assert_eq!(memo.reconcile_media_resources_background("nb_media_test", &|| false).unwrap_err().kind(), std::io::ErrorKind::Interrupted); + let after = memo.read_media_resource("nb_media_test", "kept.png").unwrap().unwrap(); + assert_eq!(before.id, after.id); + let conn = memo.open_notebook_db("nb_media_test").unwrap(); + let missing: Option = conn.query_row("SELECT missing_since FROM media_resources WHERE id=?1", [&before.id], |row| row.get(0)).unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn directory_index_rebase_updates_media_path_identity_and_preserves_projections() { let (memo, _temp, root) = fixture(); fs::create_dir(root.join("Old")).unwrap(); fs::create_dir(root.join("Oldish")).unwrap(); @@ -943,7 +1194,7 @@ mod tests { let before = memo.read_media_resource("nb_media_test", "Old/photo.png").unwrap().unwrap(); memo.update_media_resource_properties("nb_media_test", &before.id, &serde_json::json!({"caption":"keep"})).unwrap(); let conn = memo.open_note_index_connection("nb_media_test").unwrap(); - conn.execute("INSERT INTO note_tags(relative_path,tag) VALUES('Old/note.md','keep')", []).unwrap(); + conn.execute("UPDATE notes SET tags='[\"keep\"]' WHERE relative_path='Old/note.md'", []).unwrap(); conn.execute("UPDATE notes SET created_at=42 WHERE relative_path='Old/note.md'", []).unwrap(); drop(conn); fs::rename(root.join("Old"), root.join("New")).unwrap(); @@ -952,14 +1203,15 @@ mod tests { memo.rebase_directory_indexes("nb_media_test", "Old", "New")).unwrap(); } let after = memo.read_media_resource("nb_media_test", "New/photo.png").unwrap().unwrap(); - assert_eq!(after.id, before.id); + assert_eq!(after.id, media_resource_id("nb_media_test", "New/photo.png").unwrap()); + assert_ne!(after.id, before.id); assert_eq!(after.fingerprint, before.fingerprint); assert_eq!(after.properties["caption"], "keep"); assert!(memo.read_media_resource("nb_media_test", "Old/photo.png").unwrap().is_none()); assert!(memo.read_media_resource("nb_media_test", "Oldish/keep.png").unwrap().is_some()); let conn = memo.open_note_index_connection("nb_media_test").unwrap(); assert_eq!(conn.query_row("SELECT created_at FROM notes WHERE relative_path='New/note.md'", [], |row| row.get::<_, i64>(0)).unwrap(), 42); - assert_eq!(conn.query_row("SELECT tag FROM note_tags WHERE relative_path='New/note.md'", [], |row| row.get::<_, String>(0)).unwrap(), "keep"); + assert_eq!(conn.query_row("SELECT tags FROM notes WHERE relative_path='New/note.md'", [], |row| row.get::<_, String>(0)).unwrap(), "[\"keep\"]"); assert_eq!(conn.query_row("SELECT COUNT(*) FROM notes WHERE relative_path='Old/note.md'", [], |row| row.get::<_, i64>(0)).unwrap(), 0); } @@ -982,7 +1234,8 @@ mod tests { memo_file.refresh_media_resource_path("nb_media_test", "old.png").unwrap(); let renamed = memo_file.read_media_resource("nb_media_test", "new.png") .unwrap().unwrap(); - assert_eq!(renamed.id, resource.id); + assert_eq!(renamed.id, media_resource_id("nb_media_test", "new.png").unwrap()); + assert_ne!(renamed.id, resource.id); assert_eq!(renamed.properties, properties); fs::remove_file(&new).unwrap(); @@ -1011,6 +1264,112 @@ mod tests { (memo_file, temp, root) } + fn media_library_source(include_path: &str, exclude_path: &str) -> String { + serde_yaml::to_string(&serde_json::json!({ + "format": "flowix.collection", + "schema_version": 2, + "collection": { + "id": format!("col_{}", "a".repeat(32)), + "type": "media_library", + "revision": 4, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "properties": {} + }, + "payload": { + "schema_version": 2, + "dataset": { + "source": {"scope": "notebook", "depth": "descendants"}, + "schema": {"fields": []}, + "records": { + "include": [{"resource_id": "media_old_photo", "relative_path": include_path}], + "exclude": [{"resource_id": "media_old_excluded", "relative_path": exclude_path}] + }, + "filter": null + }, + "views": { + "default_id": format!("view_{}", "b".repeat(32)), + "items": [{ + "id": format!("view_{}", "b".repeat(32)), + "name": "All media", + "layout": "waterfall", + "filter": {"all": [], "any": []}, + "sort": [{"field": "created_at", "direction": "desc"}], + "config": {} + }] + } + } + })) + .unwrap() + } + + #[test] + fn media_identity_migration_rewrites_database_and_collection_references_idempotently() { + let (memo, _temp, root) = fixture(); + let photo = root.join("photo.png"); + fs::write(&photo, b"image").unwrap(); + let resource = memo.ensure_media_resource( + "nb_media_test", "photo.png", MediaResourceKind::Image, &photo, + ).unwrap(); + memo.update_media_resource_properties( + "nb_media_test", &resource.id, &serde_json::json!({"caption": "keep me"}), + ).unwrap(); + let conn = memo.open_notebook_db("nb_media_test").unwrap(); + conn.execute( + "UPDATE media_resources SET id='media_old_photo' WHERE notebook_id='nb_media_test' AND relative_path='photo.png'", + [], + ).unwrap(); + drop(conn); + + let collection_path = root.join("Library.lib.yaml"); + fs::write(&collection_path, media_library_source("photo.png", "removed.png")).unwrap(); + assert_eq!(memo.migrate_media_resource_identities("nb_media_test").unwrap(), 2); + + let canonical_photo_id = media_resource_id("nb_media_test", "photo.png").unwrap(); + let migrated = memo.read_media_resource("nb_media_test", "photo.png").unwrap().unwrap(); + assert_eq!(migrated.id, canonical_photo_id); + assert_eq!(migrated.properties["caption"], "keep me"); + + let source = fs::read_to_string(&collection_path).unwrap(); + let document = crate::collection::CollectionDocument::parse(&source).unwrap(); + assert_eq!(document.payload["dataset"]["records"]["include"][0]["resource_id"], canonical_photo_id); + assert_eq!(document.payload["dataset"]["records"]["exclude"][0]["resource_id"], media_resource_id("nb_media_test", "removed.png").unwrap()); + assert_eq!(document.collection.revision, 5); + assert_eq!(memo.migrate_media_resource_identities("nb_media_test").unwrap(), 0); + } + + #[test] + fn media_identity_migration_skips_semantically_invalid_collections_and_continues() { + let (memo, _temp, root) = fixture(); + let photo = root.join("photo.png"); + fs::write(&photo, b"image").unwrap(); + memo.ensure_media_resource( + "nb_media_test", "photo.png", MediaResourceKind::Image, &photo, + ).unwrap(); + let conn = memo.open_notebook_db("nb_media_test").unwrap(); + conn.execute( + "UPDATE media_resources SET id='media_old_photo' WHERE notebook_id='nb_media_test' AND relative_path='photo.png'", + [], + ).unwrap(); + let stored_id: String = conn.query_row( + "SELECT id FROM media_resources WHERE notebook_id='nb_media_test' AND relative_path='photo.png'", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(stored_id, "media_old_photo"); + drop(conn); + + let invalid_path = root.join("Broken.lib.yaml"); + let invalid_source = media_library_source("../photo.png", "removed.png"); + fs::write(&invalid_path, &invalid_source).unwrap(); + assert_eq!(memo.migrate_media_resource_identities("nb_media_test").unwrap(), 1); + assert_eq!( + memo.read_media_resource("nb_media_test", "photo.png").unwrap().unwrap().id, + media_resource_id("nb_media_test", "photo.png").unwrap(), + ); + assert_eq!(fs::read_to_string(invalid_path).unwrap(), invalid_source); + } + #[test] fn media_kind_classifies_supported_files_only() { assert_eq!( @@ -1321,7 +1680,7 @@ mod tests { } #[test] - fn renamed_media_keeps_its_id_and_database_properties() { + fn renamed_media_changes_its_path_id_and_keeps_database_properties() { let (memo_file, _temp, root) = fixture(); let old_media = root.join("old.png"); let new_media = root.join("new.png"); @@ -1349,7 +1708,8 @@ mod tests { ) .unwrap(); - assert_eq!(new_resource.id, old_resource.id); + assert_eq!(new_resource.id, media_resource_id("nb_media_test", "new.png").unwrap()); + assert_ne!(new_resource.id, old_resource.id); assert_eq!(new_resource.properties, properties); assert!(!MemoFile::legacy_media_properties_path(&new_media).exists()); assert!(!MemoFile::legacy_media_properties_path(&old_media).exists()); diff --git a/app/flowix-core/src/memo_file/mod.rs b/app/flowix-core/src/memo_file/mod.rs index a1a53c75d..3169f1c8f 100644 --- a/app/flowix-core/src/memo_file/mod.rs +++ b/app/flowix-core/src/memo_file/mod.rs @@ -1,16 +1,15 @@ //! Notebook storage and Markdown file operations. //! -//! Markdown files own note content. The rebuildable path projection in each -//! notebook's `.flowix/notebook.db` addresses notes by relative path. Legacy -//! memo-ID records and APIs remain for callers that have not migrated yet; -//! new Note writes do not stamp those IDs into Markdown frontmatter or filenames. +//! Markdown files own note content. Rebuildable projections address notes by +//! notebook-relative path; note identity is never generated from or parsed +//! out of Markdown frontmatter. //! //! - `frontmatter` parses and edits authored YAML metadata. //! - `note_index` maintains the path-keyed Note projection and path operations; //! it discards the old rebuildable `v2_*` projection schema on first use. -//! - `index_store` maintains legacy memo-ID records and related metadata. -//! - `ops` contains legacy CRUD and reconciliation operations. -//! - `versions` stores history currently keyed by internal memo ID. +//! - `index_store` stores content-derived listing fields. +//! - `ops` contains CRUD and reconciliation operations. +//! - `versions` stores history keyed by notebook-relative path. //! //! `MemoIndexEntry::filename` contains the disk filename, including `.md`. //! Nested notes also carry a notebook-relative path; resolve that path against @@ -19,23 +18,16 @@ use std::path::PathBuf; use std::{fs::OpenOptions, io}; -/// memo id 随机段使用的字符集 — `[0-9a-z]` 36 个字符 (小写字母 + 数字)。 -/// -/// nanoid's default alphabet includes `_` and `-`; legacy memo IDs use only -/// lowercase letters and digits. New IDs contain [`MEMO_ID_LENGTH`] characters. -pub const MEMO_ID_ALPHABET: [char; 36] = [ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', - 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', -]; -pub const MEMO_ID_LENGTH: usize = 8; - mod content; +mod database_layout; +mod storage_repair; mod derivation; mod file_management; mod file_io; pub(crate) mod frontmatter; mod index_store; mod internal_migration; +mod local_migrations; mod media_resource; mod migration; mod notebook; @@ -50,8 +42,8 @@ mod note_index; mod versions; pub use note_index::{ - NoteEntry, NoteIndexReconcileReport, NotePropertyMigrationReport, NoteSearchHit, - NoteTodoMigrationReport, NoteWriteOutcome, + NoteEntry, NoteIndexReconcileReport, NoteSearchHit, + NoteWriteOutcome, }; pub use operation_locks::{FileLockIntent, FileWriteGuard, OperationLocks}; @@ -67,15 +59,17 @@ pub use file_io::{ }; pub use file_management::{default_create_folder_for_notebook, FileManagementPolicy}; pub use frontmatter::{ - extract_body_content, extract_document_metadata, extract_frontmatter_key, - extract_frontmatter_properties, is_system_frontmatter_key, merge_frontmatter, + extract_body_content, extract_document_metadata, extract_frontmatter_properties, + merge_frontmatter, normalize_document_tags, normalize_markdown_encoding_boundaries, replace_frontmatter_tags, - DocumentMetadata, FrontmatterMetadataError, MergeOverrides, CANONICAL_FRONTMATTER_KEY, - LEGACY_FRONTMATTER_KEY, SYSTEM_FRONTMATTER_KEYS, + DocumentMetadata, FrontmatterMetadataError, MergeOverrides, }; -pub use index_store::{MemoContentCommit, MemoContentRevision, NoteContentCommit, NoteContentRevision}; +pub use index_store::{NoteContentCommit, NoteContentRevision}; +pub use local_migrations::{initialize_local_migration_table, NotebookInitializationJob}; pub use internal_migration::{NotebookInternalMigrationReport, NOTEBOOK_INTERNAL_MIGRATION_KEY}; -pub use media_resource::{media_kind_for_path, MediaResource, MediaResourceKind, MediaResourcePage}; +pub use storage_repair::{ensure_storage_directory, read_or_create_json, backup_database, backup_storage_file, is_rebuildable_storage_error, with_database_rebuild}; +pub use database_layout::{prepare_database_directory, DATABASE_DIRECTORY_NAME}; +pub use media_resource::{media_kind_for_path, media_resource_id, MediaResource, MediaResourceKind, MediaResourcePage}; pub use migration::{DataMigrationReport, NotebookMigrationReport, LATEST_DATA_MIGRATION_VERSION}; pub use ops::{ base_filename, filename_from_notebook_relative_path, is_ignored_notebook_relative_path, @@ -83,9 +77,11 @@ pub use ops::{ resolve_relative_filename_conflict, sanitize_filename_component, IsMd, }; pub use types::{ - AgentThreadItem, DeleteTagReport, Memo, MemoColor, MemoIndexEntry, MemoIndexFile, MemoLocation, - MemoMetadataFile, MemoTag, MemoTodoEntry, MoveTagReport, NoteColor, + AgentThreadItem, DeleteTagReport, Memo, MemoIndexEntry, MemoIndexFile, MemoLocation, + MemoTag, MoveTagReport, NoteColor, Notebook, NotebookConfig, NotebookManifest, NotebookSetupJob, NotebookSetupJobStatus, + NotebookSetupSnapshot, + NotebookSetupStage, NotebookSetupReport, ReconcileReport, TodoItem, }; @@ -94,16 +90,16 @@ pub use versions::{ MEMO_VERSION_LIMIT, }; -/// Compatibility storage façade over notebook registry, Note catalog, media, -/// revisions and Memo-ID records. Domain implementations live in separate -/// modules; `NotebookRegistry` owns the device-local registry path and cache. +/// Storage façade over notebook registry, Note catalog, media and revisions. +/// Domain implementations live in separate modules; `NotebookRegistry` owns +/// the device-local registry path and cache. /// /// 字段: /// - `registry`: 用户配置目录 (`~/.flowix/`) 下的设备级笔记本注册表。 -/// - Note 投影、兼容 memo-ID 数据和媒体属性位于 +/// - Note 投影和媒体属性位于 /// `/.flowix/notebook.db`。 /// - `current_notebook_id`: 当前活跃 notebook id, `None` 表示走默认。 -/// - `index_cache`: 当前 notebook 兼容 memo-ID 列表的内存缓存。读路径先查询 SQLite +/// - `index_cache`: 当前 notebook 相对路径索引的内存缓存。读路径先查询 SQLite /// `memo_index_state.last_updated`,只有版本一致才复用,保证其他进程写入可见。 /// 写路径 ([`MemoFile::write_index`] / `_locked` 系列) 在 DB 写入成功后回填。 /// 切 notebook 时由 [`Self::set_current_notebook`] 失效。 @@ -340,3 +336,6 @@ impl MemoFile { #[cfg(test)] mod tests; + +mod workspace; +pub use workspace::{NotebookAddDir, NotebookWorkspaceConfig, NotebookWorkspaceSnapshot}; diff --git a/app/flowix-core/src/memo_file/note_index.rs b/app/flowix-core/src/memo_file/note_index.rs index c603fc66a..fb16287b2 100644 --- a/app/flowix-core/src/memo_file/note_index.rs +++ b/app/flowix-core/src/memo_file/note_index.rs @@ -3,7 +3,7 @@ //! This index never supplies bytes for a note. Markdown remains the source of //! content and the projection can be discarded and rebuilt from the notebook. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io; use std::path::Path; @@ -17,13 +17,13 @@ use walkdir::WalkDir; use super::{ notebook_path_from_relative, notebook_relative_path, AgentThreadItem, Memo, MemoFile, - MemoIndexEntry, NoteColor, TodoItem, + NoteColor, TodoItem, }; -const SCHEMA_VERSION: i64 = 3; -// Version 9 tolerates legacy malformed tags while retaining ordinary properties. -const PARSER_VERSION: i64 = 9; -const NOTE_SEARCH_FTS_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 4; +// Version 10 stores derived tags, todos, and agents on each notes row. +const PARSER_VERSION: i64 = 10; +const NOTE_SEARCH_FTS_VERSION: i64 = 2; const NOTE_INDEX_REFRESH_PENDING: &str = "note-index-refresh-pending"; const NOTE_INDEX_REFRESH_PENDING_DIR: &str = "note-index-refresh-pending.d"; static NOTE_INDEX_REFRESH_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -129,6 +129,13 @@ pub enum NoteWriteOutcome { Conflict { disk_content: String }, } +#[derive(Clone, Copy)] +enum MaintenanceAdmission { + Wait, + SkipIfBusy, + FailIfBusy, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct NoteIndexReconcileReport { pub added: usize, @@ -146,61 +153,6 @@ pub struct NoteSearchHit { pub matched_in: String, } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct NoteTodoMigrationReport { - pub notes_written: usize, - pub tasks_written: usize, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct NotePropertyMigrationReport { - pub notes_written: usize, - pub properties_written: usize, -} - -#[derive(Debug)] -struct LegacyNoteProperties { - id: String, - relative_path: String, - created_at: i64, - favorited: bool, - icon: Option, - properties_json: String, -} - -#[derive(Debug)] -struct LegacyTodoAttributes { - todo_id: String, - priority: String, - time_range: String, - owner: String, - assignee: String, - created_at: i64, - updated_at: i64, -} - -impl LegacyTodoAttributes { - fn has_persistent_value(&self) -> bool { - !self.priority.is_empty() - || !self.time_range.is_empty() - || !self.owner.is_empty() - || !self.assignee.is_empty() - || self.created_at != 0 - || self.updated_at != 0 - } - - fn json_value(&self) -> serde_json::Value { - serde_json::json!({ - "priority": self.priority, - "timeRange": self.time_range, - "owner": self.owner, - "assignee": self.assignee, - "createdAt": self.created_at, - "updatedAt": self.updated_at, - }) - } -} - impl MemoFile { pub(crate) fn mark_note_index_refresh_pending_for_notebook(&self, notebook_id: &str) -> io::Result<()> { let root = self.notebook_root_for_note(notebook_id)?; @@ -667,7 +619,7 @@ impl MemoFile { let conn = self.open_note_index_connection(notebook_id)?; let exists: bool = conn .query_row( - "SELECT EXISTS(SELECT 1 FROM note_tags WHERE tag = ?1)", + "SELECT EXISTS(SELECT 1 FROM notes n, json_each(n.tags) t WHERE t.value = ?1)", [tag], |row| row.get(0), ) @@ -683,15 +635,14 @@ impl MemoFile { tag: &str, ) -> io::Result> { let conn = self.open_note_index_connection(notebook_id)?; - let prefix = format!("{tag}/"); let mut stmt = conn .prepare( - "SELECT DISTINCT relative_path FROM note_tags \ - WHERE tag = ?1 OR tag LIKE ?2 ESCAPE '\\' ORDER BY relative_path", + "SELECT DISTINCT n.relative_path FROM notes n, json_each(n.tags) t \ + WHERE t.value = ?1 OR substr(t.value, 1, length(?1)+1) = ?1 || '/' ORDER BY n.relative_path", ) .map_err(io::Error::other)?; let rows = stmt - .query_map(params![tag, format!("{prefix}%")], |row| row.get(0)) + .query_map(params![tag], |row| row.get(0)) .map_err(io::Error::other)?; rows.collect::, _>>().map_err(io::Error::other) } @@ -716,13 +667,14 @@ impl MemoFile { relative_path: &str, ) -> io::Result> { let conn = self.open_note_index_connection(notebook_id)?; - let row: Option<(String, String, String, Option, i64, i64, i64, Option, String, String, u64)> = conn + let row: Option<(String, String, String, Option, i64, i64, i64, Option, String, String, u64, String, String, String)> = conn .query_row( - "SELECT relative_path, title, preview, thumbnail, created_at, updated_at, favorited, icon, colors_json, properties_json, size_bytes \ + "SELECT relative_path, title, preview, thumbnail, created_at, updated_at, favorited, icon, colors_json, properties_json, size_bytes, tags, todos, agents \ FROM notes WHERE relative_path=?1", params![relative_path], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, - row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?, row.get(9)?, row.get(10)?)), + row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?, row.get(9)?, row.get(10)?, + row.get(11)?, row.get(12)?, row.get(13)?)), ) .optional() .map_err(io::Error::other)?; @@ -738,19 +690,22 @@ impl MemoFile { colors, properties, size_bytes, + tags, + todos, + agents, )) = row else { return Ok(None); }; - let mut entry = NoteEntry { + let entry = NoteEntry { size_bytes, relative_path, title, preview, thumbnail, - tags: Vec::new(), - todos: Vec::new(), - agents: Vec::new(), + tags: serde_json::from_str(&tags).map_err(io::Error::other)?, + todos: serde_json::from_str(&todos).map_err(io::Error::other)?, + agents: serde_json::from_str(&agents).map_err(io::Error::other)?, created_at, updated_at, favorited: favorited != 0, @@ -758,42 +713,6 @@ impl MemoFile { colors: serde_json::from_str(&colors).map_err(io::Error::other)?, properties: serde_json::from_str(&properties).map_err(io::Error::other)?, }; - let mut stmt = conn - .prepare("SELECT tag FROM note_tags WHERE relative_path=?1 ORDER BY tag") - .map_err(io::Error::other)?; - entry.tags = stmt - .query_map(params![&entry.relative_path], |row| row.get(0)) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - let mut stmt = conn.prepare( - "SELECT todo_id, content, status FROM note_todos WHERE relative_path=?1 ORDER BY position" - ).map_err(io::Error::other)?; - entry.todos = stmt - .query_map(params![&entry.relative_path], |row| { - Ok(TodoItem { - id: row.get(0)?, - content: row.get(1)?, - status: row.get(2)?, - }) - }) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - let mut stmt = conn.prepare( - "SELECT thread_id, title, agent_type FROM note_agents WHERE relative_path=?1 ORDER BY position" - ).map_err(io::Error::other)?; - entry.agents = stmt - .query_map(params![&entry.relative_path], |row| { - Ok(AgentThreadItem { - thread_id: row.get(0)?, - title: row.get(1)?, - agent_type: row.get(2)?, - }) - }) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; Ok(Some(entry)) } @@ -823,7 +742,6 @@ impl MemoFile { .unwrap_or(updated_at); let content = fs::read_to_string(&path)?; let mut derived = Memo { - id: String::new(), filename: relative_path .rsplit('/') .next() @@ -872,7 +790,7 @@ impl MemoFile { } let conn = self.open_note_index_connection(notebook_id)?; let mut stmt = conn.prepare( - "SELECT relative_path, title, preview, thumbnail, created_at, updated_at, favorited, icon, colors_json, properties_json, size_bytes \ + "SELECT relative_path, title, preview, thumbnail, created_at, updated_at, favorited, icon, colors_json, properties_json, size_bytes, tags, todos, agents \ FROM notes ORDER BY created_at ASC, relative_path ASC" ).map_err(io::Error::other)?; let rows = stmt @@ -889,6 +807,9 @@ impl MemoFile { row.get::<_, String>(8)?, row.get::<_, String>(9)?, row.get::<_, u64>(10)?, + row.get::<_, String>(11)?, + row.get::<_, String>(12)?, + row.get::<_, String>(13)?, )) }) .map_err(io::Error::other)?; @@ -906,6 +827,9 @@ impl MemoFile { colors, properties, size_bytes, + tags, + todos, + agents, ) = row.map_err(io::Error::other)?; entries.push(NoteEntry { size_bytes, @@ -913,9 +837,9 @@ impl MemoFile { title, preview, thumbnail, - tags: Vec::new(), - todos: Vec::new(), - agents: Vec::new(), + tags: serde_json::from_str(&tags).map_err(io::Error::other)?, + todos: serde_json::from_str(&todos).map_err(io::Error::other)?, + agents: serde_json::from_str(&agents).map_err(io::Error::other)?, created_at, updated_at, favorited: favorited != 0, @@ -924,68 +848,6 @@ impl MemoFile { properties: serde_json::from_str(&properties).map_err(io::Error::other)?, }); } - drop(stmt); - let positions: HashMap<_, _> = entries - .iter() - .enumerate() - .map(|(index, entry)| (entry.relative_path.clone(), index)) - .collect(); - let mut stmt = conn - .prepare("SELECT relative_path, tag FROM note_tags ORDER BY relative_path, tag") - .map_err(io::Error::other)?; - let rows = stmt - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(io::Error::other)?; - for row in rows { - let (path, tag) = row.map_err(io::Error::other)?; - if let Some(index) = positions.get(&path) { - entries[*index].tags.push(tag); - } - } - let mut stmt = conn.prepare( - "SELECT relative_path, todo_id, content, status FROM note_todos ORDER BY relative_path, position" - ).map_err(io::Error::other)?; - let rows = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - TodoItem { - id: row.get(1)?, - content: row.get(2)?, - status: row.get(3)?, - }, - )) - }) - .map_err(io::Error::other)?; - for row in rows { - let (path, todo) = row.map_err(io::Error::other)?; - if let Some(index) = positions.get(&path) { - entries[*index].todos.push(todo); - } - } - let mut stmt = conn.prepare( - "SELECT relative_path, thread_id, title, agent_type FROM note_agents ORDER BY relative_path, position" - ).map_err(io::Error::other)?; - let rows = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - AgentThreadItem { - thread_id: row.get(1)?, - title: row.get(2)?, - agent_type: row.get(3)?, - }, - )) - }) - .map_err(io::Error::other)?; - for row in rows { - let (path, agent) = row.map_err(io::Error::other)?; - if let Some(index) = positions.get(&path) { - entries[*index].agents.push(agent); - } - } Ok(entries) } @@ -1035,9 +897,9 @@ impl MemoFile { OR lower(body) LIKE ?1 ESCAPE '\\' OR lower(tags) LIKE ?1 ESCAPE '\\') AND (?4 IS NULL OR EXISTS ( - SELECT 1 FROM note_tags t - WHERE t.relative_path=note_search_fts.relative_path - AND (t.tag=?4 OR substr(t.tag, 1, length(?4)+1)=?4 || '/') + SELECT 1 FROM notes n, json_each(n.tags) t + WHERE n.relative_path=note_search_fts.relative_path + AND (t.value=?4 OR substr(t.value, 1, length(?4)+1)=?4 || '/') )) ORDER BY updated_at DESC, relative_path ASC LIMIT ?3", pattern, @@ -1052,9 +914,9 @@ impl MemoFile { FROM note_search_fts WHERE note_search_fts MATCH ?1 AND (?4 IS NULL OR EXISTS ( - SELECT 1 FROM note_tags t - WHERE t.relative_path=note_search_fts.relative_path - AND (t.tag=?4 OR substr(t.tag, 1, length(?4)+1)=?4 || '/') + SELECT 1 FROM notes n, json_each(n.tags) t + WHERE n.relative_path=note_search_fts.relative_path + AND (t.value=?4 OR substr(t.value, 1, length(?4)+1)=?4 || '/') )) ORDER BY updated_at DESC, relative_path ASC LIMIT ?3", quoted, @@ -1161,7 +1023,7 @@ impl MemoFile { continue; } let tags: String = tx.query_row( - "SELECT COALESCE(group_concat(tag, ' '), '') FROM note_tags WHERE relative_path=?1", + "SELECT COALESCE(group_concat(value, ' '), '') FROM notes n, json_each(n.tags) WHERE n.relative_path=?1", params![relative_path], |row| row.get(0), ).map_err(io::Error::other)?; tx.execute( @@ -1221,21 +1083,17 @@ impl MemoFile { let mut sql = String::from("SELECT n.relative_path FROM notes n WHERE 1=1"); let mut args: Vec = Vec::new(); match filter { - "todos" => sql.push_str( - " AND EXISTS (SELECT 1 FROM note_todos t WHERE t.relative_path=n.relative_path)", - ), - "agents" => sql.push_str( - " AND EXISTS (SELECT 1 FROM note_agents a WHERE a.relative_path=n.relative_path)", - ), + "todos" => sql.push_str(" AND json_array_length(n.todos)>0"), + "agents" => sql.push_str(" AND json_array_length(n.agents)>0"), "favorited" => sql.push_str(" AND n.favorited=1"), "tagged" => { if let Some(tag) = tag_id { - sql.push_str(" AND EXISTS (SELECT 1 FROM note_tags t WHERE t.relative_path=n.relative_path AND (t.tag=? OR substr(t.tag,1,length(?)+1)=?))"); + sql.push_str(" AND EXISTS (SELECT 1 FROM json_each(n.tags) t WHERE t.value=? OR substr(t.value,1,length(?)+1)=?)"); args.push(tag.to_owned().into()); args.push(tag.to_owned().into()); args.push(format!("{tag}/").into()); } else { - sql.push_str(" AND EXISTS (SELECT 1 FROM note_tags t WHERE t.relative_path=n.relative_path)"); + sql.push_str(" AND json_array_length(n.tags)>0"); } } "thisWeek" | "thisMonth" => { @@ -1319,69 +1177,6 @@ impl MemoFile { .collect() } - /// Adapt the path-keyed list to the current ID-bearing API while migration - /// of external callers is still in progress. - pub(crate) fn list_note_entries_with_legacy_ids( - &self, - notebook_id: &str, - ) -> io::Result>> { - if !self.note_index_is_ready(notebook_id)? { - return Ok(None); - } - let entries = self.read_note_entries(notebook_id)?; - let conn = self.open_note_index_connection(notebook_id)?; - let legacy_table: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memos'", - [], - |row| row.get(0), - ) - .map_err(io::Error::other)?; - if legacy_table == 0 { - return Ok(None); - } - let mut stmt = conn - .prepare("SELECT relative_path, id FROM memos WHERE notebook_id=?1") - .map_err(io::Error::other)?; - let mappings = stmt - .query_map(params![notebook_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - if entries.len() != mappings.len() { - return Ok(None); - } - let mut adapted = Vec::with_capacity(entries.len()); - for entry in entries { - let Some(id) = mappings.get(&entry.relative_path) else { - return Ok(None); - }; - adapted.push(MemoIndexEntry { - id: id.clone(), - filename: entry - .relative_path - .rsplit('/') - .next() - .unwrap_or(&entry.relative_path) - .to_owned(), - relative_path: entry.relative_path, - preview: entry.preview, - thumbnail: entry.thumbnail.clone(), - tags: entry.tags, - todos: entry.todos, - agents: entry.agents, - created_at: entry.created_at, - updated_at: entry.updated_at, - favorited: entry.favorited, - icon: entry.icon, - colors: entry.colors, - properties: entry.properties, - }); - } - Ok(Some(adapted)) - } pub(crate) fn note_index_is_ready(&self, notebook_id: &str) -> io::Result { let conn = self.open_note_index_connection(notebook_id)?; let mut stmt = conn.prepare( @@ -1402,6 +1197,32 @@ impl MemoFile { .is_some_and(|value| value == &PARSER_VERSION.to_string())) } + /// Read a completed projection without creating directories, tables or scans. + /// Absence, an unfinished build, and an obsolete parser return unknown. + pub fn cached_note_count(&self, notebook_id: &str) -> io::Result> { + let notebook = self.get_notebook_config_by_id(notebook_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "notebook not found"))?; + let path = Path::new(¬ebook.path).join(".flowix/notebook.db"); + if !path.is_file() { return Ok(None); } + let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(io::Error::other)?; + conn.busy_timeout(Duration::from_millis(50)).map_err(io::Error::other)?; + let has_projection: bool = conn.query_row( + "SELECT COUNT(*)=2 FROM sqlite_master WHERE type='table' AND name IN ('note_index_meta', 'notes')", + [], |row| row.get(0), + ).map_err(io::Error::other)?; + if !has_projection { return Ok(None); } + let ready: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM note_index_meta WHERE key='build_state' AND value='ready') + AND EXISTS(SELECT 1 FROM note_index_meta WHERE key='parser_version' AND value=?1)", + [PARSER_VERSION.to_string()], |row| row.get(0), + ).map_err(io::Error::other)?; + if !ready { return Ok(None); } + let count: i64 = conn.query_row("SELECT COUNT(*) FROM notes", [], |row| row.get(0)) + .map_err(io::Error::other)?; + Ok(Some(usize::try_from(count).unwrap_or(0))) + } + pub(crate) fn note_count(&self, notebook_id: &str) -> io::Result { let conn = self.open_note_index_connection(notebook_id)?; let count: i64 = conn @@ -1451,11 +1272,11 @@ impl MemoFile { Ok(usize::try_from(value).unwrap_or(0)) }; let total = count("SELECT COUNT(*) FROM notes")?; - let agents = count("SELECT COUNT(DISTINCT relative_path) FROM note_agents")?; - let todos = count("SELECT COUNT(DISTINCT relative_path) FROM note_todos")?; + let agents = count("SELECT COUNT(*) FROM notes WHERE json_array_length(agents)>0")?; + let todos = count("SELECT COUNT(*) FROM notes WHERE json_array_length(todos)>0")?; let mut stmt = conn .prepare( - "SELECT tag, COUNT(*) FROM note_tags GROUP BY tag ORDER BY tag COLLATE NOCASE ASC", + "SELECT t.value, COUNT(DISTINCT n.relative_path) FROM notes n, json_each(n.tags) t GROUP BY t.value ORDER BY t.value COLLATE NOCASE ASC", ) .map_err(io::Error::other)?; let tags = stmt @@ -1478,7 +1299,7 @@ impl MemoFile { ) -> io::Result> { let conn = self.open_note_index_connection(notebook_id)?; let mut stmt = conn - .prepare("SELECT tag, relative_path FROM note_tags") + .prepare("SELECT t.value, n.relative_path FROM notes n, json_each(n.tags) t") .map_err(io::Error::other)?; let pairs = stmt .query_map([], |row| { @@ -1490,350 +1311,54 @@ impl MemoFile { pairs } - /// Move note properties that may exist only in the legacy index into the - /// Markdown frontmatter. Existing file properties always win. - pub fn migrate_note_properties_for_notebook( - &self, - notebook_id: &str, - ) -> io::Result { - let _write_guard = self.acquire_cross_process_write_lock()?; - let _index_guard = self.current_index_io.lock().expect("index_io poisoned"); - let root = self - .get_notebook_config_by_id(notebook_id) - .map(|config| std::path::PathBuf::from(config.path)) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "notebook not found"))?; + /// Drop the obsolete Memo-ID projection after rebuilding the path index from Markdown. + /// Derived tags, todos, agents, colors, and properties come from the source files. + pub fn retire_legacy_memo_index(&self, notebook_id: &str) -> io::Result<()> { + self.initialize_note_index_schema(notebook_id)?; let conn = self.open_note_index_connection(notebook_id)?; - let complete: Option = conn + let retired: Option = conn .query_row( - "SELECT value FROM note_index_meta WHERE key='note_properties_migrated'", + "SELECT value FROM note_index_meta WHERE key='legacy_memo_index_retired'", [], |row| row.get(0), ) .optional() .map_err(io::Error::other)?; - if complete.as_deref() == Some("1") { - return Ok(NotePropertyMigrationReport::default()); - } - let has_legacy: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memos'", - [], - |row| row.get(0), - ) - .map_err(io::Error::other)?; - if has_legacy == 0 { - conn.execute( - "INSERT INTO note_index_meta (key, value) VALUES ('note_properties_migrated','1') \ - ON CONFLICT(key) DO UPDATE SET value='1'", - [], - ) - .map_err(io::Error::other)?; - return Ok(NotePropertyMigrationReport::default()); - } - let mut statement = conn - .prepare( - "SELECT id, relative_path, created_at, favorited, icon, properties \ - FROM memos WHERE notebook_id=?1 ORDER BY relative_path", - ) - .map_err(io::Error::other)?; - let notes = statement - .query_map(params![notebook_id], |row| { - Ok(LegacyNoteProperties { - id: row.get(0)?, - relative_path: row.get(1)?, - created_at: row.get(2)?, - favorited: row.get::<_, i64>(3)? != 0, - icon: row.get(4)?, - properties_json: row.get(5)?, - }) - }) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - drop(statement); - let legacy_created_times: Vec<_> = notes - .iter() - .filter(|note| note.created_at > 0) - .map(|note| (note.relative_path.clone(), note.created_at)) - .collect(); - let mut report = NotePropertyMigrationReport::default(); - for note in notes { - let stored_properties: serde_json::Value = serde_json::from_str(¬e.properties_json) - .map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "invalid legacy properties for {}: {error}", - note.relative_path - ), - ) - })?; - let stored_properties = stored_properties.as_object().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "legacy properties are not an object for {}", - note.relative_path - ), - ) - })?; - let path = notebook_path_from_relative(&root, ¬e.relative_path) - .map_err(io::Error::other)?; - let original = fs::read_to_string(&path)?; - let metadata = super::extract_document_metadata(&original) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; - let existing = metadata.properties.as_object().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "frontmatter is not a mapping") - })?; - let mut values = serde_json::Map::new(); - if note.favorited && !existing.contains_key("flowix_favorited") { - values.insert("flowix_favorited".into(), serde_json::json!(true)); - } - if let Some(icon) = note.icon.filter(|value| !value.is_empty()) { - if !existing.contains_key("flowix_icon") { - values.insert("flowix_icon".into(), serde_json::json!(icon)); - } - } - if !existing.contains_key("flowix_colors") { - let mut colors = conn - .prepare("SELECT color FROM memo_colors WHERE memo_id=?1 ORDER BY position") - .map_err(io::Error::other)? - .query_map(params![note.id], |row| row.get::<_, String>(0)) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - if !colors.is_empty() { - values.insert( - "flowix_colors".into(), - serde_json::json!(std::mem::take(&mut colors)), - ); - } - } - for (key, value) in stored_properties { - if key != "key" - && key != "flowix_key" - && key != "flowix_created_at" - && !existing.contains_key(key) - && !values.contains_key(key) - { - values.insert(key.clone(), value.clone()); - } - } - if values.is_empty() { - continue; - } - let mut overrides = super::MergeOverrides::new(); - for (key, value) in &values { - if key.is_empty() || key.contains(['\n', '\r']) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid legacy property name in {}", note.relative_path), - )); - } - let yaml_key = if key - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') - { - key.clone() - } else { - serde_json::to_string(key).map_err(io::Error::other)? - }; - overrides.insert(yaml_key, value.to_string()); - } - let updated = super::merge_frontmatter(&original, &overrides); - let readback = super::extract_document_metadata(&updated) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; - for (key, value) in &values { - if readback.properties.get(key) != Some(value) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("property {key} readback failed for {}", note.relative_path), - )); - } - } - if fs::read_to_string(&path)? != original { - return Err(io::Error::new( - io::ErrorKind::WouldBlock, - format!( - "note changed during property migration: {}", - note.relative_path - ), - )); - } - super::atomic_write_bytes(&path, updated.as_bytes())?; - report.notes_written += 1; - report.properties_written += values.len(); - } - // Creation time remains index metadata. Seed it from the old index - // once, without changing the Markdown file for this field. - for (relative_path, created_at) in legacy_created_times { - self.refresh_note_path(notebook_id, &relative_path)?; - conn.execute( - "UPDATE notes SET created_at=?2 WHERE relative_path=?1", - params![relative_path, created_at], - ) - .map_err(io::Error::other)?; + if retired.as_deref() == Some("1") { + return Ok(()); } - conn.execute( - "INSERT INTO note_index_meta (key, value) VALUES ('note_properties_migrated','1') \ - ON CONFLICT(key) DO UPDATE SET value='1'", - [], + conn.execute_batch( + "BEGIN IMMEDIATE; + DELETE FROM notes; + DELETE FROM note_index_meta WHERE key='parser_version'; + COMMIT;", ) .map_err(io::Error::other)?; - Ok(report) - } - - /// Persist non-derivable legacy task attributes in their Markdown note. - /// The migration is idempotent. A mismatched task ID or conflicting - /// frontmatter value aborts before writing that note. - pub fn migrate_note_todo_metadata_for_notebook( - &self, - notebook_id: &str, - ) -> io::Result { - let _write_guard = self.acquire_cross_process_write_lock()?; - let _index_guard = self.current_index_io.lock().expect("index_io poisoned"); - let root = self - .get_notebook_config_by_id(notebook_id) - .map(|config| std::path::PathBuf::from(config.path)) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "notebook not found"))?; + drop(conn); + self.reconcile_note_index_blocking(notebook_id)?; let conn = self.open_note_index_connection(notebook_id)?; - let complete: Option = conn - .query_row( - "SELECT value FROM note_index_meta WHERE key='todo_metadata_migrated'", - [], - |row| row.get(0), - ) - .optional() - .map_err(io::Error::other)?; - if complete.as_deref() == Some("1") { - return Ok(NoteTodoMigrationReport::default()); - } - let has_legacy: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memo_todos'", - [], - |row| row.get(0), - ) - .map_err(io::Error::other)?; - if has_legacy == 0 { - conn.execute( - "INSERT INTO note_index_meta (key, value) VALUES ('todo_metadata_migrated', '1') \ - ON CONFLICT(key) DO UPDATE SET value='1'", - [], - ) - .map_err(io::Error::other)?; - return Ok(NoteTodoMigrationReport::default()); - } - let mut stmt = conn - .prepare("SELECT m.relative_path, t.todo_id, t.priority, t.time_range, t.owner, t.assignee, \ - t.created_at, t.updated_at FROM memo_todos t \ - JOIN memos m ON m.id=t.memo_id WHERE m.notebook_id=?1 ORDER BY m.relative_path, t.position") - .map_err(io::Error::other)?; - let rows = stmt - .query_map(params![notebook_id], |row| { - Ok(( - row.get::<_, String>(0)?, - LegacyTodoAttributes { - todo_id: row.get(1)?, - priority: row.get(2)?, - time_range: row.get(3)?, - owner: row.get(4)?, - assignee: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - }, - )) - }) - .map_err(io::Error::other)?; - let mut by_path: BTreeMap> = BTreeMap::new(); - for row in rows { - let (path, attributes) = row.map_err(io::Error::other)?; - if attributes.has_persistent_value() { - by_path.entry(path).or_default().push(attributes); - } - } - drop(stmt); - let mut report = NoteTodoMigrationReport::default(); - for (relative_path, attributes) in by_path { - let path = - notebook_path_from_relative(&root, &relative_path).map_err(io::Error::other)?; - let original = fs::read_to_string(&path)?; - let with_ids = super::ensure_todo_ids_in_content(&original); - let todos = super::extract_todos_from_body(&with_ids); - let known_ids: HashSet = todos.iter().map(|todo| todo.id.clone()).collect(); - if known_ids.len() != todos.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("duplicate task IDs in {relative_path}"), - )); - } - let mut metadata = super::extract_document_metadata(&with_ids) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))? - .properties - .get("flowix_todo_metadata") - .and_then(serde_json::Value::as_object) - .cloned() - .unwrap_or_default(); - for attribute in &attributes { - if !known_ids.contains(&attribute.todo_id) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "legacy task {} is missing from {}", - attribute.todo_id, relative_path - ), - )); - } - let value = attribute.json_value(); - if let Some(existing) = metadata.get(&attribute.todo_id) { - if existing != &value { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "task metadata conflict for {} in {}", - attribute.todo_id, relative_path - ), - )); - } - } else { - metadata.insert(attribute.todo_id.clone(), value); - } - } - let mut overrides = HashMap::new(); - overrides.insert( - "flowix_todo_metadata".to_owned(), - serde_json::Value::Object(metadata.clone()).to_string(), - ); - let updated = super::merge_frontmatter(&with_ids, &overrides.into_iter().collect()); - let readback = super::extract_document_metadata(&updated) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; - if readback.properties.get("flowix_todo_metadata") - != Some(&serde_json::Value::Object(metadata)) - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("task metadata readback failed for {relative_path}"), - )); - } - if updated != original { - if fs::read_to_string(&path)? != original { - return Err(io::Error::new( - io::ErrorKind::WouldBlock, - format!("note changed during task metadata migration: {relative_path}"), - )); - } - super::atomic_write_bytes(&path, updated.as_bytes())?; - report.notes_written += 1; - report.tasks_written += attributes.len(); - } - } - conn.execute( - "INSERT INTO note_index_meta (key, value) VALUES ('todo_metadata_migrated', '1') \ - ON CONFLICT(key) DO UPDATE SET value='1'", - [], + conn.execute_batch( + "BEGIN IMMEDIATE; + DROP TABLE IF EXISTS memo_content_revisions; + DROP TABLE IF EXISTS memo_agents; + DROP TABLE IF EXISTS memo_colors; + DROP TRIGGER IF EXISTS trg_memo_tags_register_notebook_tag; + DROP TABLE IF EXISTS memo_tags; + DROP TABLE IF EXISTS memo_todos; + DROP TABLE IF EXISTS pending_external_memo_creates; + DROP TABLE IF EXISTS notebook_tags; + DROP TABLE IF EXISTS memos; + DROP TABLE IF EXISTS memo_lifecycles; + DROP TABLE IF EXISTS memo_index_state; + DROP TABLE IF EXISTS notebook_data_migrations; + DROP TABLE IF EXISTS schema_migrations; + INSERT INTO note_index_meta (key, value) + VALUES ('legacy_memo_index_retired', '1') + ON CONFLICT(key) DO UPDATE SET value='1'; + COMMIT;", ) .map_err(io::Error::other)?; - Ok(report) + Ok(()) } /// Reconcile one notebook's Note projection with files on disk. Existing @@ -1844,7 +1369,7 @@ impl MemoFile { /// already reconciling the same notebook, so a background refresh never /// turns into a spurious 5s timeout error. The next read pass retries. pub fn reconcile_note_index(&self, notebook_id: &str) -> io::Result { - self.reconcile_note_index_inner(notebook_id, false, true) + self.reconcile_note_index_inner(notebook_id, false, MaintenanceAdmission::SkipIfBusy, &|| true) } /// Hash every file, including entries whose size and mtime are unchanged. @@ -1854,7 +1379,7 @@ impl MemoFile { /// maintenance lock rather than skipping, so the caller can rely on a full /// pass having completed. pub fn verify_note_index(&self, notebook_id: &str) -> io::Result { - self.reconcile_note_index_inner(notebook_id, true, false) + self.reconcile_note_index_inner(notebook_id, true, MaintenanceAdmission::Wait, &|| true) } /// Blocking reconcile used by short-lived CLI/MCP processes that need an @@ -1867,7 +1392,7 @@ impl MemoFile { &self, notebook_id: &str, ) -> io::Result { - self.reconcile_note_index_inner(notebook_id, false, false) + self.reconcile_note_index_inner(notebook_id, false, MaintenanceAdmission::Wait, &|| true) } /// Enumerate Markdown note paths directly from the notebook tree without @@ -1937,13 +1462,32 @@ impl MemoFile { ) } + /// Upgrade only the rebuildable projection in place. Never replace the + /// shared notebook database, which also owns media properties/revisions. + pub fn prepare_background_note_projection(&self, notebook_id: &str) -> io::Result<()> { + let Some(_guard) = self.operation_locks().try_notebook_maintenance(notebook_id, "prepare_note_projection")? else { + return Err(io::Error::new(io::ErrorKind::WouldBlock, "notebook maintenance is busy")); + }; + self.open_note_index_connection_unchecked(notebook_id, true)?; + Ok(()) + } + + /// Cooperative desktop maintenance. Locks and transactions remain per file; + /// cancellation leaves an incomplete cold index explicitly unavailable. + pub fn reconcile_note_index_background( + &self, notebook_id: &str, keep_running: &dyn Fn() -> bool, + ) -> io::Result { + self.reconcile_note_index_inner(notebook_id, false, MaintenanceAdmission::FailIfBusy, keep_running) + } + fn reconcile_note_index_inner( &self, notebook_id: &str, verify_hashes: bool, - skip_if_busy: bool, + admission: MaintenanceAdmission, + keep_running: &dyn Fn() -> bool, ) -> io::Result { - let _maintenance_guard = if skip_if_busy { + let _maintenance_guard = if !matches!(admission, MaintenanceAdmission::Wait) { let Some(guard) = self .operation_locks() .try_notebook_maintenance(notebook_id, "reconcile_note_index")? @@ -1952,6 +1496,9 @@ impl MemoFile { notebook_id, "note index reconcile skipped: another maintenance pass is in progress" ); + if matches!(admission, MaintenanceAdmission::FailIfBusy) { + return Err(io::Error::new(io::ErrorKind::WouldBlock, "notebook maintenance is busy")); + } return Ok(NoteIndexReconcileReport::default()); }; guard @@ -1981,12 +1528,13 @@ impl MemoFile { .map_err(io::Error::other)? .and_then(|value| value.parse().ok()); let reparse_all = parser_version != Some(PARSER_VERSION); - conn.execute( - "INSERT INTO note_index_meta (key, value) VALUES ('build_state', 'building') \ - ON CONFLICT(key) DO UPDATE SET value='building'", - [], - ) - .map_err(io::Error::other)?; + // A warm refresh must not make existing lists unavailable while scanning. + if reparse_all || !self.note_index_is_ready(notebook_id)? { + conn.execute( + "INSERT INTO note_index_meta (key, value) VALUES ('build_state', 'building') \ + ON CONFLICT(key) DO UPDATE SET value='building'", [], + ).map_err(io::Error::other)?; + } let mut seen = HashSet::new(); let mut report = NoteIndexReconcileReport::default(); let policy = self.file_management_policy(notebook_id); @@ -2004,6 +1552,9 @@ impl MemoFile { .unwrap_or(false) }) { + if !keep_running() { + return Err(io::Error::new(io::ErrorKind::Interrupted, "index maintenance cancelled")); + } let entry = entry.map_err(io::Error::other)?; if !entry.file_type().is_file() || !is_markdown_note_path(entry.path()) { continue; @@ -2030,6 +1581,9 @@ impl MemoFile { .map_err(io::Error::other)?; drop(stmt); for relative in indexed { + if !keep_running() { + return Err(io::Error::new(io::ErrorKind::Interrupted, "index maintenance cancelled")); + } if !seen.contains(&relative) { let candidate = notebook_path_from_relative(&root, &relative).map_err(io::Error::other)?; // The scan only nominates stale rows. A file may have appeared @@ -2121,6 +1675,9 @@ impl MemoFile { Err(error) => return Err(error), }; if missing || policy.is_index_ignored_at(root, Path::new(&relative)) { + if !root.is_dir() { + return Err(io::Error::new(io::ErrorKind::NotFound, "notebook directory unavailable")); + } tx.execute( "DELETE FROM note_search_fts WHERE relative_path = ?1", params![relative], @@ -2185,7 +1742,6 @@ impl MemoFile { .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let title = display_title_from_relative_path(&relative); let mut derived = Memo { - id: String::new(), filename: relative.rsplit('/').next().unwrap_or(&relative).to_owned(), relative_path: relative.clone(), preview: String::new(), @@ -2202,13 +1758,14 @@ impl MemoFile { }; super::apply_derived_memo_fields(&mut derived, &content); tx.execute( - "INSERT INTO notes (relative_path, size_bytes, modified_ns, content_hash, title, preview, thumbnail, properties_json, indexed_at, created_at, updated_at, favorited, icon, colors_json, filename, filename_lower) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) \ + "INSERT INTO notes (relative_path, size_bytes, modified_ns, content_hash, title, preview, thumbnail, properties_json, indexed_at, created_at, updated_at, favorited, icon, colors_json, filename, filename_lower, tags, todos, agents) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) \ ON CONFLICT(relative_path) DO UPDATE SET size_bytes=excluded.size_bytes, modified_ns=excluded.modified_ns, \ content_hash=excluded.content_hash, title=excluded.title, preview=excluded.preview, thumbnail=excluded.thumbnail, \ properties_json=excluded.properties_json, indexed_at=excluded.indexed_at, \ created_at=excluded.created_at, updated_at=excluded.updated_at, favorited=excluded.favorited, \ - icon=excluded.icon, colors_json=excluded.colors_json, filename=excluded.filename, filename_lower=excluded.filename_lower", + icon=excluded.icon, colors_json=excluded.colors_json, filename=excluded.filename, filename_lower=excluded.filename_lower, \ + tags=excluded.tags, todos=excluded.todos, agents=excluded.agents", params![relative, size, modified_ns, hash, title, derived.preview, derived.thumbnail, derived.properties.to_string(), chrono::Utc::now().timestamp_millis(), @@ -2217,20 +1774,11 @@ impl MemoFile { modified_ns.unwrap_or(0) / 1_000_000, i64::from(derived.favorited), derived.icon, serde_json::to_string(&derived.colors).map_err(io::Error::other)?, - derived.filename, derived.filename.to_lowercase()], + derived.filename, derived.filename.to_lowercase(), + serde_json::to_string(&derived.tags).map_err(io::Error::other)?, + serde_json::to_string(&derived.todos).map_err(io::Error::other)?, + serde_json::to_string(&derived.agents).map_err(io::Error::other)?], ).map_err(io::Error::other)?; - tx.execute( - "DELETE FROM note_tags WHERE relative_path = ?1", - params![relative], - ) - .map_err(io::Error::other)?; - for tag in &derived.tags { - tx.execute( - "INSERT INTO note_tags (relative_path, tag) VALUES (?1, ?2)", - params![relative, tag], - ) - .map_err(io::Error::other)?; - } tx.execute( "DELETE FROM note_search_fts WHERE relative_path = ?1", params![relative], @@ -2248,45 +1796,6 @@ impl MemoFile { ], ) .map_err(io::Error::other)?; - tx.execute( - "DELETE FROM note_todos WHERE relative_path = ?1", - params![relative], - ) - .map_err(io::Error::other)?; - for (position, todo) in derived.todos.iter().enumerate() { - let attributes = derived - .properties - .get("flowix_todo_metadata") - .and_then(|value| value.get(&todo.id)); - let string_field = |name| { - attributes - .and_then(|value| value.get(name)) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - }; - let number_field = |name| { - attributes - .and_then(|value| value.get(name)) - .and_then(serde_json::Value::as_i64) - .unwrap_or(0) - }; - tx.execute("INSERT INTO note_todos \ - (relative_path, position, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - params![relative, position as i64, todo.id, todo.content, todo.status, - string_field("priority"), string_field("timeRange"), string_field("owner"), - string_field("assignee"), number_field("createdAt"), number_field("updatedAt")]) - .map_err(io::Error::other)?; - } - tx.execute( - "DELETE FROM note_agents WHERE relative_path = ?1", - params![relative], - ) - .map_err(io::Error::other)?; - for (position, agent) in derived.agents.iter().enumerate() { - tx.execute("INSERT INTO note_agents (relative_path, position, thread_id, title, agent_type) VALUES (?1, ?2, ?3, ?4, ?5)", - params![relative, position as i64, agent.thread_id, agent.title, agent.agent_type]).map_err(io::Error::other)?; - } tx.commit().map_err(io::Error::other)?; if previous.is_some() { report.updated += 1; @@ -2297,13 +1806,44 @@ impl MemoFile { } pub(crate) fn open_note_index_connection(&self, notebook_id: &str) -> io::Result { + self.open_note_index_connection_inner(notebook_id, false) + } + + /// Upgrade an existing note index only from the version-initialization worker. + pub fn initialize_note_index_schema(&self, notebook_id: &str) -> io::Result<()> { + self.open_note_index_connection_inner(notebook_id, true)?; + Ok(()) + } + + fn open_note_index_connection_inner( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> io::Result { + let path = self.notebook_db_path(notebook_id)?; + if run_legacy_migrations { + super::with_database_rebuild(&path, || { + self.open_note_index_connection_unchecked(notebook_id, true) + }) + } else { + self.open_note_index_connection_unchecked(notebook_id, false) + } + } + + fn open_note_index_connection_unchecked( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> io::Result { let path = self.notebook_db_path(notebook_id)?; let mut conn = Connection::open(path).map_err(io::Error::other)?; conn.busy_timeout(std::time::Duration::from_secs(10)) .map_err(io::Error::other)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .map_err(io::Error::other)?; - discard_legacy_v2_note_projection(&mut conn)?; + if run_legacy_migrations { + discard_legacy_v2_note_projection(&mut conn)?; + } conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; \ CREATE TABLE IF NOT EXISTS note_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); \ CREATE TABLE IF NOT EXISTS notes (relative_path TEXT PRIMARY KEY, \ @@ -2311,20 +1851,8 @@ impl MemoFile { title TEXT NOT NULL, preview TEXT NOT NULL, thumbnail TEXT, properties_json TEXT NOT NULL, indexed_at INTEGER NOT NULL, \ created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, \ favorited INTEGER NOT NULL DEFAULT 0, icon TEXT, colors_json TEXT NOT NULL DEFAULT '[]', \ - filename TEXT NOT NULL DEFAULT '', filename_lower TEXT NOT NULL DEFAULT ''); \ - CREATE TABLE IF NOT EXISTS note_tags (relative_path TEXT NOT NULL REFERENCES notes(relative_path) ON DELETE CASCADE, \ - tag TEXT NOT NULL, PRIMARY KEY(relative_path, tag)); \ - CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag); \ - CREATE INDEX IF NOT EXISTS idx_notes_created_page ON notes(favorited DESC, created_at DESC, relative_path DESC); \ - CREATE INDEX IF NOT EXISTS idx_notes_updated_page ON notes(favorited DESC, updated_at DESC, relative_path DESC); \ - CREATE TABLE IF NOT EXISTS note_todos (relative_path TEXT NOT NULL REFERENCES notes(relative_path) ON DELETE CASCADE, \ - position INTEGER NOT NULL, todo_id TEXT NOT NULL, content TEXT NOT NULL, status TEXT NOT NULL, \ - priority TEXT NOT NULL DEFAULT '', time_range TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', \ - assignee TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, \ - PRIMARY KEY(relative_path, position)); \ - CREATE TABLE IF NOT EXISTS note_agents (relative_path TEXT NOT NULL REFERENCES notes(relative_path) ON DELETE CASCADE, \ - position INTEGER NOT NULL, thread_id TEXT NOT NULL, title TEXT NOT NULL, agent_type TEXT NOT NULL, \ - PRIMARY KEY(relative_path, position)); \ + filename TEXT NOT NULL DEFAULT '', filename_lower TEXT NOT NULL DEFAULT '', \ + tags TEXT NOT NULL DEFAULT '[]', todos TEXT NOT NULL DEFAULT '[]', agents TEXT NOT NULL DEFAULT '[]'); \ CREATE TABLE IF NOT EXISTS note_content_revisions (notebook_id TEXT NOT NULL, \ relative_path TEXT NOT NULL, content_hash TEXT NOT NULL, local_revision INTEGER NOT NULL, \ change_id TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(notebook_id, relative_path));") @@ -2336,55 +1864,62 @@ impl MemoFile { );", ) .map_err(io::Error::other)?; - for (column, definition) in [ - ("priority", "TEXT NOT NULL DEFAULT ''"), - ("time_range", "TEXT NOT NULL DEFAULT ''"), - ("owner", "TEXT NOT NULL DEFAULT ''"), - ("assignee", "TEXT NOT NULL DEFAULT ''"), - ("created_at", "INTEGER NOT NULL DEFAULT 0"), - ("updated_at", "INTEGER NOT NULL DEFAULT 0"), - ] { - let mut statement = conn - .prepare("PRAGMA table_info(note_todos)") - .map_err(io::Error::other)?; - let names = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - if !names.iter().any(|name| name == column) { - conn.execute_batch(&format!( - "ALTER TABLE note_todos ADD COLUMN {column} {definition}" - )) - .map_err(io::Error::other)?; + if run_legacy_migrations { + for (column, definition) in [ + ("thumbnail", "TEXT"), + ("created_at", "INTEGER NOT NULL DEFAULT 0"), + ("updated_at", "INTEGER NOT NULL DEFAULT 0"), + ("favorited", "INTEGER NOT NULL DEFAULT 0"), + ("icon", "TEXT"), + ("colors_json", "TEXT NOT NULL DEFAULT '[]'"), + ("filename", "TEXT NOT NULL DEFAULT ''"), + ("filename_lower", "TEXT NOT NULL DEFAULT ''"), + ("tags", "TEXT NOT NULL DEFAULT '[]'"), + ("todos", "TEXT NOT NULL DEFAULT '[]'"), + ("agents", "TEXT NOT NULL DEFAULT '[]'"), + ] { + let mut statement = conn + .prepare("PRAGMA table_info(notes)") + .map_err(io::Error::other)?; + let names = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(io::Error::other)? + .collect::, _>>() + .map_err(io::Error::other)?; + if !names.iter().any(|name| name == column) { + conn.execute_batch(&format!( + "ALTER TABLE notes ADD COLUMN {column} {definition}" + )) + .map_err(io::Error::other)?; + } } - } - for (column, definition) in [ - ("thumbnail", "TEXT"), - ("created_at", "INTEGER NOT NULL DEFAULT 0"), - ("updated_at", "INTEGER NOT NULL DEFAULT 0"), - ("favorited", "INTEGER NOT NULL DEFAULT 0"), - ("icon", "TEXT"), - ("colors_json", "TEXT NOT NULL DEFAULT '[]'"), - ("filename", "TEXT NOT NULL DEFAULT ''"), - ("filename_lower", "TEXT NOT NULL DEFAULT ''"), - ] { - let mut statement = conn - .prepare("PRAGMA table_info(notes)") - .map_err(io::Error::other)?; - let names = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - if !names.iter().any(|name| name == column) { - conn.execute_batch(&format!( - "ALTER TABLE notes ADD COLUMN {column} {definition}" - )) + // Older path projections stored derived values in child tables. + // Markdown is authoritative, so do not copy these rows into the + // new projection. Force a full parser pass before any query can + // rely on the replacement columns, then discard the old tables. + let has_table = |name: &str| -> io::Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [name], + |row| row.get(0), + ).map_err(io::Error::other) + }; + let has_legacy_derived_tables = ["note_tags", "note_todos", "note_agents"] + .into_iter() + .try_fold(false, |found, table| Ok::<_, io::Error>(found || has_table(table)?))?; + if has_legacy_derived_tables { + conn.execute( + "DELETE FROM note_index_meta WHERE key='parser_version'", + [], + ) .map_err(io::Error::other)?; } + conn.execute_batch("DROP TABLE IF EXISTS note_tags; DROP TABLE IF EXISTS note_todos; DROP TABLE IF EXISTS note_agents;") + .map_err(io::Error::other)?; } - conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_filename_page ON notes(favorited DESC, filename_lower ASC, filename ASC, relative_path ASC);") + conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_created_page ON notes(favorited DESC, created_at DESC, relative_path DESC); \ + CREATE INDEX IF NOT EXISTS idx_notes_updated_page ON notes(favorited DESC, updated_at DESC, relative_path DESC); \ + CREATE INDEX IF NOT EXISTS idx_notes_filename_page ON notes(favorited DESC, filename_lower ASC, filename ASC, relative_path ASC);") .map_err(io::Error::other)?; conn.execute( "INSERT INTO note_index_meta (key, value) VALUES ('schema_version', ?1) \ @@ -2400,10 +1935,18 @@ impl MemoFile { ) .map_err(io::Error::other)?; if stored_version != SCHEMA_VERSION.to_string() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unsupported Note index schema version: {stored_version}"), - )); + let legacy_version = stored_version.parse::().unwrap_or(i64::MAX); + if !run_legacy_migrations || legacy_version > SCHEMA_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported Note index schema version: {stored_version}"), + )); + } + conn.execute( + "UPDATE note_index_meta SET value = ?1 WHERE key = 'schema_version'", + params![SCHEMA_VERSION.to_string()], + ) + .map_err(io::Error::other)?; } let stored_notebook: Option = conn .query_row( @@ -2471,6 +2014,82 @@ mod tests { use super::*; use crate::memo_file::NotebookConfig; + fn background_fixture() -> (tempfile::TempDir, MemoFile, std::path::PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notebook"); fs::create_dir_all(&root).unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.initialize_current_registry_storage().unwrap(); + memo.write_notebook_configs(&[NotebookConfig { + id: "background".into(), name: "Background".into(), icon: None, + path: root.to_string_lossy().into_owned(), is_default: true, + sort: 0, created_at: 0, updated_at: 0, + }]).unwrap(); + (temp, memo, root) + } + + #[test] + fn cached_counts_do_not_create_or_scan_a_cold_notebook() { + let (_temp, memo, root) = background_fixture(); + fs::write(root.join("a.md"), "untouched").unwrap(); + assert_eq!(memo.cached_note_count("background").unwrap(), None); + assert!(!root.join(".flowix/notebook.db").exists()); + } + + #[test] + fn cached_counts_treat_an_old_database_without_projection_as_unknown() { + let (_temp, memo, _root) = background_fixture(); + let path = memo.notebook_db_path("background").unwrap(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("CREATE TABLE legacy_data (value TEXT); INSERT INTO legacy_data VALUES ('keep');").unwrap(); + assert_eq!(memo.cached_note_count("background").unwrap(), None); + let value: String = conn.query_row("SELECT value FROM legacy_data", [], |row| row.get(0)).unwrap(); + assert_eq!(value, "keep"); + let tables: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE name='notes'", [], |row| row.get(0)).unwrap(); + assert_eq!(tables, 0); + } + + #[test] + fn background_scan_reports_busy_instead_of_claiming_completion() { + let (_temp, memo, _root) = background_fixture(); + let _guard = memo.operation_locks().notebook_maintenance("background", "test_busy").unwrap(); + assert_eq!(memo.reconcile_note_index_background("background", &|| true).unwrap_err().kind(), io::ErrorKind::WouldBlock); + // Preserve the older passive API's documented skip behavior. + assert_eq!(memo.reconcile_note_index("background").unwrap(), NoteIndexReconcileReport::default()); + } + + #[test] + fn cold_tag_queries_request_preparation_without_scanning() { + let (_temp, memo, root) = background_fixture(); + fs::write(root.join("a.md"), "#tag\nbody").unwrap(); + assert_eq!(memo.read_tag_usage_summary_for_notebook_id(Some("background")).unwrap_err().kind(), io::ErrorKind::WouldBlock); + assert_eq!(memo.read_tag_prefix_counts_for_notebook_id(Some("background")).unwrap_err().kind(), io::ErrorKind::WouldBlock); + assert_eq!(memo.note_count("background").unwrap(), 0); + assert_eq!(memo.cached_note_count("background").unwrap(), None); + } + + #[test] + fn cancelled_cold_index_can_resume_without_changing_source_files() { + let (_temp, memo, root) = background_fixture(); + fs::write(root.join("a.md"), "# Keep\nbody").unwrap(); + assert_eq!(memo.reconcile_note_index_background("background", &|| false).unwrap_err().kind(), io::ErrorKind::Interrupted); + assert_eq!(memo.cached_note_count("background").unwrap(), None); + memo.reconcile_note_index_background("background", &|| true).unwrap(); + assert_eq!(memo.cached_note_count("background").unwrap(), Some(1)); + assert_eq!(fs::read_to_string(root.join("a.md")).unwrap(), "# Keep\nbody"); + } + + #[test] + fn cancelled_warm_refresh_keeps_the_previous_projection_readable() { + let (_temp, memo, root) = background_fixture(); + fs::write(root.join("a.md"), "a").unwrap(); + memo.reconcile_note_index_background("background", &|| true).unwrap(); + fs::write(root.join("b.md"), "b").unwrap(); + assert!(memo.reconcile_note_index_background("background", &|| false).is_err()); + assert_eq!(memo.read_note_entries("background").unwrap().len(), 1); + memo.reconcile_note_index_background("background", &|| true).unwrap(); + assert_eq!(memo.read_note_entries("background").unwrap().len(), 2); + } + #[test] fn parser_upgrade_repairs_null_tag_properties_without_changing_note() { let temp = tempfile::tempdir().unwrap(); @@ -2523,15 +2142,17 @@ mod tests { INSERT INTO v2_notes VALUES ('old.md'); CREATE VIRTUAL TABLE v2_note_search USING fts5(body); INSERT INTO v2_note_search (body) VALUES ('old'); - CREATE TABLE memos (id TEXT PRIMARY KEY); - INSERT INTO memos VALUES ('legacy-id');").unwrap(); + CREATE TABLE memos (relative_path TEXT PRIMARY KEY); + INSERT INTO memos VALUES ('legacy-note.md');").unwrap(); drop(conn); let conn = store.open_note_index_connection("nb_test").unwrap(); let note: String = conn.query_row("SELECT relative_path FROM notes", [], |row| row.get(0)).unwrap(); assert_eq!(note, "current.md"); - let legacy: String = conn.query_row("SELECT id FROM memos", [], |row| row.get(0)).unwrap(); - assert_eq!(legacy, "legacy-id"); + let memo_path: String = conn + .query_row("SELECT relative_path FROM memos", [], |row| row.get(0)) + .unwrap(); + assert_eq!(memo_path, "legacy-note.md"); let old_tables: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'v2_%'", [], |row| row.get(0)).unwrap(); assert_eq!(old_tables, 0); } @@ -2677,10 +2298,6 @@ mod tests { ) .unwrap(); assert_eq!(legacy_tables, 0); - assert!(store - .list_note_entries_with_legacy_ids("nb_test") - .unwrap() - .is_none()); let path_entries = store.read_note_entries("nb_test").unwrap(); assert_eq!(path_entries.len(), 1); assert_eq!(path_entries[0].relative_path, "sub/a.md"); @@ -2705,14 +2322,14 @@ mod tests { ); let tags: i64 = conn .query_row( - "SELECT COUNT(*) FROM note_tags WHERE tag = 'work'", + "SELECT COUNT(*) FROM notes n, json_each(n.tags) t WHERE t.value = 'work'", [], |row| row.get(0), ) .unwrap(); assert_eq!(tags, 1); drop(conn); - assert_eq!(store.memo_counts_by_notebook().unwrap()["nb_test"], 1); + assert_eq!(store.cached_note_count("nb_test").unwrap(), Some(1)); assert_eq!( store .occupied_filenames_in_directory("nb_test", Some("sub")) @@ -2775,52 +2392,6 @@ mod tests { .is_none()); } - #[test] - fn legacy_revisions_backfill_into_the_path_keyed_table_once() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("notebook"); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("Legacy.md"), "# Legacy\n").unwrap(); - let mut store = MemoFile::new(temp.path().join("config")); - store - .write_notebook_configs(&[NotebookConfig { - id: "nb_test".into(), - name: "Test".into(), - icon: None, - path: root.to_string_lossy().into_owned(), - is_default: true, - sort: 0, - created_at: 0, - updated_at: 0, - }]) - .unwrap(); - store.set_current_notebook(Some("nb_test".into())); - // Register the file in the legacy id-keyed cache, then commit a - // revision through the legacy API. - let memo = store - .register_existing_file_for_notebook_id("nb_test", &root.join("Legacy.md")) - .map_err(|error| error.to_string()) - .unwrap(); - store - .commit_memo_content_revision(&memo.id, "nb_test", "hash-legacy", "change-legacy") - .unwrap(); - - // First path-keyed read migrates the legacy row. - let revision = store - .read_note_content_revision_for_notebook("nb_test", "Legacy.md") - .unwrap() - .expect("legacy revision must be visible through the path key"); - assert_eq!(revision.revision, 1); - assert_eq!(revision.change_id, "change-legacy"); - - // A new path-keyed commit continues the same stream. - let commit = store - .commit_note_content_revision("nb_test", "Legacy.md", "hash-new", "change-new") - .unwrap(); - assert!(commit.changed); - assert_eq!(commit.state.revision, 2); - } - #[test] fn moves_note_by_path_without_a_legacy_memo_row() { let temp = tempfile::tempdir().unwrap(); @@ -2872,7 +2443,7 @@ mod tests { } #[test] - fn note_projection_coexists_with_legacy_rows_in_the_same_notebook_db() { + fn note_projection_coexists_with_path_keyed_memo_rows_in_the_same_notebook_db() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("notebook"); fs::create_dir_all(&root).unwrap(); @@ -2903,10 +2474,10 @@ mod tests { // parser version and reparses that row once. assert_eq!(store.reconcile_note_index("nb_test").unwrap().updated, 1); let conn = store.open_note_index_connection("nb_test").unwrap(); - let legacy_id: String = conn - .query_row("SELECT id FROM memos", [], |row| row.get(0)) + let memo_path: String = conn + .query_row("SELECT relative_path FROM memos", [], |row| row.get(0)) .unwrap(); - assert_eq!(legacy_id, created.id); + assert_eq!(memo_path, created.relative_path); let note_path: String = conn .query_row("SELECT relative_path FROM notes", [], |row| row.get(0)) .unwrap(); @@ -2914,23 +2485,16 @@ mod tests { drop(conn); let conn = store.open_memo_index_db_for_notebook_id("nb_test").unwrap(); conn.execute( - "UPDATE memos SET preview='stale legacy preview' WHERE id=?1", - params![created.id], + "UPDATE memos SET preview='stale memo preview' WHERE relative_path=?1", + params![created.relative_path], ) .unwrap(); drop(conn); - let listed = store - .list_note_entries_with_legacy_ids("nb_test") - .unwrap() - .unwrap(); - assert_eq!(listed.len(), 1); - assert_ne!(listed[0].preview, "stale legacy preview"); - assert_eq!(listed[0].id, created.id); let mut service = crate::service::NoteService::new(&store); let service_list = service.list("nb_test").unwrap(); - assert_ne!(service_list[0].preview, "stale legacy preview"); + assert_ne!(service_list[0].preview, "stale memo preview"); store - .write_memo(&created.id, "# Changed\nnew body\n") + .write_memo(&created.relative_path, "# Changed\nnew body\n") .unwrap(); let conn = store.open_note_index_connection("nb_test").unwrap(); let title: String = conn @@ -2938,7 +2502,9 @@ mod tests { .unwrap(); assert_eq!(title, "Legacy and Note"); drop(conn); - store.delete_memo_result_global(&created.id).unwrap(); + store + .delete_memo_result_global(&created.relative_path) + .unwrap(); let conn = store.open_note_index_connection("nb_test").unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM notes", [], |row| row.get(0)) @@ -2946,179 +2512,5 @@ mod tests { assert_eq!(count, 0); } - #[test] - fn legacy_task_attributes_survive_rebuilding_the_note_projection() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("notebook"); - fs::create_dir_all(&root).unwrap(); - let mut store = MemoFile::new(temp.path().join("config")); - store - .write_notebook_configs(&[NotebookConfig { - id: "nb_test".into(), - name: "Test".into(), - icon: None, - path: root.to_string_lossy().into_owned(), - is_default: true, - sort: 0, - created_at: 0, - updated_at: 0, - }]) - .unwrap(); - store.set_current_notebook(Some("nb_test".into())); - let created = store - .create_memo("Tasks", "- [ ] keep this task\n", None) - .unwrap(); - let conn = store.open_memo_index_db_for_notebook_id("nb_test").unwrap(); - conn.execute( - "UPDATE memo_todos SET priority='high', owner='me', created_at=11 WHERE memo_id=?1", - params![created.id], - ) - .unwrap(); - drop(conn); - let report = store - .migrate_note_todo_metadata_for_notebook("nb_test") - .unwrap(); - assert_eq!(report.notes_written, 1); - assert_eq!(report.tasks_written, 1); - assert_eq!( - store - .migrate_note_todo_metadata_for_notebook("nb_test") - .unwrap() - .notes_written, - 0 - ); - let content = fs::read_to_string(root.join(&created.relative_path)).unwrap(); - assert!(content.contains("flowix_todo_metadata:")); - store.reconcile_note_index("nb_test").unwrap(); - let conn = store.open_note_index_connection("nb_test").unwrap(); - let (priority, owner, created_at): (String, String, i64) = conn - .query_row( - "SELECT priority, owner, created_at FROM note_todos", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .unwrap(); - assert_eq!( - (priority.as_str(), owner.as_str(), created_at), - ("high", "me", 11) - ); - conn.execute("DELETE FROM notes", []).unwrap(); - drop(conn); - store.reconcile_note_index("nb_test").unwrap(); - let conn = store.open_note_index_connection("nb_test").unwrap(); - let priority: String = conn - .query_row("SELECT priority FROM note_todos", [], |row| row.get(0)) - .unwrap(); - assert_eq!(priority, "high"); - } - - #[test] - fn legacy_note_properties_survive_rebuilding_and_file_values_win() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("notebook"); - fs::create_dir_all(&root).unwrap(); - let mut store = MemoFile::new(temp.path().join("config")); - store - .write_notebook_configs(&[NotebookConfig { - id: "nb_test".into(), - name: "Test".into(), - icon: None, - path: root.to_string_lossy().into_owned(), - is_default: true, - sort: 0, - created_at: 0, - updated_at: 0, - }]) - .unwrap(); - store.set_current_notebook(Some("nb_test".into())); - let created = store - .create_memo( - "Properties", - "---\nflowix_icon: file-icon\n---\n# Properties\n", - None, - ) - .unwrap(); - let conn = store.open_memo_index_db_for_notebook_id("nb_test").unwrap(); - conn.execute( - "UPDATE memos SET created_at=123, favorited=1, icon='index-icon' WHERE id=?1", - params![created.id], - ) - .unwrap(); - conn.execute( - "INSERT INTO memo_colors (memo_id,color,position) VALUES (?1,'blue',0)", - params![created.id], - ) - .unwrap(); - drop(conn); - let report = store - .migrate_note_properties_for_notebook("nb_test") - .unwrap(); - assert_eq!(report.notes_written, 1); - let content = fs::read_to_string(root.join(&created.relative_path)).unwrap(); - let properties = super::super::extract_document_metadata(&content) - .unwrap() - .properties; - assert!(properties.get("flowix_created_at").is_none()); - assert_eq!(properties["flowix_favorited"], true); - assert_eq!(properties["flowix_icon"], "file-icon"); - assert_eq!(properties["flowix_colors"], serde_json::json!(["blue"])); - assert_eq!( - store - .migrate_note_properties_for_notebook("nb_test") - .unwrap() - .notes_written, - 0 - ); - store.reconcile_note_index("nb_test").unwrap(); - let conn = store.open_note_index_connection("nb_test").unwrap(); - let (created_at, icon): (i64, String) = conn - .query_row("SELECT created_at, icon FROM notes", [], |row| { - Ok((row.get(0)?, row.get(1)?)) - }) - .unwrap(); - assert_eq!((created_at, icon.as_str()), (123, "file-icon")); - } - - #[test] - fn legacy_creation_time_stays_in_index_without_changing_markdown() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("notebook"); - fs::create_dir_all(&root).unwrap(); - let mut store = MemoFile::new(temp.path().join("config")); - store - .write_notebook_configs(&[NotebookConfig { - id: "nb_test".into(), - name: "Test".into(), - icon: None, - path: root.to_string_lossy().into_owned(), - is_default: true, - sort: 0, - created_at: 0, - updated_at: 0, - }]) - .unwrap(); - store.set_current_notebook(Some("nb_test".into())); - let created = store.create_memo("Plain", "# Plain\n", None).unwrap(); - let path = root.join(&created.relative_path); - let original = fs::read(&path).unwrap(); - let conn = store.open_memo_index_db_for_notebook_id("nb_test").unwrap(); - conn.execute( - "UPDATE memos SET created_at=123 WHERE id=?1", - params![created.id], - ) - .unwrap(); - drop(conn); - - let report = store - .migrate_note_properties_for_notebook("nb_test") - .unwrap(); - assert_eq!(report.notes_written, 0); - assert_eq!(fs::read(&path).unwrap(), original); - let conn = store.open_note_index_connection("nb_test").unwrap(); - let created_at: i64 = conn - .query_row("SELECT created_at FROM notes", [], |row| row.get(0)) - .unwrap(); - assert_eq!(created_at, 123); - } } diff --git a/app/flowix-core/src/memo_file/notebook.rs b/app/flowix-core/src/memo_file/notebook.rs index 1515a65d8..118a6dca2 100644 --- a/app/flowix-core/src/memo_file/notebook.rs +++ b/app/flowix-core/src/memo_file/notebook.rs @@ -1,19 +1,20 @@ //! Notebook registry storage. //! -//! The authoritative notebook registry lives in `index.db` under the user -//! config directory (`~/.flowix/index.db` in production). Notebook-owned note +//! The authoritative notebook registry lives in `db/local.db` under the user +//! config directory (`~/.flowix/db/local.db` in production). Notebook-owned note //! and media data live separately in `/.flowix/notebook.db`. +use rusqlite::{params, Connection, OptionalExtension, Row}; use std::collections::HashSet; use std::fs; use std::path::PathBuf; -use rusqlite::{params, Connection, OptionalExtension, Row}; use super::file_io::atomic_write_bytes; +use super::types::NotebookSetupReport; use super::types::{ NotebookConfig, NotebookManifest, NotebookSetupJob, NotebookSetupJobStatus, + NotebookSetupSnapshot, NotebookSetupStage, }; -use super::types::NotebookSetupReport; use super::MemoFile; pub(super) fn sqlite_to_io(error: rusqlite::Error) -> std::io::Error { @@ -30,6 +31,25 @@ fn notebook_setup_status_as_str(status: NotebookSetupJobStatus) -> &'static str } } +fn notebook_setup_stage_as_str(stage: NotebookSetupStage) -> &'static str { + match stage { + NotebookSetupStage::Preparing => "preparing", + NotebookSetupStage::Writing => "writing", + NotebookSetupStage::Indexing => "indexing", + NotebookSetupStage::Completed => "completed", + } +} + +fn notebook_setup_stage_from_str(stage: &str) -> Option { + match stage { + "preparing" | "queued" => Some(NotebookSetupStage::Preparing), + "writing" | "template" => Some(NotebookSetupStage::Writing), + "indexing" => Some(NotebookSetupStage::Indexing), + "completed" => Some(NotebookSetupStage::Completed), + _ => None, + } +} + fn map_notebook_setup_job(row: &Row<'_>) -> rusqlite::Result { let status: String = row.get(2)?; let status = match status.as_str() { @@ -40,23 +60,57 @@ fn map_notebook_setup_job(row: &Row<'_>) -> rusqlite::Result { "failed" => NotebookSetupJobStatus::Failed, _ => return Err(rusqlite::Error::InvalidQuery), }; - let completed_files: i64 = row.get(4)?; - let total_files: i64 = row.get(5)?; - let report_json: Option = row.get(8)?; + let report_json: Option = row.get(6)?; + let stage: String = row.get(3)?; + let stage = notebook_setup_stage_from_str(&stage).ok_or(rusqlite::Error::InvalidQuery)?; Ok(NotebookSetupJob { notebook_id: row.get(0)?, template_id: row.get(1)?, status, - stage: row.get(3)?, - completed_files: completed_files.max(0) as usize, - total_files: total_files.max(0) as usize, - message: row.get(6)?, - report: report_json.and_then(|json| serde_json::from_str::(&json).ok()), - updated_at: row.get(7)?, - overwrite_existing: row.get::<_, i64>(9)? != 0, + stage, + message: row.get(4)?, + report: report_json + .and_then(|json| serde_json::from_str::(&json).ok()), + updated_at: row.get(5)?, + overwrite_existing: row.get::<_, i64>(7)? != 0, + revision: row.get(8)?, + run_id: row.get(9)?, + job_id: row.get(10)?, + execution_requested: row.get::<_, i64>(11)? != 0, }) } +fn next_notebook_setup_revision( + tx: &rusqlite::Transaction<'_>, + updated_at: i64, +) -> std::io::Result { + tx.execute( + "INSERT OR IGNORE INTO app_state (key, value, updated_at) VALUES ('notebook_setup_revision', '0', ?1)", + [updated_at], + ) + .map_err(sqlite_to_io)?; + tx.execute( + "UPDATE app_state SET value = CAST(COALESCE(value, '0') AS INTEGER) + 1, updated_at = ?1 WHERE key = 'notebook_setup_revision'", + [updated_at], + ) + .map_err(sqlite_to_io)?; + tx.query_row( + "SELECT CAST(COALESCE(value, '0') AS INTEGER) FROM app_state WHERE key = 'notebook_setup_revision'", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io) +} + +fn current_notebook_setup_revision(conn: &Connection) -> std::io::Result { + conn.query_row( + "SELECT CAST(COALESCE(value, '0') AS INTEGER) FROM app_state WHERE key = 'notebook_setup_revision'", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io) +} + impl MemoFile { pub const NOTEBOOK_MANIFEST_VERSION: u32 = 1; @@ -164,11 +218,7 @@ impl MemoFile { scene_id: &str, ) -> std::io::Result<()> { Self::ensure_notebook_manifest(config)?; - Self::set_notebook_scene_id( - std::path::Path::new(&config.path), - &config.id, - scene_id, - )?; + Self::set_notebook_scene_id(std::path::Path::new(&config.path), &config.id, scene_id)?; Ok(()) } @@ -189,8 +239,7 @@ impl MemoFile { Ok(flowix_dir.join("notebook.db")) } - /// Device-local notebook registry database. The on-disk name remains - /// `index.db` for compatibility; it stores registry state, not the active + /// Device-local notebook registry database. It stores registry state, not the active /// notebook's note catalog. pub fn registry_db_path(&self) -> PathBuf { self.registry.db_path() @@ -218,6 +267,65 @@ impl MemoFile { Ok(()) } + /// Prepare the current registry schema and migrate its database directory. + pub fn initialize_registry_storage(&self) -> std::io::Result<()> { + super::database_layout::prepare_database_directory( + &self.registry.config_dir, + &["local.db"], + )?; + self.initialize_current_registry_storage() + } + + /// Prepare only the current store. A completed installation must not inspect + /// or copy legacy databases when a runtime dependency needs rebuilding. + pub fn initialize_current_registry_storage(&self) -> std::io::Result<()> { + super::with_database_rebuild(&self.registry_db_path(), || { + let connection = self.registry.open_for_initialization()?; + self.workspace_connection()?; + self.read_notebook_configs()?; + self.read_selected_notebook_id()?; + Ok(connection) + })?; + Ok(()) + } + + /// Read a completion marker from the device-local migration journal. + pub fn local_migration_completed(&self, key: &str) -> std::io::Result { + let connection = self.registry.open()?; + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM local_migrations WHERE migration_key = ?1)", + [key], + |row| row.get(0), + ) + .map_err(sqlite_to_io) + } + + /// Record a completed device-local migration step. + pub fn mark_local_migration_completed(&self, key: &str) -> std::io::Result<()> { + let connection = self.registry.open()?; + connection + .execute( + "INSERT OR IGNORE INTO local_migrations (migration_key, completed_at) VALUES (?1, CAST(strftime('%s','now') AS INTEGER) * 1000)", + [key], + ) + .map_err(sqlite_to_io)?; + Ok(()) + } + + /// Whether a registered notebook still needs its legacy storage decision. + /// Offline notebooks remain pending until their paths can be inspected. + pub fn has_pending_notebook_storage_initialization(&self) -> std::io::Result { + for notebook in self.read_notebook_configs()? { + for job in super::NotebookInitializationJob::ALL { + if !self.local_migration_completed(&job.key(¬ebook.id))? { + return Ok(true); + } + } + } + Ok(false) + } + pub(super) fn open_registry_db(&self) -> std::io::Result { self.registry.open() } @@ -304,7 +412,7 @@ impl MemoFile { FROM notebooks -- 用户拖拽顺序优先, 然后用 created_at + name 兜底 (旧库未 -- normalize 时 sort 全部一致, 仍能保持稳定顺序)。 - ORDER BY sort ASC, created_at ASC, name COLLATE NOCASE ASC + ORDER BY pinned DESC, sort ASC, created_at ASC, name COLLATE NOCASE ASC "#, ) .map_err(sqlite_to_io)?; @@ -336,6 +444,40 @@ impl MemoFile { Ok(configs) } + /// Read device-local pin state for the global notebook selector. + pub fn read_notebook_pin_states(&self) -> std::io::Result> { + let conn = self.open_registry_db()?; + let mut stmt = conn + .prepare("SELECT id FROM notebooks WHERE pinned = 1") + .map_err(sqlite_to_io)?; + let pinned_ids = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(sqlite_to_io)? + .collect::, _>>() + .map_err(sqlite_to_io)?; + Ok(pinned_ids) + } + + /// Update a notebook's device-local pin state without changing its drag order. + pub fn set_notebook_pinned(&self, notebook_id: &str, pinned: bool) -> std::io::Result { + let conn = self.open_registry_db()?; + let changed = conn + .execute( + "UPDATE notebooks SET pinned = ?1 WHERE id = ?2", + params![if pinned { 1 } else { 0 }, notebook_id], + ) + .map_err(sqlite_to_io)? + > 0; + if changed { + *self + .registry + .configs_cache + .write() + .expect("notebook_configs_cache poisoned") = None; + } + Ok(changed) + } + /// Registered notebook directories. pub fn registered_notebook_paths(&self) -> Vec { self.read_notebook_configs() @@ -345,13 +487,89 @@ impl MemoFile { .collect() } - /// Synchronize notebook rows in `index.db` without deleting unchanged + /// Synchronize notebook rows in `local.db` without deleting unchanged /// notebook ids. Deleting and reinserting every row would trigger /// `ON DELETE CASCADE` on memo rows for notebooks that still exist. pub fn write_notebook_configs(&self, notebooks: &[NotebookConfig]) -> std::io::Result<()> { self.write_notebook_configs_with_setup_job(notebooks, None) } + /// Drop legacy notebook display, setup, and external-directory settings + /// while keeping the registry identities and their filesystem paths. + /// The folder name becomes the display name, icons are cleared, and order + /// is rebuilt from the stable path order. + pub fn reset_legacy_notebook_settings_for_initialization(&self) -> std::io::Result<()> { + let mut conn = self.open_registry_db()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + tx.execute_batch( + "DROP TABLE IF EXISTS notebook_add_dirs; + DROP TABLE IF EXISTS notebook_workspace_state; + CREATE TABLE notebook_workspace_state ( + notebook_id TEXT PRIMARY KEY REFERENCES notebooks(id) ON DELETE CASCADE, + revision INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE notebook_add_dirs ( + id TEXT NOT NULL, + notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, + path TEXT NOT NULL, + path_key TEXT NOT NULL, + label TEXT NOT NULL, + enabled INTEGER NOT NULL, + sort INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(notebook_id,id), + UNIQUE(notebook_id,path_key) + );", + ) + .map_err(sqlite_to_io)?; + let mut notebooks = { + let mut statement = tx + .prepare("SELECT id, path FROM notebooks") + .map_err(sqlite_to_io)?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + notebooks.sort_by_key(|(_, path)| path.to_lowercase()); + for (index, (id, path)) in notebooks.iter().enumerate() { + let display_name = std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("笔记本"); + tx.execute( + "UPDATE notebooks SET name=?1, icon=NULL, sort=?2 WHERE id=?3", + rusqlite::params![display_name, (index as i64 + 1) * 10, id], + ) + .map_err(sqlite_to_io)?; + } + + tx.execute("DELETE FROM notebook_setup_jobs", []) + .map_err(sqlite_to_io)?; + tx.execute( + "INSERT INTO notebook_workspace_state (notebook_id, revision) + SELECT id, 1 FROM notebooks", + [], + ) + .map_err(sqlite_to_io)?; + tx.execute("DROP TABLE IF EXISTS data_migration_state", []) + .map_err(sqlite_to_io)?; + tx.commit().map_err(sqlite_to_io)?; + + *self + .registry + .configs_cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + Ok(()) + } + /// Atomically persist notebook registration and its optional first-open /// setup job. This closes the crash window between creating a notebook and /// remembering which template it should receive. @@ -376,7 +594,9 @@ impl MemoFile { } } let mut conn = self.open_registry_db()?; - let tx = conn.transaction().map_err(sqlite_to_io)?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; { let mut stmt = tx .prepare( @@ -421,14 +641,41 @@ impl MemoFile { .map_err(sqlite_to_io)?; drop(stmt); + let mut removed_notebook = false; for id in existing_ids { if !keep_ids.contains(id.as_str()) { tx.execute("DELETE FROM notebooks WHERE id = ?1", params![id]) .map_err(sqlite_to_io)?; + removed_notebook = true; } } + if removed_notebook { + next_notebook_setup_revision(&tx, chrono::Utc::now().timestamp_millis())?; + } + if let Some(job) = setup_job { + if job.job_id.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "notebook setup job identity is missing", + )); + } + let existing_status: Option = tx + .query_row( + "SELECT status FROM notebook_setup_jobs WHERE notebook_id = ?1", + [&job.notebook_id], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_to_io)?; + if existing_status.as_deref() == Some("running") { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "NOTEBOOK_TEMPLATE_SETUP_IN_PROGRESS", + )); + } + let revision = next_notebook_setup_revision(&tx, job.updated_at)?; let status = notebook_setup_status_as_str(job.status); let report_json = job .report @@ -439,30 +686,34 @@ impl MemoFile { tx.execute( r#" INSERT INTO notebook_setup_jobs - (notebook_id, template_id, status, stage, completed_files, total_files, message, report_json, updated_at, overwrite_existing) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + (notebook_id, template_id, status, stage, message, report_json, updated_at, overwrite_existing, revision, run_id, job_id, execution_requested) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(notebook_id) DO UPDATE SET template_id = excluded.template_id, status = excluded.status, stage = excluded.stage, - completed_files = excluded.completed_files, - total_files = excluded.total_files, message = excluded.message, report_json = excluded.report_json, updated_at = excluded.updated_at, - overwrite_existing = excluded.overwrite_existing + overwrite_existing = excluded.overwrite_existing, + revision = excluded.revision, + run_id = excluded.run_id, + job_id = excluded.job_id, + execution_requested = excluded.execution_requested "#, params![ job.notebook_id, job.template_id, status, - job.stage, - job.completed_files as i64, - job.total_files as i64, + notebook_setup_stage_as_str(job.stage), job.message, report_json, job.updated_at, if job.overwrite_existing { 1 } else { 0 }, + revision, + job.run_id, + job.job_id, + if job.execution_requested { 1 } else { 0 }, ], ) .map_err(sqlite_to_io)?; @@ -478,11 +729,14 @@ impl MemoFile { Ok(()) } - pub fn get_notebook_setup_job(&self, notebook_id: &str) -> std::io::Result> { + pub fn get_notebook_setup_job( + &self, + notebook_id: &str, + ) -> std::io::Result> { let conn = self.open_registry_db()?; conn.query_row( - r#"SELECT notebook_id, template_id, status, stage, completed_files, - total_files, message, updated_at, report_json, overwrite_existing + r#"SELECT notebook_id, template_id, status, stage, message, updated_at, + report_json, overwrite_existing, revision, run_id, job_id, execution_requested FROM notebook_setup_jobs WHERE notebook_id = ?1"#, [notebook_id], map_notebook_setup_job, @@ -495,8 +749,8 @@ impl MemoFile { let conn = self.open_registry_db()?; let mut statement = conn .prepare( - r#"SELECT notebook_id, template_id, status, stage, completed_files, - total_files, message, updated_at, report_json, overwrite_existing + r#"SELECT notebook_id, template_id, status, stage, message, updated_at, + report_json, overwrite_existing, revision, run_id, job_id, execution_requested FROM notebook_setup_jobs"#, ) .map_err(sqlite_to_io)?; @@ -506,45 +760,138 @@ impl MemoFile { rows.collect::, _>>().map_err(sqlite_to_io) } - pub fn write_notebook_setup_job(&self, job: &NotebookSetupJob) -> std::io::Result<()> { - let conn = self.open_registry_db()?; + pub fn get_notebook_setup_snapshot(&self) -> std::io::Result { + let mut conn = self.open_registry_db()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Deferred) + .map_err(sqlite_to_io)?; + let revision = current_notebook_setup_revision(&tx)?; + let jobs = { + let mut statement = tx + .prepare( + r#"SELECT notebook_id, template_id, status, stage, message, updated_at, + report_json, overwrite_existing, revision, run_id, job_id, execution_requested + FROM notebook_setup_jobs"#, + ) + .map_err(sqlite_to_io)?; + let rows = statement + .query_map([], map_notebook_setup_job) + .map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + tx.commit().map_err(sqlite_to_io)?; + Ok(NotebookSetupSnapshot { revision, jobs }) + } + + pub fn write_notebook_setup_job( + &self, + job: &NotebookSetupJob, + ) -> std::io::Result { + self.write_notebook_setup_job_with_run_transition(job, None) + } + + pub fn start_notebook_setup_job( + &self, + job: &NotebookSetupJob, + expected_run_id: &str, + ) -> std::io::Result { + self.write_notebook_setup_job_with_run_transition(job, Some(expected_run_id)) + } + + fn write_notebook_setup_job_with_run_transition( + &self, + job: &NotebookSetupJob, + expected_run_id: Option<&str>, + ) -> std::io::Result { + let mut conn = self.open_registry_db()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + if job.job_id.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "notebook setup job identity is missing", + )); + } + let current: Option<(String, String, String)> = tx + .query_row( + "SELECT job_id, run_id, status FROM notebook_setup_jobs WHERE notebook_id = ?1", + [&job.notebook_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(sqlite_to_io)?; + if let Some((current_job_id, current_run_id, current_status)) = current { + if current_job_id != job.job_id { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "NOTEBOOK_SETUP_JOB_SUPERSEDED", + )); + } + if current_run_id != job.run_id + && !(job.status == NotebookSetupJobStatus::Running + && expected_run_id == Some(current_run_id.as_str()) + && matches!( + current_status.as_str(), + "pending" | "running" | "partial" | "failed" + )) + { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "NOTEBOOK_SETUP_RUN_SUPERSEDED", + )); + } + } else if job.status != NotebookSetupJobStatus::Pending { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "NOTEBOOK_SETUP_JOB_MISSING", + )); + } + let revision = next_notebook_setup_revision(&tx, job.updated_at)?; let report_json = job .report .as_ref() .map(serde_json::to_string) .transpose() .map_err(std::io::Error::other)?; - conn.execute( + tx.execute( r#" INSERT INTO notebook_setup_jobs - (notebook_id, template_id, status, stage, completed_files, total_files, message, report_json, updated_at, overwrite_existing) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + (notebook_id, template_id, status, stage, message, report_json, updated_at, overwrite_existing, revision, run_id, job_id, execution_requested) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(notebook_id) DO UPDATE SET template_id = excluded.template_id, status = excluded.status, stage = excluded.stage, - completed_files = excluded.completed_files, - total_files = excluded.total_files, message = excluded.message, report_json = excluded.report_json, updated_at = excluded.updated_at, - overwrite_existing = excluded.overwrite_existing + overwrite_existing = excluded.overwrite_existing, + revision = excluded.revision, + run_id = excluded.run_id, + job_id = excluded.job_id, + execution_requested = excluded.execution_requested "#, params![ job.notebook_id, job.template_id, notebook_setup_status_as_str(job.status), - job.stage, - job.completed_files as i64, - job.total_files as i64, + notebook_setup_stage_as_str(job.stage), job.message, report_json, job.updated_at, if job.overwrite_existing { 1 } else { 0 }, + revision, + job.run_id, + job.job_id, + if job.execution_requested { 1 } else { 0 }, ], ) .map_err(sqlite_to_io)?; - Ok(()) + tx.commit().map_err(sqlite_to_io)?; + let mut persisted = job.clone(); + persisted.revision = revision; + Ok(persisted) } /// 分配一个新 notebook 的 sort: 已存在的最大 sort + 10, 步长 10 方便后续 diff --git a/app/flowix-core/src/memo_file/notebook_registry.rs b/app/flowix-core/src/memo_file/notebook_registry.rs index 82de89310..d433dda0e 100644 --- a/app/flowix-core/src/memo_file/notebook_registry.rs +++ b/app/flowix-core/src/memo_file/notebook_registry.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::PathBuf; use std::time::Duration; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use super::notebook::sqlite_to_io; use super::types::NotebookConfig; @@ -23,16 +23,34 @@ impl NotebookRegistry { } pub(super) fn db_path(&self) -> PathBuf { - self.config_dir.join("index.db") + self.config_dir.join("db").join("local.db") } pub(super) fn open(&self) -> std::io::Result { + self.open_with_legacy_migrations(false) + } + + pub(super) fn open_for_initialization(&self) -> std::io::Result { + self.open_with_legacy_migrations(true) + } + + fn open_with_legacy_migrations( + &self, + run_legacy_migrations: bool, + ) -> std::io::Result { if let Some(parent) = self.db_path().parent() { fs::create_dir_all(parent)?; } let mut conn = Connection::open(self.db_path()).map_err(sqlite_to_io)?; conn.busy_timeout(Duration::from_secs(10)) .map_err(sqlite_to_io)?; + let notebooks_table_exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='notebooks')", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io)?; let has_sort_column = conn .prepare("PRAGMA table_info(notebooks)") .map_err(sqlite_to_io)? @@ -40,7 +58,20 @@ impl NotebookRegistry { .map_err(sqlite_to_io)? .filter_map(Result::ok) .any(|name| name == "sort"); - if !has_sort_column { + let has_pinned_column = conn + .prepare("PRAGMA table_info(notebooks)") + .map_err(sqlite_to_io)? + .query_map([], |row| row.get::<_, String>(1)) + .map_err(sqlite_to_io)? + .filter_map(Result::ok) + .any(|name| name == "pinned"); + if notebooks_table_exists && !has_sort_column && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook registry requires version initialization", + )); + } + if notebooks_table_exists && !has_sort_column { conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS notebooks ( @@ -56,6 +87,16 @@ impl NotebookRegistry { ) .map_err(sqlite_to_io)?; conn.execute_batch("ALTER TABLE notebooks ADD COLUMN sort INTEGER NOT NULL DEFAULT 0;") + .map_err(sqlite_to_io)?; + } + if notebooks_table_exists && !has_pinned_column { + if !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook registry requires pinned-column migration", + )); + } + conn.execute_batch("ALTER TABLE notebooks ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0,1));") .map_err(sqlite_to_io)?; } conn.execute_batch( @@ -70,6 +111,7 @@ impl NotebookRegistry { path TEXT NOT NULL UNIQUE, is_default INTEGER NOT NULL, sort INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -77,6 +119,8 @@ impl NotebookRegistry { ON notebooks(is_default); CREATE INDEX IF NOT EXISTS idx_notebooks_sort ON notebooks(sort); + CREATE INDEX IF NOT EXISTS idx_notebooks_pinned_sort + ON notebooks(pinned DESC, sort ASC); CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT, @@ -87,12 +131,18 @@ impl NotebookRegistry { template_id TEXT, status TEXT NOT NULL, stage TEXT NOT NULL, - completed_files INTEGER NOT NULL DEFAULT 0, - total_files INTEGER NOT NULL DEFAULT 0, message TEXT, report_json TEXT, overwrite_existing INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + run_id TEXT NOT NULL DEFAULT '', + job_id TEXT NOT NULL DEFAULT '', + execution_requested INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS local_migrations ( + migration_key TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL DEFAULT 0 ); "#, ) @@ -104,6 +154,12 @@ impl NotebookRegistry { .map_err(sqlite_to_io)? .filter_map(Result::ok) .any(|name| name == "report_json"); + if !has_setup_report && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook setup registry requires version initialization", + )); + } if !has_setup_report { conn.execute_batch("ALTER TABLE notebook_setup_jobs ADD COLUMN report_json TEXT;") .map_err(sqlite_to_io)?; @@ -115,13 +171,81 @@ impl NotebookRegistry { .map_err(sqlite_to_io)? .filter_map(Result::ok) .any(|name| name == "overwrite_existing"); + if !has_setup_overwrite && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook setup registry requires version initialization", + )); + } if !has_setup_overwrite { conn.execute_batch( "ALTER TABLE notebook_setup_jobs ADD COLUMN overwrite_existing INTEGER NOT NULL DEFAULT 0;", ) .map_err(sqlite_to_io)?; } - if !has_sort_column { + for (column, declaration) in [ + ("revision", "INTEGER NOT NULL DEFAULT 0"), + ("run_id", "TEXT NOT NULL DEFAULT ''"), + ("job_id", "TEXT NOT NULL DEFAULT ''"), + ("execution_requested", "INTEGER NOT NULL DEFAULT 0"), + ] { + let has_column = conn + .prepare("PRAGMA table_info(notebook_setup_jobs)") + .map_err(sqlite_to_io)? + .query_map([], |row| row.get::<_, String>(1)) + .map_err(sqlite_to_io)? + .filter_map(Result::ok) + .any(|name| name == column); + if !has_column && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("notebook setup registry requires version initialization ({column})"), + )); + } + if !has_column { + conn.execute_batch(&format!( + "ALTER TABLE notebook_setup_jobs ADD COLUMN {column} {declaration};" + )) + .map_err(sqlite_to_io)?; + } + } + conn.execute( + "UPDATE notebook_setup_jobs SET job_id = lower(hex(randomblob(16))) WHERE job_id = ''", + [], + ) + .map_err(sqlite_to_io)?; + let migration_tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + let execution_request_migration: Option = migration_tx + .query_row( + "SELECT value FROM app_state WHERE key = 'notebook_setup_execution_request_migrated'", + [], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_to_io)?; + if execution_request_migration.is_none() { + migration_tx + .execute( + "UPDATE notebook_setup_jobs SET execution_requested = 1 WHERE status IN ('pending', 'running')", + [], + ) + .map_err(sqlite_to_io)?; + migration_tx + .execute( + "INSERT INTO app_state (key, value, updated_at) VALUES ('notebook_setup_execution_request_migrated', '1', 0)", + [], + ) + .map_err(sqlite_to_io)?; + } + migration_tx.commit().map_err(sqlite_to_io)?; + conn.execute( + "INSERT OR IGNORE INTO app_state (key, value, updated_at) SELECT 'notebook_setup_revision', CAST(COALESCE(MAX(revision), 0) AS TEXT), 0 FROM notebook_setup_jobs", + [], + ) + .map_err(sqlite_to_io)?; + if notebooks_table_exists && !has_sort_column { let tx = conn.transaction().map_err(sqlite_to_io)?; let mut stmt = tx .prepare( diff --git a/app/flowix-core/src/memo_file/onboarding.rs b/app/flowix-core/src/memo_file/onboarding.rs index 0de099fa6..e02a80bf0 100644 --- a/app/flowix-core/src/memo_file/onboarding.rs +++ b/app/flowix-core/src/memo_file/onboarding.rs @@ -39,8 +39,12 @@ impl MemoFile { /// Safety guard: if the target folder already contains memos (for example, /// the user registered a folder full of notes), this is a no-op. pub fn seed_onboarding_docs(&self) -> std::io::Result { - let index = self.read_index_result()?.unwrap_or_default(); - seed_onboarding_docs_if_empty(self, self.get_memo_base(), index.memos.is_empty(), None) + let notebook_id = self.current_notebook_id_for_index(); + if !self.note_index_is_ready(¬ebook_id)? { + self.reconcile_note_index_blocking(¬ebook_id)?; + } + let empty = self.note_count(¬ebook_id)? == 0; + seed_onboarding_docs_if_empty(self, self.get_memo_base(), empty, None) } /// Seed onboarding documents into a notebook without changing the @@ -48,13 +52,14 @@ impl MemoFile { /// imports: importing a newly registered notebook must not change the /// user's active notebook while they are browsing another one. pub fn seed_onboarding_docs_for_notebook_id(&self, notebook_id: &str) -> std::io::Result { - let index = self - .read_index_for_notebook_id(Some(notebook_id))? - .unwrap_or_default(); + if !self.note_index_is_ready(notebook_id)? { + self.reconcile_note_index_blocking(notebook_id)?; + } + let empty = self.note_count(notebook_id)? == 0; let base = self .memo_base_for_notebook_id_result(notebook_id) .map_err(std::io::Error::other)?; - seed_onboarding_docs_if_empty(self, base, index.memos.is_empty(), Some(notebook_id)) + seed_onboarding_docs_if_empty(self, base, empty, Some(notebook_id)) } } @@ -233,7 +238,7 @@ mod tests { content.contains(original), "original Markdown body must survive" ); - assert_eq!(crate::memo_file::extract_frontmatter_key(&content), None); + assert!(!content.contains("flowix_key:")); } #[test] diff --git a/app/flowix-core/src/memo_file/ops.rs b/app/flowix-core/src/memo_file/ops.rs index 678f28980..d631543b8 100644 --- a/app/flowix-core/src/memo_file/ops.rs +++ b/app/flowix-core/src/memo_file/ops.rs @@ -1,30 +1,22 @@ -//! Legacy memo-ID CRUD and reconciliation operations. -//! -//! Markdown remains the content source. This module maintains an ID-bearing -//! cache for older callers; path-based operations and the rebuildable Note -//! index also live in `note_index`. New internal IDs contain eight `[0-9a-z]` -//! characters; older six-character IDs remain readable. New writes do not -//! append an ID to filenames or frontmatter. +//! Markdown CRUD and reconciliation operations keyed by notebook-relative path. //! //! `Memo` / `MemoIndexEntry::filename` stores the disk filename including //! `.md`; `relative_path` identifies nested files within the notebook. //! //! ## 锁模型 //! -//! ID-cache mutations use `current_index_io` to serialize their read-modify- -//! write sequence. Internal `_locked` methods are called while holding it. +//! Index mutations use `current_index_io` to serialize their read-modify-write +//! sequence. Internal `_locked` methods are called while holding it. use std::fs; use std::path::{Path, PathBuf}; -use rusqlite::OptionalExtension; - use super::derivation::{apply_derived_memo_fields, extract_title_and_preview}; pub(super) use super::file_io::{atomic_create_bytes, atomic_write_bytes, rename_file_noclobber}; use super::frontmatter::extract_document_metadata_preserving_invalid_tag_paths; use super::frontmatter::{ extract_document_metadata_tolerant, - is_system_frontmatter_key, merge_frontmatter, replace_frontmatter_tags, + merge_frontmatter, replace_frontmatter_tags, replace_frontmatter_tags_preserving_invalid_paths, MergeOverrides, }; use super::extract_document_metadata; @@ -216,15 +208,6 @@ fn normalize_for_compare(path: &Path) -> PathBuf { } impl MemoFile { - fn generate_global_memo_id(&self) -> String { - loop { - let id = nanoid::nanoid!(8, &super::MEMO_ID_ALPHABET); - if self.resolve_memo_location(&id).ok().flatten().is_none() { - return id; - } - } - } - pub(crate) fn memo_base_for_notebook_id_result( &self, notebook_id: &str, @@ -234,13 +217,17 @@ impl MemoFile { .ok_or_else(|| format!("notebook {notebook_id} not found")) } - pub fn read_memo_for_notebook_id(&self, notebook_id: &str, id: &str) -> Option { + pub fn read_memo_for_notebook_id( + &self, + notebook_id: &str, + relative_path: &str, + ) -> Option { self.read_index_for_notebook_id(Some(notebook_id)) .ok() .flatten()? .memos .into_iter() - .find(|entry| entry.id == id) + .find(|entry| entry.relative_path == relative_path) .map(|entry| MemoFile::index_entry_to_memo(&entry)) } diff --git a/app/flowix-core/src/memo_file/ops/crud.rs b/app/flowix-core/src/memo_file/ops/crud.rs index a28a476fe..63eea49cd 100644 --- a/app/flowix-core/src/memo_file/ops/crud.rs +++ b/app/flowix-core/src/memo_file/ops/crud.rs @@ -23,9 +23,9 @@ fn same_title_with_generated_conflict_suffix(current_base: &str, derived_title: } impl MemoFile { - /// 创建一个 memo: 写 .md + 写 memo index。返回新建的 Memo (含 id / filename)。 + /// Create a Markdown note and return its path-keyed compatibility DTO. pub fn create_memo(&self, title: &str, body: &str, tag: Option<&str>) -> std::io::Result { - self.create_memo_inner(None, title, body, tag, false) + self.create_memo_inner(None, title, body, tag) } /// Create in a registered notebook without changing the process-local current notebook. @@ -36,7 +36,7 @@ impl MemoFile { body: &str, tag: Option<&str>, ) -> std::io::Result { - self.create_memo_inner(Some(notebook_id), title, body, tag, false) + self.create_memo_inner(Some(notebook_id), title, body, tag) } /// Create in an existing notebook subdirectory. The directory is expressed @@ -55,12 +55,11 @@ impl MemoFile { title, body, tag, - false, ) } - /// Create from a separate CLI/MCP process and leave an explicit marker for - /// Desktop's filesystem watcher before the markdown file becomes visible. + /// Compatibility wrapper for external callers. The watcher indexes the + /// created file by notebook-relative path when it becomes visible. pub fn create_external_memo_for_notebook_id( &self, notebook_id: &str, @@ -68,7 +67,7 @@ impl MemoFile { body: &str, tag: Option<&str>, ) -> std::io::Result { - self.create_memo_inner(Some(notebook_id), title, body, tag, true) + self.create_memo_inner(Some(notebook_id), title, body, tag) } fn create_memo_inner( @@ -77,16 +76,8 @@ impl MemoFile { title: &str, body: &str, tag: Option<&str>, - mark_external_create: bool, ) -> std::io::Result { - self.create_memo_inner_in_directory( - notebook_id, - None, - title, - body, - tag, - mark_external_create, - ) + self.create_memo_inner_in_directory(notebook_id, None, title, body, tag) } fn create_memo_inner_in_directory( @@ -96,7 +87,6 @@ impl MemoFile { title: &str, body: &str, tag: Option<&str>, - mark_external_create: bool, ) -> std::io::Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); let (base, resolved_notebook_id) = if let Some(notebook_id) = notebook_id { @@ -136,7 +126,6 @@ impl MemoFile { None => base.clone(), }; - let id = self.generate_global_memo_id(); let now = chrono::Utc::now().timestamp_millis(); let candidate = base_filename(title); // Check indexed names as well as disk existence before atomically @@ -146,7 +135,7 @@ impl MemoFile { parent_relative_path.filter(|path| !path.is_empty()), )?; - // Preserve supplied Markdown; its legacy key is not used as the cache ID. + // Preserve supplied Markdown; the notebook-relative path is the cache key. let prepared_content = body.to_string(); let initial_content = match tag { Some(tag) if !tag.trim().is_empty() => { @@ -163,10 +152,6 @@ impl MemoFile { _ => prepared_content, }; validate_document_frontmatter(&initial_content)?; - let persisted_id = id; - if mark_external_create { - self.mark_pending_external_memo_create(&persisted_id, &resolved_notebook_id)?; - } let filename = loop { let filename = resolve_filename_conflict(&create_base, &candidate, &occupied); let path = create_base.join(&filename); @@ -175,22 +160,17 @@ impl MemoFile { Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { occupied.push(filename); } - Err(error) => { - if mark_external_create { - let _ = self.clear_pending_external_memo_create(&persisted_id); - } - return Err(error); - } + Err(error) => return Err(error), } }; + let relative_path = parent_relative_path + .filter(|path| !path.is_empty()) + .map(|parent| format!("{parent}/{filename}")) + .unwrap_or_else(|| filename.clone()); let mut memo = Memo { - id: persisted_id, filename: filename.clone(), - relative_path: parent_relative_path - .filter(|path| !path.is_empty()) - .map(|parent| format!("{parent}/{filename}")) - .unwrap_or_else(|| filename.clone()), + relative_path, preview: String::new(), thumbnail: None, tags: vec![], @@ -213,9 +193,6 @@ impl MemoFile { if fs::read_to_string(&path).ok().as_deref() == Some(initial_content.as_str()) { let _ = fs::remove_file(path); } - if mark_external_create { - let _ = self.clear_pending_external_memo_create(&memo.id); - } return Err(error); } Ok(memo) @@ -224,12 +201,12 @@ impl MemoFile { /// 改名: 物理文件可能 rename, memo index entry.filename 同步更新。 /// `new_title` 为空字符串时**不**重命名, 仅刷新派生字段 (no-op)。 /// 冲突自动追加 `-1` / `-2`。 - pub fn rename_memo(&self, id: &str, new_title: &str) -> std::io::Result { + pub fn rename_memo(&self, relative_path: &str, new_title: &str) -> std::io::Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); + let notebook_id = self.current_notebook_id_for_index(); let location = self - .resolve_memo_location(id)? - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "memo not found"))?; - let notebook_id = location.notebook.id; + .resolve_note_location_for_notebook_id(¬ebook_id, relative_path)? + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "note not found"))?; let base = PathBuf::from(location.notebook.path); let mut memo = MemoFile::index_entry_to_memo(&location.memo); let old_filename = memo.filename.clone(); @@ -252,7 +229,7 @@ impl MemoFile { .map(|l| { l.memos .into_iter() - .filter(|e| e.id != memo.id) + .filter(|e| e.relative_path != memo.relative_path) .map(|e| e.relative_path) .collect() }) @@ -284,80 +261,25 @@ impl MemoFile { memo.relative_path = new_relative_path; memo.updated_at = chrono::Utc::now().timestamp_millis(); apply_derived_memo_fields(&mut memo, &new_content); + if memo.relative_path != old_relative_path { + MemoFile::sync_index_on_delete_for_notebook_id_locked(self, ¬ebook_id, &old_relative_path)?; + } MemoFile::sync_index_on_write_for_notebook_id_locked(self, ¬ebook_id, &memo)?; Ok(memo) } /// 写入 body (不改 title)。物理文件不 rename, 仅重写 .md + 同步 memo index 派生字段。 - pub fn write_memo(&self, id: &str, body: &str) -> std::io::Result { + pub fn write_memo(&self, relative_path: &str, body: &str) -> std::io::Result { let _guard = self.current_index_io.lock().expect("index_io poisoned"); self.ensure_dirs()?; - self.write_memo_inner_locked(id, body) - } - - /// Write a globally resolved memo without renaming its file or switching notebooks. - pub fn write_memo_preserving_filename_global( - &self, - id: &str, - body: &str, - ) -> std::io::Result { - let _guard = self.current_index_io.lock().expect("index_io poisoned"); - let (memo, notebook_id) = self.write_memo_preserving_filename_content(id, body, None)?; - MemoFile::sync_index_on_write_for_notebook_id_locked(self, ¬ebook_id, &memo)?; - Ok(memo) - } - - /// The caller holds the file lock. Keep content I/O outside the legacy - /// process-local index mutex so independent files can write concurrently. - pub(crate) fn write_memo_preserving_filename_under_file_lock( - &self, - id: &str, - body: &str, - file_guard: &super::super::FileWriteGuard, - ) -> std::io::Result { - let (memo, notebook_id) = self.write_memo_preserving_filename_content(id, body, Some(file_guard))?; - let _guard = self.current_index_io.lock().expect("index_io poisoned"); - MemoFile::sync_index_on_write_for_notebook_id_locked(self, ¬ebook_id, &memo)?; - Ok(memo) - } - - fn write_memo_preserving_filename_content( - &self, - id: &str, - body: &str, - file_guard: Option<&super::super::FileWriteGuard>, - ) -> std::io::Result<(Memo, String)> { - let location = self.resolve_memo_location(id)?.ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, format!("memo {id} not found")) - })?; - let base = PathBuf::from(&location.notebook.path); - fs::create_dir_all(&base)?; - fs::create_dir_all(base.join(".flowix"))?; - fs::create_dir_all(base.join("attachments"))?; - - let mut memo = MemoFile::index_entry_to_memo(&location.memo); - let merged = body.to_string(); - validate_document_frontmatter(&merged)?; - let path = notebook_path_from_relative(&base, &memo.relative_path) - .map_err(std::io::Error::other)?; - fs::metadata(&path)?; - if let Some(file_guard) = file_guard { - if std::fs::canonicalize(&path)? != file_guard.path() { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "file guard does not match memo path")); - } - } - atomic_write_bytes(&path, merged.as_bytes())?; - - memo.updated_at = chrono::Utc::now().timestamp_millis(); - apply_derived_memo_fields(&mut memo, &merged); - Ok((memo, location.notebook.id)) + self.write_memo_inner_locked(relative_path, body) } /// 无锁版本的 [`Self::write_memo`]。调用方已持 `current_index_io` 锁。 /// 抽出供 [`Self::write_memo_renaming_on_title_change`] 单事务合用。 - fn write_memo_inner_locked(&self, id: &str, body: &str) -> std::io::Result { - let mut memo = self.read_current_memo(id).ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, format!("memo {id} not found")) + fn write_memo_inner_locked(&self, relative_path: &str, body: &str) -> std::io::Result { + let mut memo = self.read_current_memo(relative_path).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, format!("note path {relative_path} not found")) })?; let merged = body.to_string(); validate_document_frontmatter(&merged)?; @@ -383,14 +305,14 @@ impl MemoFile { /// 调用方: `commands::memo::write_document` (Tiptap 编辑保存 IPC 入口)。 pub fn write_memo_renaming_on_title_change( &self, - id: &str, + relative_path: &str, body: &str, ) -> std::io::Result { let _guard = self.current_index_io.lock().expect("index_io poisoned"); self.ensure_dirs()?; // 先 write_memo (含 key 注入 + memo index 同步) - let memo = self.write_memo_inner_locked(id, body)?; + let memo = self.write_memo_inner_locked(relative_path, body)?; // 抽最终磁盘内容(同锁内, 写盘已完成, 文件可读) let path = notebook_path_from_relative(&self.get_memo_base(), &memo.relative_path) @@ -422,7 +344,7 @@ impl MemoFile { .map(|l| { l.memos .into_iter() - .filter(|e| e.id != memo.id) + .filter(|e| e.relative_path != memo.relative_path) .map(|e| e.relative_path) .collect() }) @@ -461,25 +383,29 @@ impl MemoFile { let new_content = existing; atomic_write_bytes(&new_path, new_content.as_bytes())?; + let old_relative_path = memo.relative_path.clone(); let mut updated = memo; updated.filename = new_filename; updated.relative_path = new_relative_path; updated.updated_at = chrono::Utc::now().timestamp_millis(); apply_derived_memo_fields(&mut updated, &new_content); + if updated.relative_path != old_relative_path { + MemoFile::sync_index_on_delete_locked(self, &old_relative_path)?; + } MemoFile::sync_index_on_write_locked(self, &updated)?; Ok(updated) } pub fn write_memo_renaming_on_title_change_global( &self, - id: &str, + relative_path: &str, body: &str, ) -> std::io::Result { let _guard = self.current_index_io.lock().expect("index_io poisoned"); - let location = self.resolve_memo_location(id)?.ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, format!("memo {id} not found")) + let notebook_id = self.current_notebook_id_for_index(); + let location = self.resolve_note_location_for_notebook_id(¬ebook_id, relative_path)?.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, format!("note path {relative_path} not found")) })?; - let notebook_id = location.notebook.id; let base = PathBuf::from(location.notebook.path); fs::create_dir_all(&base)?; fs::create_dir_all(base.join(".flowix"))?; @@ -520,7 +446,7 @@ impl MemoFile { .map(|l| { l.memos .into_iter() - .filter(|e| e.id != memo.id) + .filter(|e| e.relative_path != memo.relative_path) .map(|e| e.relative_path) .collect() }) @@ -566,18 +492,18 @@ impl MemoFile { /// 删除: 删 .md + memo index 移除 entry。 /// 物理文件已不在 (例如外部 `rm`) 但 memo index 仍残留 → 清 list, 视为成功。 /// 完全找不到 (list 也没有) → false。 - pub fn delete_memo(&self, id: &str) -> bool { - self.delete_memo_result(id).unwrap_or(false) + pub fn delete_memo(&self, relative_path: &str) -> bool { + self.delete_memo_result(relative_path).unwrap_or(false) } /// 删除并返回真实 IO 结果。 /// /// 保留 [`Self::delete_memo`] 的布尔兼容 API,CLI/JSON-RPC 使用本方法避免 /// 把 remove/index 写入失败伪装成成功。 - pub fn delete_memo_result(&self, id: &str) -> std::io::Result { + pub fn delete_memo_result(&self, relative_path: &str) -> std::io::Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); - let memo = self.read_current_memo(id); + let memo = self.read_current_memo(relative_path); let path = memo.as_ref().and_then(|m| { notebook_path_from_relative(&self.get_memo_base(), &m.relative_path).ok() }); @@ -588,18 +514,19 @@ impl MemoFile { } _ => { // 物理文件已无, 但 memo index 仍残留 → 兜底清 list - self.read_current_memo(id).is_some() + self.read_current_memo(relative_path).is_some() } }; if removed { - MemoFile::sync_index_on_delete_locked(self, id)?; + MemoFile::sync_index_on_delete_locked(self, relative_path)?; } Ok(removed) } - pub fn delete_memo_result_global(&self, id: &str) -> std::io::Result { + pub fn delete_memo_result_global(&self, relative_path: &str) -> std::io::Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); - let Some(location) = self.resolve_memo_location(id)? else { + let notebook_id = self.current_notebook_id_for_index(); + let Some(location) = self.resolve_note_location_for_notebook_id(¬ebook_id, relative_path)? else { return Ok(false); }; @@ -615,7 +542,7 @@ impl MemoFile { true }; if removed { - MemoFile::sync_index_on_delete_for_notebook_id_locked(self, &location.notebook.id, id)?; + MemoFile::sync_index_on_delete_for_notebook_id_locked(self, ¬ebook_id, relative_path)?; } Ok(removed) } @@ -626,7 +553,7 @@ impl MemoFile { &self, notebook_id: &str, relative_path: &str, - expected_id: &str, + expected_relative_path: &str, ) -> std::io::Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); let base = self.memo_base_for_notebook_id_result(notebook_id).map_err(std::io::Error::other)?; @@ -639,25 +566,25 @@ impl MemoFile { let Some(memo) = self.find_memo_by_relative_path_for_notebook_id(notebook_id, relative_path) else { return Ok(true); }; - if memo.id != expected_id { + if memo.relative_path != expected_relative_path { return Ok(false); } - MemoFile::sync_index_on_delete_for_notebook_id_locked(self, notebook_id, expected_id)?; + MemoFile::sync_index_on_delete_for_notebook_id_locked(self, notebook_id, expected_relative_path)?; Ok(true) } /// Delete a memo only when it belongs to the requested notebook. /// - /// Cloud synchronization must never resolve an incoming note ID globally: - /// a duplicate/malicious remote ID from another notebook must not remove + /// Cloud synchronization must never resolve a note path globally: + /// the same relative path in another notebook must not remove /// that notebook's local file. pub fn delete_memo_result_for_notebook_id( &self, notebook_id: &str, - id: &str, + relative_path: &str, ) -> std::io::Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); - let Some(memo) = self.read_memo_for_notebook_id(notebook_id, id) else { + let Some(memo) = self.read_memo_for_notebook_id(notebook_id, relative_path) else { return Ok(false); }; let base = self @@ -668,7 +595,7 @@ impl MemoFile { if path.exists() { fs::remove_file(&path)?; } - MemoFile::sync_index_on_delete_for_notebook_id_locked(self, notebook_id, id)?; + MemoFile::sync_index_on_delete_for_notebook_id_locked(self, notebook_id, relative_path)?; Ok(true) } } diff --git a/app/flowix-core/src/memo_file/ops/reconcile.rs b/app/flowix-core/src/memo_file/ops/reconcile.rs index af0e0da1d..242c554ad 100644 --- a/app/flowix-core/src/memo_file/ops/reconcile.rs +++ b/app/flowix-core/src/memo_file/ops/reconcile.rs @@ -54,7 +54,7 @@ impl MemoFile { // '/' sorts immediately before '0': this range selects only descendants // and lets path indexes bound the update, including '%' or '_' in names. let upper_bound = format!("{previous}0"); - for table in ["notes", "note_tags", "note_todos", "note_agents", "note_search_fts"] { + for table in ["notes", "note_search_fts"] { tx.execute( &format!("UPDATE {table} SET relative_path=?2 || substr(relative_path,length(?1)+1) WHERE relative_path>=?1 AND relative_path=?1 AND relative_path=?1 AND relative_path=?2 AND relative_path(0)?, row.get::<_, String>(1)?)) + }).map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + let media_moves = media_rows.into_iter().map(|(old_id, old_path)| { + let next_path = format!("{next}/{}", old_path.strip_prefix(&previous_prefix).unwrap_or_default()); + let next_id = crate::memo_file::media_resource_id(notebook_id, &next_path) + .map_err(std::io::Error::other)?; + let temporary_id = format!("__flowix_media_move__{}", uuid::Uuid::now_v7().simple()); + Ok((old_id, temporary_id, next_path, next_id)) + }).collect::>>()?; + for (old_id, temporary_id, _, _) in &media_moves { + tx.execute( + "UPDATE media_resources SET id=?1 WHERE notebook_id=?2 AND id=?3", + params![temporary_id, notebook_id, old_id], + ).map_err(sqlite_to_io)?; + } + for (_, temporary_id, next_path, next_id) in media_moves { + tx.execute( + "UPDATE media_resources SET id=?1, relative_path=?2 WHERE notebook_id=?3 AND id=?4", + params![next_id, next_path, notebook_id, temporary_id], + ).map_err(sqlite_to_io)?; + } tx.commit().map_err(sqlite_to_io) } @@ -79,7 +104,8 @@ impl MemoFile { &self, source: &Path, target: &Path, - ) -> std::io::Result> { + ) -> std::io::Result<()> { + let source_is_directory = source.is_dir(); let notebook_ids = self.notebook_ids_for_paths(&[source, target])?; let _change_guard = if notebook_ids.is_empty() { None @@ -99,62 +125,54 @@ impl MemoFile { )); } let _guard = self.current_index_io.lock().expect("index_io poisoned"); - let mut changes = Vec::new(); - for notebook in self.read_notebook_configs()? { - let base = PathBuf::from(¬ebook.path); - for before in self.read_all_memos_for_notebook_id(Some(¬ebook.id)) { - let old = notebook_path_from_relative(&base, &before.relative_path) - .map_err(std::io::Error::other)?; - let Ok(suffix) = old.strip_prefix(source) else { - continue; - }; - let next = if suffix.as_os_str().is_empty() { - target.to_path_buf() - } else { - target.join(suffix) - }; - let relative = - notebook_relative_path(&base, &next).map_err(std::io::Error::other)?; - if self - .find_memo_by_relative_path_for_notebook_id(¬ebook.id, &relative) - .is_some_and(|m| m.id != before.id) - { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "destination is already indexed", - )); - } - let mut after = before.clone(); - after.filename = filename_from_notebook_relative_path(&relative); - after.relative_path = relative; - changes.push((notebook.id.clone(), before, after)); + let mut directory_rebases: Vec<(String, String, String)> = Vec::new(); + if source_is_directory { + let source_absolute = std::fs::canonicalize(source)?; + let target_parent = target.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + let target_absolute = std::fs::canonicalize(target_parent)?.join( + target.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no name") + })?, + ); + for notebook_id in ¬ebook_ids { + let notebook = self.get_notebook_config_by_id(notebook_id).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "notebook not found") + })?; + let root = std::fs::canonicalize(notebook.path)?; + let previous = source_absolute + .strip_prefix(&root) + .map_err(std::io::Error::other)? + .to_string_lossy().replace('\\', "/"); + let next = target_absolute + .strip_prefix(&root) + .map_err(std::io::Error::other)? + .to_string_lossy().replace('\\', "/"); + self.validate_directory_index_rebase(notebook_id, &previous, &next)?; + directory_rebases.push((notebook_id.clone(), previous, next)); } } rename_file_noclobber(source, target)?; - for (notebook, _, after) in &changes { - if let Err(error) = - Self::sync_index_on_write_for_notebook_id_locked_without_projection(self, notebook, after) - { - // Restore the original address on a failed index commit. - rename_file_noclobber(target, source)?; - for (notebook, before, _) in &changes { - Self::sync_index_on_write_for_notebook_id_locked_without_projection(self, notebook, before)?; + let mut rebased_directories: Vec<(String, String, String)> = Vec::new(); + for (notebook_id, previous, next) in &directory_rebases { + if let Err(error) = self.rebase_directory_indexes(notebook_id, previous, next) { + for (applied_notebook, applied_previous, applied_next) in rebased_directories.iter().rev() { + if let Err(rollback_error) = self.rebase_directory_indexes( + applied_notebook, applied_next, applied_previous, + ) { + tracing::error!(notebook_id = %applied_notebook, "failed to roll back directory index rebase: {rollback_error}"); + } } + rename_file_noclobber(target, source)?; return Err(error); } + rebased_directories.push((notebook_id.clone(), previous.clone(), next.clone())); } drop(_guard); drop(_change_guard); drop(_legacy_guard); - for (notebook_id, before, after) in &changes { - for relative_path in [&before.relative_path, &after.relative_path] { - if let Err(error) = self.refresh_note_path(notebook_id, relative_path) { - let _ = self.mark_note_index_refresh_pending_for_notebook(notebook_id); - tracing::warn!(notebook_id, relative_path, "path moved but note index refresh failed: {error}"); - } - } - } - Ok(changes) + Ok(()) } /// 启动 / 切 notebook 时调用: 递归扫描 notebook 内 .md, 把 memo index 没记录的补进来。 @@ -522,11 +540,11 @@ impl MemoFile { }) } - pub fn reload_memo_from_disk(&self, id: &str) -> Result { + pub fn reload_memo_from_disk(&self, relative_path: &str) -> Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); let memo = self - .read_current_memo(id) - .ok_or_else(|| format!("memo {id} not in memo index"))?; + .read_current_memo(relative_path) + .ok_or_else(|| format!("note path {relative_path} not in index"))?; self.reload_memo_inner_locked(memo) } @@ -611,16 +629,10 @@ impl MemoFile { .map(|e| MemoFile::index_entry_to_memo(&e)) } - /// 按 id 找 memo 物理文件绝对路径。文件可能已不在 (返回路径不保证存在)。 - pub fn find_memo_file_path(&self, id: &str) -> Option { - let location = self.resolve_memo_location(id).ok().flatten()?; - Some( - notebook_path_from_relative( - &PathBuf::from(location.notebook.path), - &location.memo.relative_path, - ) - .ok()?, - ) + /// 按 notebook-relative path 取得笔记绝对路径。文件可能已不在。 + pub fn find_memo_file_path(&self, relative_path: &str) -> Option { + let base = self.get_memo_base(); + notebook_path_from_relative(&base, relative_path).ok() } /// 按 filename 拼绝对路径。 @@ -652,12 +664,10 @@ impl MemoFile { .collect(); if let serde_json::Value::Object(properties) = &memo.properties { for (key, value) in properties { - if is_system_frontmatter_key(key) - || matches!( - key.as_str(), - "tag" | "tags" | "flowix_favorited" | "flowix_icon" | "flowix_colors" - ) - { + if matches!( + key.as_str(), + "tag" | "tags" | "flowix_favorited" | "flowix_icon" | "flowix_colors" + ) { continue; } overrides.insert( @@ -684,10 +694,7 @@ impl MemoFile { pub fn sync_metadata_only_global(&self, memo: &Memo) -> std::io::Result<()> { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); - let notebook_id = self - .resolve_memo_location(&memo.id)? - .map(|location| location.notebook.id) - .unwrap_or_else(|| self.current_notebook_id_for_index()); + let notebook_id = self.current_notebook_id_for_index(); let rebuilt = self.persist_memo_metadata_to_markdown(¬ebook_id, memo)?; MemoFile::sync_index_on_write_for_notebook_id_locked(self, ¬ebook_id, &rebuilt) } @@ -715,7 +722,7 @@ impl MemoFile { ); return false; } - MemoFile::sync_index_on_delete_locked(self, &memo.id).is_ok() + MemoFile::sync_index_on_delete_locked(self, &memo.relative_path).is_ok() } pub fn unregister_memo_by_path_for_notebook_id( @@ -752,7 +759,7 @@ impl MemoFile { // macOS /var vs /private/var / 大小写 / trailing slash 等表示差异, // 不应阻断 watcher Remove -> emit Deleted (否则外部 rm / claude code // 删除不会更新 memo list, 与 Create 走 watcher 正常工作的不对称)。 - // `sync_index_on_delete` 按 memo.id 注销, 与 abs_path 表示无关, + // `sync_index_on_delete` 按 memo.relative_path 注销, 与 abs_path 表示无关, // 继续 unregister 不存在误删风险。 tracing::debug!( "[watcher-delete] unregister_memo_by_path_for_notebook_id path mismatch (continuing): expected={} actual={}", @@ -760,7 +767,7 @@ impl MemoFile { abs_path.display() ); } - MemoFile::sync_index_on_delete_for_notebook_id_locked(self, notebook_id, &memo.id).is_ok() + MemoFile::sync_index_on_delete_for_notebook_id_locked(self, notebook_id, &memo.relative_path).is_ok() } /// Idempotently sync an existing memo entry to the filename currently on disk. @@ -768,17 +775,18 @@ impl MemoFile { /// The watcher can observe `Create/Modify(new_path)` while an internal save is /// still holding `current_index_io`. By the time this method obtains the lock, /// the save path may already have updated the index to `new_path`. Resolve by - /// memo id inside the lock so both states converge and still produce an update. + /// relative path inside the lock so both states converge and still produce an update. pub fn sync_memo_filename_from_disk_key( &self, - id: &str, + relative_path: &str, new_path: &Path, ) -> Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); + let notebook_id = self.current_notebook_id_for_index(); let location = self - .resolve_memo_location(id) + .resolve_note_location_for_notebook_id(¬ebook_id, relative_path) .map_err(|e| format!("resolve memo location failed: {e}"))? - .ok_or_else(|| format!("memo id not in index: {id}"))?; + .ok_or_else(|| format!("note path not in index: {relative_path}"))?; if !new_path.is_md() { return Err(format!("new path is not markdown: {}", new_path.display())); @@ -813,11 +821,11 @@ impl MemoFile { if let Some(existing) = list .memos .iter() - .find(|entry| entry.relative_path == new_relative_path && entry.id != id) + .find(|entry| entry.relative_path == new_relative_path && entry.relative_path != relative_path) { return Err(format!( - "new filename already occupied by another memo (id={})", - existing.id + "new filename already occupied by another note (path={})", + existing.relative_path )); } @@ -829,6 +837,14 @@ impl MemoFile { apply_derived_memo_fields(&mut memo, &content); memo.updated_at = chrono::Utc::now().timestamp_millis(); + if current_relative_path != memo.relative_path { + MemoFile::sync_index_on_delete_for_notebook_id_locked( + self, + &location.notebook.id, + ¤t_relative_path, + ) + .map_err(|e| format!("remove old note index row failed: {e}"))?; + } MemoFile::sync_index_on_write_for_notebook_id_locked(self, &location.notebook.id, &memo) .map_err(|e| format!("sync memo index failed: {e}"))?; Ok(memo) @@ -837,13 +853,13 @@ impl MemoFile { pub fn sync_memo_filename_from_disk_key_for_notebook_id( &self, notebook_id: &str, - id: &str, + relative_path: &str, new_path: &Path, ) -> Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); let existing = self - .read_memo_for_notebook_id(notebook_id, id) - .ok_or_else(|| format!("memo id not in notebook {notebook_id}: {id}"))?; + .read_memo_for_notebook_id(notebook_id, relative_path) + .ok_or_else(|| format!("note path not in notebook {notebook_id}: {relative_path}"))?; if !new_path.is_md() { return Err(format!("new path is not markdown: {}", new_path.display())); @@ -877,34 +893,41 @@ impl MemoFile { if let Some(occupied) = list .memos .iter() - .find(|entry| entry.relative_path == new_relative_path && entry.id != id) + .find(|entry| entry.relative_path == new_relative_path && entry.relative_path != relative_path) { return Err(format!( - "new filename already occupied by another memo (id={})", - occupied.id + "new filename already occupied by another note (path={})", + occupied.relative_path )); } let content = std::fs::read_to_string(new_path) .map_err(|e| format!("failed to read new path {}: {e}", new_path.display()))?; + let existing_relative_path = existing.relative_path.clone(); let mut memo = existing; memo.filename = new_filename; memo.relative_path = new_relative_path; apply_derived_memo_fields(&mut memo, &content); memo.updated_at = chrono::Utc::now().timestamp_millis(); + if existing_relative_path != memo.relative_path { + MemoFile::sync_index_on_delete_for_notebook_id_locked( + self, + notebook_id, + &existing_relative_path, + ) + .map_err(|e| format!("remove old note index row failed: {e}"))?; + } MemoFile::sync_index_on_write_for_notebook_id_locked(self, notebook_id, &memo) .map_err(|e| format!("sync memo index failed: {e}"))?; Ok(memo) } /// 物理 rename 同步: 把 memo index entry 从 old_filename 改成 new_filename, - /// **保留 id**。 物理文件已由调用方 (OS / 用户) 搬好, 我们不动磁盘。 + /// 按新的 notebook-relative path 更新索引。物理文件已由调用方搬好。 /// /// 跟 `unregister_memo_by_path` + `register_existing_file` 的"双 register" - /// 区别: 后者会生成**新** id, 把同一份磁盘内容当新 memo 入库; - /// rename_memo_file 保留 id, 让 memo index 跟着物理 mv 走, 触发 - /// `MemoEvent::Updated` (id 永不变, 前端 store 按 id patch 即可)。 + /// 旧路径的索引行会删除,新路径的索引行从 Markdown 重新生成。 /// /// 失败条件: /// - old_filename 不在 memo index (没记录) @@ -912,7 +935,7 @@ impl MemoFile { /// - new_filename 不是 .md 后缀 /// - 任一路径规范化后不在当前 notebook base 下 /// - /// 物理文件 invariant: 调用方负责保证 new_path 实际指向同 id 的内容 + /// 物理文件 invariant: 调用方负责保证 new_path 实际指向已移动的内容 /// (即 OS 层 mv 已经完成)。 我们只更新 memo index 索引。 pub fn rename_memo_file(&self, old_path: &Path, new_path: &Path) -> Result { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); @@ -926,7 +949,7 @@ impl MemoFile { Some(m) => m.clone(), None => return Err(format!("old path not in memo index: {old_relative_path}")), }; - let id = memo.id.clone(); + let old_relative_path = memo.relative_path.clone(); // 2. 旧路径在不在当前 notebook base 下 (规范化检查) let expected_old_abs = notebook_path_from_relative(&base, &old_relative_path)?; @@ -944,10 +967,10 @@ impl MemoFile { // 4. new_filename 不能已在 memo index (会跟另一条 entry 撞名) if let Some(existing) = self.find_memo_by_relative_path(&new_relative_path) { - if existing.id != id { + if existing.relative_path != old_relative_path { return Err(format!( - "new filename already occupied by another memo (id={})", - existing.id + "new filename already occupied by another note (path={})", + existing.relative_path )); } } @@ -963,7 +986,12 @@ impl MemoFile { let now = chrono::Utc::now().timestamp_millis(); memo.updated_at = now; - // 6. 同步 memo index (沿用 sync_index_on_write_locked, 它走 filename 做索引) + // A relative path is the note key, so moving the file retires the old + // row and rebuilds the new one from Markdown. + if old_relative_path != memo.relative_path { + MemoFile::sync_index_on_delete_locked(self, &old_relative_path) + .map_err(|e| format!("remove old note index row failed: {e}"))?; + } MemoFile::sync_index_on_write_locked(self, &memo) .map_err(|e| format!("sync memo index failed: {e}"))?; Ok(memo) @@ -984,14 +1012,14 @@ impl MemoFile { pub fn move_memo_to_directory_for_notebook_id( &self, notebook_id: &str, - memo_id: &str, + relative_path: &str, parent_relative_path: &str, ) -> Result<(Memo, PathBuf, PathBuf), String> { let _index_io_guard = self.current_index_io.lock().expect("index_io poisoned"); let base = self.memo_base_for_notebook_id_result(notebook_id)?; let memo = self - .read_memo_for_notebook_id(notebook_id, memo_id) - .ok_or_else(|| format!("memo id not in notebook {notebook_id}: {memo_id}"))?; + .read_memo_for_notebook_id(notebook_id, relative_path) + .ok_or_else(|| format!("note path not in notebook {notebook_id}: {relative_path}"))?; let old_path = notebook_path_from_relative(&base, &memo.relative_path)?; let parent = if parent_relative_path.trim().is_empty() { base.clone() @@ -1054,7 +1082,7 @@ impl MemoFile { Some(m) => m, None => return Err(format!("old path not in memo index: {old_relative_path}")), }; - let id = memo.id.clone(); + let old_relative_path = memo.relative_path.clone(); let expected_old_abs = notebook_path_from_relative(&base, &old_relative_path)?; if normalize_for_compare(&expected_old_abs) != normalize_for_compare(old_path) { @@ -1071,10 +1099,10 @@ impl MemoFile { if let Some(existing) = self.find_memo_by_relative_path_for_notebook_id(notebook_id, &new_relative_path) { - if existing.id != id { + if existing.relative_path != old_relative_path { return Err(format!( - "new filename already occupied by another memo (id={})", - existing.id + "new filename already occupied by another note (path={})", + existing.relative_path )); } } @@ -1087,6 +1115,14 @@ impl MemoFile { apply_derived_memo_fields(&mut memo, &content); memo.updated_at = chrono::Utc::now().timestamp_millis(); + if old_relative_path != memo.relative_path { + MemoFile::sync_index_on_delete_for_notebook_id_locked( + self, + notebook_id, + &old_relative_path, + ) + .map_err(|e| format!("remove old note index row failed: {e}"))?; + } MemoFile::sync_index_on_write_for_notebook_id_locked(self, notebook_id, &memo) .map_err(|e| format!("sync memo index failed: {e}"))?; Ok(memo) diff --git a/app/flowix-core/src/memo_file/ops/registration.rs b/app/flowix-core/src/memo_file/ops/registration.rs index 3076bf47c..7e561d26e 100644 --- a/app/flowix-core/src/memo_file/ops/registration.rs +++ b/app/flowix-core/src/memo_file/ops/registration.rs @@ -20,14 +20,6 @@ impl MemoFile { self.register_existing_file_for_notebook_id_locked(notebook_id, path) } - pub fn register_existing_file_for_notebook_id_without_frontmatter( - &self, - notebook_id: &str, - path: &Path, - ) -> Result { - self.register_existing_file_for_notebook_id(notebook_id, path) - } - pub fn register_existing_file_as_new_for_notebook_id( &self, notebook_id: &str, @@ -47,30 +39,18 @@ impl MemoFile { ) } - /// Cloud protocol association lives in the index, never in authored Markdown. - pub fn register_existing_file_with_cache_id( - &self, - notebook_id: &str, - path: &Path, - cache_id: &str, - ) -> Result { - let _guard = self.current_index_io.lock().expect("index_io poisoned"); - self.register_path_locked(notebook_id, path, Some(cache_id)) - } - pub(super) fn register_existing_file_for_notebook_id_locked( &self, notebook_id: &str, path: &Path, ) -> Result { - self.register_path_locked(notebook_id, path, None) + self.register_path_locked(notebook_id, path) } fn register_path_locked( &self, notebook_id: &str, path: &Path, - cache_id: Option<&str>, ) -> Result { if !path.is_md() || !path.is_file() { return Err(format!("not an existing Markdown file: {}", path.display())); @@ -80,27 +60,11 @@ impl MemoFile { if let Some(memo) = self.find_memo_by_relative_path_for_notebook_id(notebook_id, &relative_path) { - if cache_id.is_some_and(|id| id != memo.id) { - return Err("path already belongs to another cache entry".into()); - } return self.reload_memo_inner_for_notebook_id_locked(notebook_id, memo); } let content = fs::read_to_string(path).map_err(|error| error.to_string())?; - if let Some(id) = cache_id { - if self - .resolve_memo_location(id) - .map_err(|e| e.to_string())? - .is_some() - { - return Err("cache entry already belongs to another path".into()); - } - } let now = chrono::Utc::now().timestamp_millis(); let mut memo = Memo { - // Internal cache/history association only; never a Markdown identity. - id: cache_id - .map(str::to_string) - .unwrap_or_else(|| self.generate_global_memo_id()), filename: filename_from_notebook_relative_path(&relative_path), relative_path, preview: String::new(), diff --git a/app/flowix-core/src/memo_file/ops/tags.rs b/app/flowix-core/src/memo_file/ops/tags.rs index 5c76aced3..31967a8f1 100644 --- a/app/flowix-core/src/memo_file/ops/tags.rs +++ b/app/flowix-core/src/memo_file/ops/tags.rs @@ -28,10 +28,6 @@ impl MemoFile { const MIGRATION_KEY: &str = "yaml-body-tag-union-index"; const MIGRATION_VERSION: u32 = 1; - let _guard = self - .current_index_io - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); if self .notebook_data_migration_version(notebook_id, MIGRATION_KEY)? .unwrap_or_default() @@ -40,41 +36,14 @@ impl MemoFile { return Ok(0); } - let base = self - .memo_base_for_notebook_id_result(notebook_id) + // Tags are derived directly from Markdown into notes.tags. This + // migration now rebuilds the path index instead of backfilling the + // duplicate path projection. + let report = self + .reconcile_note_index(notebook_id) .map_err(std::io::Error::other)?; - let entries = self - .read_index_for_notebook_id(Some(notebook_id))? - .unwrap_or_default() - .memos; - let mut updated = 0usize; - for entry in entries { - let path = super::super::notebook_path_from_relative(&base, &entry.relative_path) - .map_err(std::io::Error::other) - .unwrap_or_else(|_| base.join(&entry.filename)); - let content = match std::fs::read_to_string(&path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - tracing::debug!( - notebook = %notebook_id, - path = %path.display(), - "skip tag migration for missing memo file" - ); - continue; - } - Err(error) => return Err(error), - }; - let before = entry.tags.clone(); - let mut memo = MemoFile::index_entry_to_memo(&entry); - apply_derived_memo_fields(&mut memo, &content); - if memo.tags != before { - MemoFile::sync_index_on_write_for_notebook_id_locked(self, notebook_id, &memo)?; - updated += 1; - } - } - self.mark_notebook_data_migration(notebook_id, MIGRATION_KEY, MIGRATION_VERSION)?; - Ok(updated) + Ok(report.updated + report.added) } pub fn move_memo_tag_locked( @@ -143,26 +112,18 @@ impl MemoFile { .map(str::to_string) .unwrap_or_else(|| self.current_notebook_id_for_index()); - // 4. 冲突检查: new_path 在该 notebook 是否已存在 - let conn = self.open_memo_index_db_for_notebook_id(¬ebook_id_owned)?; let prefix = format!("{old_path}/"); - let mut catalog_stmt = conn - .prepare( - "SELECT path FROM notebook_tags - WHERE notebook_id = ?1 - AND (path = ?2 OR path LIKE ?3 ESCAPE '\\') - ORDER BY length(path) ASC, path ASC", - ) - .map_err(sqlite_to_io)?; - let catalog_paths: Vec = catalog_stmt - .query_map( - rusqlite::params![¬ebook_id_owned, &old_path, format!("{prefix}%")], - |row| row.get(0), - ) - .map_err(sqlite_to_io)? - .collect::, _>>() - .map_err(sqlite_to_io)?; - drop(catalog_stmt); + // Tags are derived from notes.tags. Moving a subtree changes the + // Markdown source and never writes a separate tag catalog. + let existing_tag_paths: std::collections::HashSet = self + .read_notebook_tag_paths(Some(¬ebook_id_owned))? + .into_iter() + .collect(); + let catalog_paths: Vec = existing_tag_paths + .iter() + .cloned() + .filter(|path| path == &old_path || path.starts_with(&prefix)) + .collect(); let renamed_catalog_paths: Vec<(String, String)> = catalog_paths .iter() .map(|path| { @@ -173,19 +134,7 @@ impl MemoFile { let moving_paths: std::collections::HashSet<&str> = catalog_paths.iter().map(String::as_str).collect(); for (_, target) in &renamed_catalog_paths { - let collision: Option = conn - .query_row( - "SELECT path FROM notebook_tags - WHERE notebook_id = ?1 AND path = ?2 LIMIT 1", - rusqlite::params![¬ebook_id_owned, target], - |row| row.get(0), - ) - .optional() - .map_err(sqlite_to_io)?; - if collision - .as_deref() - .is_some_and(|path| !moving_paths.contains(path)) - { + if existing_tag_paths.contains(target) && !moving_paths.contains(target.as_str()) { return Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, format!("target tag already exists in notebook: {target}"), @@ -193,15 +142,14 @@ impl MemoFile { } } - let new_exists = self.note_tag_exists(¬ebook_id_owned, &new_path)?; - if new_exists { + if existing_tag_paths.contains(&new_path) && !moving_paths.contains(new_path.as_str()) { return Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, format!("target tag already exists in notebook: {new_path}"), )); } - // 5. 找所有 affected note relative_path (note_tags 里有 old_path 或 old_path/*) + // 5. Find notes whose derived tags match this path or a descendant. let affected_paths: Vec = self.note_paths_with_tag(¬ebook_id_owned, &old_path)?; if catalog_paths.is_empty() && affected_paths.is_empty() { @@ -283,7 +231,7 @@ impl MemoFile { atomic_write_bytes(&path, merged.as_bytes())?; - // 重新派生 Note 投影 + note_tags。 + // Refresh the path projection from the edited Markdown. self.refresh_note_path(¬ebook_id_owned, relative_path)?; // 写盘 + 投影同步完成后, 把 relative_path 交回调用方 ── 调用方 @@ -294,29 +242,6 @@ impl MemoFile { report.affected_memos += 1; } - let mut catalog_conn = self.open_memo_index_db_for_notebook_id(¬ebook_id_owned)?; - let catalog_tx = catalog_conn.transaction().map_err(sqlite_to_io)?; - for old in &catalog_paths { - catalog_tx - .execute( - "DELETE FROM notebook_tags WHERE notebook_id = ?1 AND path = ?2", - rusqlite::params![¬ebook_id_owned, old], - ) - .map_err(sqlite_to_io)?; - } - let now = chrono::Utc::now().timestamp_millis(); - for (_, new) in &renamed_catalog_paths { - catalog_tx - .execute( - "INSERT OR REPLACE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - VALUES (?1, ?2, ?3, ?3)", - rusqlite::params![¬ebook_id_owned, new, now], - ) - .map_err(sqlite_to_io)?; - } - catalog_tx.commit().map_err(sqlite_to_io)?; - Ok(report) } @@ -324,15 +249,15 @@ impl MemoFile { /// under `tag_path/`) from YAML, body tokens, and the memo index. /// /// Semantics: - /// - `tag_path` itself: removed from `memo_tags`, YAML, and body tokens. + /// - `tag_path` itself: removed from the note index, YAML, and body tokens. /// - `tag_path/<...>` subtree (any depth): all of them are removed in /// one shot from both sources and the derived index. /// - Other tags and non-tag body text are untouched. /// /// Constraints (caller has roughly validated; we re-validate defensively): /// 1. `tag_path` must pass [`normalize_tag_path`] (legal path). - /// 2. `tag_path` must exist in this notebook (`memo_tags` table has at - /// least one `tag = tag_path` or `tag LIKE tag_path/%` entry); + /// 2. `tag_path` must exist in this notebook's `notes.tags` projection + /// at the given path or somewhere in its descendant subtree; /// otherwise we error out. /// /// Locking: same as `move_memo_tag_locked` -- enters holding @@ -383,28 +308,14 @@ impl MemoFile { .map(str::to_string) .unwrap_or_else(|| self.current_notebook_id_for_index()); - let conn = self.open_memo_index_db_for_notebook_id(¬ebook_id_owned)?; - // 3. collect every tag path to delete: `tag_path` itself + every // subtree tag at any depth. let prefix = format!("{tag_path}/"); - let mut stmt = conn - .prepare( - "SELECT path FROM notebook_tags - WHERE notebook_id = ?1 - AND (path = ?2 OR path LIKE ?3 ESCAPE '\\') - ORDER BY length(path) DESC, path ASC", - ) - .map_err(sqlite_to_io)?; - let deleted_tags: Vec = stmt - .query_map( - rusqlite::params![¬ebook_id_owned, &tag_path, format!("{prefix}%")], - |row| row.get(0), - ) - .map_err(sqlite_to_io)? - .collect::, _>>() - .map_err(sqlite_to_io)?; - drop(stmt); + let deleted_tags: Vec = self + .read_notebook_tag_paths(Some(¬ebook_id_owned))? + .into_iter() + .filter(|path| path == &tag_path || path.starts_with(&prefix)) + .collect(); // 4. collect affected note relative_paths let affected_paths: Vec = @@ -472,18 +383,6 @@ impl MemoFile { report.affected_memos += 1; } - let mut catalog_conn = self.open_memo_index_db_for_notebook_id(¬ebook_id_owned)?; - let catalog_tx = catalog_conn.transaction().map_err(sqlite_to_io)?; - for deleted in &report.deleted_tags { - catalog_tx - .execute( - "DELETE FROM notebook_tags WHERE notebook_id = ?1 AND path = ?2", - rusqlite::params![¬ebook_id_owned, deleted], - ) - .map_err(sqlite_to_io)?; - } - catalog_tx.commit().map_err(sqlite_to_io)?; - Ok(report) } } diff --git a/app/flowix-core/src/memo_file/registration.rs b/app/flowix-core/src/memo_file/registration.rs index 694a9bfd3..26ccdf473 100644 --- a/app/flowix-core/src/memo_file/registration.rs +++ b/app/flowix-core/src/memo_file/registration.rs @@ -1,4 +1,2 @@ -//! Legacy memo-ID registration and disk reconciliation live in -//! `ops::registration` and `ops::reconcile`. Registering an -//! existing Markdown file associates its notebook-relative path with an -//! internal cache ID without changing the file or reading `flowix_key`. +//! Markdown registration and disk reconciliation live in +//! `ops::registration` and `ops::reconcile`. diff --git a/app/flowix-core/src/memo_file/storage_repair.rs b/app/flowix-core/src/memo_file/storage_repair.rs new file mode 100644 index 000000000..421aeb62c --- /dev/null +++ b/app/flowix-core/src/memo_file/storage_repair.rs @@ -0,0 +1,327 @@ +//! Small startup helpers for recreatable application storage. +use std::{ + error::Error, + fs, io, + path::{Path, PathBuf}, +}; + +/// Preserve a database and all its SQLite companions in one backup directory. +/// No replacement is attempted unless every existing component was preserved. +pub fn backup_database(path: &Path) -> io::Result> { + backup_files(path, &["", "-wal", "-shm", "-journal"]) +} + +pub fn backup_storage_file(path: &Path) -> io::Result> { + backup_files(path, &[""]) +} + +fn backup_files(path: &Path, suffixes: &[&str]) -> io::Result> { + let mut sources = Vec::new(); + for suffix in suffixes { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + let source = PathBuf::from(name); + match fs::symlink_metadata(&source) { + Ok(_) => sources.push(source), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + if sources.is_empty() { + return Ok(None); + } + let parent = path + .parent() + .ok_or_else(|| io::Error::other("storage path has no parent"))?; + let backup = parent + .join("rebuild-backups") + .join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&backup)?; + let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); + for source in sources { + let target = backup.join(source.file_name().unwrap()); + if let Err(error) = fs::rename(&source, &target) { + for (source, target) in moved.iter().rev() { + if let Err(rollback) = fs::rename(target, source) { + return Err(io::Error::other(format!( + "backup failed: {error}; rollback failed: {rollback}; preserved files: {}", + backup.display() + ))); + } + } + return Err(error); + } + moved.push((source, target)); + } + tracing::warn!(path = %path.display(), backup = %backup.display(), "preserved abnormal application storage before rebuilding"); + Ok(Some(backup)) +} + +/// Busy, permission, disk-full and I/O errors must never move a live database. +pub fn is_rebuildable_storage_error(error: &(dyn Error + 'static)) -> bool { + let mut current = Some(error); + while let Some(error) = current { + if let Some(sqlite) = error.downcast_ref::() { + return match sqlite { + rusqlite::Error::SqlInputError { msg, .. } => [ + "no such table", + "no such column", + "has no column", + "malformed", + ] + .iter() + .any(|part| msg.contains(part)), + rusqlite::Error::SqliteFailure(code, Some(message)) + if code.code == rusqlite::ErrorCode::Unknown => + { + [ + "no such table", + "no such column", + "has no column", + "malformed", + ] + .iter() + .any(|part| message.contains(part)) + } + rusqlite::Error::SqliteFailure(code, _) => matches!( + code.code, + rusqlite::ErrorCode::DatabaseCorrupt + | rusqlite::ErrorCode::NotADatabase + | rusqlite::ErrorCode::SchemaChanged + ), + rusqlite::Error::InvalidColumnName(_) + | rusqlite::Error::InvalidColumnType(..) + | rusqlite::Error::FromSqlConversionFailure(..) => true, + _ => false, + }; + } + if let Some(io) = error.downcast_ref::() { + if io.kind() == io::ErrorKind::InvalidData { + return true; + } + if let Some(inner) = io.get_ref() { + current = Some(inner); + continue; + } + } + current = error.source(); + } + false +} + +/// The operation must release its connections before returning an error. +pub fn with_database_rebuild( + path: &Path, + mut initialize: impl FnMut() -> io::Result, +) -> io::Result { + // Serialize initialization and repair across processes. Ordinary database + // access must not call this helper or rename storage held by other readers. + if let Some(parent) = path.parent() { ensure_storage_directory(parent)?; } + let mut lock_path = path.as_os_str().to_os_string(); + lock_path.push(".rebuild.lock"); + let lock = fs::OpenOptions::new().create(true).truncate(false) + .read(true).write(true).open(PathBuf::from(lock_path))?; + fs2::FileExt::lock_exclusive(&lock)?; + match fs::symlink_metadata(path) { + Ok(metadata) if !metadata.file_type().is_file() => { + backup_database(path)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + backup_database(path)?; + } + _ => {} + } + match initialize() { + Ok(value) => Ok(value), + Err(error) if is_rebuildable_storage_error(&error) => { + tracing::warn!(%error, path = %path.display(), "recreating abnormal application database"); + backup_database(path)?; + initialize() + } + Err(error) => Err(error), + } +} + +pub fn ensure_storage_directory(path: &Path) -> io::Result<()> { + if fs::symlink_metadata(path).is_ok() && !path.is_dir() { + backup_storage_file(path)?; + } + fs::create_dir_all(path) +} + +/// JSON owned by the application: preserve malformed input, then persist defaults. +pub fn read_or_create_json(path: &Path, default: impl FnOnce() -> T) -> io::Result +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + if let Some(parent) = path.parent() { ensure_storage_directory(parent)?; } + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(value) => return Ok(value), + Err(_) => { + backup_storage_file(path)?; + } + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) if error.kind() == io::ErrorKind::IsADirectory => { + backup_storage_file(path)?; + } + Err(error) => return Err(error), + } + let value = default(); + let bytes = serde_json::to_vec_pretty(&value).map_err(io::Error::other)?; + super::atomic_write_bytes(path, &bytes)?; + Ok(value) +} + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + use crate::memo_file::{prepare_database_directory, MemoFile, NotebookConfig}; + + #[test] + fn preserves_complete_sqlite_group() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("local.db"); + for suffix in ["", "-wal", "-shm", "-journal"] { + fs::write( + temp.path().join(format!("local.db{suffix}")), + suffix.as_bytes(), + ) + .unwrap(); + } + let backup = backup_database(&path).unwrap().unwrap(); + for suffix in ["", "-wal", "-shm", "-journal"] { + assert_eq!( + fs::read(backup.join(format!("local.db{suffix}"))).unwrap(), + suffix.as_bytes() + ); + assert!(!temp.path().join(format!("local.db{suffix}")).exists()); + } + } + + #[test] + fn canonical_database_does_not_receive_legacy_wal() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("db")).unwrap(); + fs::write(temp.path().join("local.db"), b"legacy").unwrap(); + fs::write(temp.path().join("local.db-wal"), b"legacy wal").unwrap(); + fs::write(temp.path().join("db/local.db"), b"canonical").unwrap(); + prepare_database_directory(temp.path(), &["local.db"]).unwrap(); + assert_eq!( + fs::read(temp.path().join("db/local.db")).unwrap(), + b"canonical" + ); + assert!(!temp.path().join("db/local.db-wal").exists()); + assert_eq!( + fs::read(temp.path().join("local.db-wal")).unwrap(), + b"legacy wal" + ); + } + + #[test] + fn invalid_registry_rebuilds_and_legacy_input_cannot_block_startup() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("db")).unwrap(); + fs::write(temp.path().join("db/local.db"), b"broken registry").unwrap(); + fs::write(temp.path().join("db/index.db"), b"broken legacy").unwrap(); + let memo = MemoFile::new(temp.path().to_path_buf()); + memo.initialize_registry_storage().unwrap(); + assert!(memo.read_notebook_configs().unwrap().is_empty()); + assert!(memo.read_notebook_workspace().unwrap().notebooks.is_empty()); + let backups = fs::read_dir(temp.path().join("db/rebuild-backups")) + .unwrap() + .count(); + assert_eq!(backups, 1); + memo.initialize_registry_storage().unwrap(); + assert_eq!( + fs::read_dir(temp.path().join("db/rebuild-backups")) + .unwrap() + .count(), + 1 + ); + } + + #[test] + fn malformed_registry_schema_is_recreated() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("db")).unwrap(); + let path = temp.path().join("db/local.db"); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("CREATE TABLE notebooks(id TEXT);") + .unwrap(); + drop(conn); + let memo = MemoFile::new(temp.path().to_path_buf()); + memo.initialize_registry_storage().unwrap(); + assert!(memo.read_notebook_configs().unwrap().is_empty()); + assert!(path.parent().unwrap().join("rebuild-backups").exists()); + } + + #[test] + fn malformed_json_is_preserved_and_defaults_are_persisted() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("preference.json"); + fs::write(&path, b"{broken").unwrap(); + let value: serde_json::Value = + read_or_create_json(&path, || serde_json::json!({"language":"zh-CN"})).unwrap(); + assert_eq!(value["language"], "zh-CN"); + assert_eq!( + serde_json::from_slice::(&fs::read(&path).unwrap()).unwrap(), + value + ); + assert!(temp.path().join("rebuild-backups").exists()); + } + + #[test] + fn busy_database_is_not_moved_or_rebuilt() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("local.db"); + fs::write(&path, b"keep").unwrap(); + let result: io::Result<()> = with_database_rebuild(&path, || { + Err(io::Error::other(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY), + None, + ))) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"keep"); + assert!(!temp.path().join("rebuild-backups").exists()); + } + + #[test] + fn notebook_database_rebuild_keeps_markdown_and_attachments() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notebook"); + fs::create_dir_all(root.join(".flowix")).unwrap(); + fs::create_dir_all(root.join("attachments")).unwrap(); + fs::write(root.join("kept.md"), b"# Keep this note\n").unwrap(); + fs::write(root.join("attachments/kept.bin"), b"attachment").unwrap(); + fs::write( + root.join(".flowix/notebook.db"), + b"invalid notebook database", + ) + .unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.write_notebook_configs(&[NotebookConfig { + id: "nb_kept".into(), + name: "Kept".into(), + icon: None, + path: root.to_string_lossy().into_owned(), + is_default: false, + sort: 0, + created_at: 0, + updated_at: 0, + }]) + .unwrap(); + memo.reconcile_note_index_blocking("nb_kept").unwrap(); + assert_eq!( + fs::read(root.join("kept.md")).unwrap(), + b"# Keep this note\n" + ); + assert_eq!( + fs::read(root.join("attachments/kept.bin")).unwrap(), + b"attachment" + ); + assert!(root.join(".flowix/rebuild-backups").exists()); + } +} diff --git a/app/flowix-core/src/memo_file/tests.rs b/app/flowix-core/src/memo_file/tests.rs index b223f9010..8191e3ad9 100644 --- a/app/flowix-core/src/memo_file/tests.rs +++ b/app/flowix-core/src/memo_file/tests.rs @@ -17,7 +17,6 @@ use super::ops::{ use super::types::MemoIndexFile; use super::MemoFile; use std::fs; -use std::path::Path; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -26,7 +25,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; // ===================================================================== /// 测试 fixture: 构造一个 MemoFile 指向 tempdir, 模拟 "default notebook"。 -/// `read_notebook_configs` 走 `index.db` (SQLite), `get_memo_base` 通过 +/// `read_notebook_configs` 走 `local.db` (SQLite), `get_memo_base` 通过 /// `set_current_notebook` 走当前 notebook id 而不是 fallback。helper 只负责 /// 给 `MemoFile` 传两个路径 + 切到 "nb_test", 不再写任何 /// legacy JSON。`AtomicUsize` + nanos 后缀防并行 cargo test 撞名。 @@ -63,23 +62,19 @@ fn fresh_memo_file() -> (MemoFile, PathBuf) { } #[test] -fn create_memo_generates_eight_char_id() { +fn create_memo_uses_relative_path_as_compatibility_key() { let (mf, _tmp) = fresh_memo_file(); let memo = mf.create_memo("Eight", "body", None).unwrap(); - assert_eq!(memo.id.len(), super::MEMO_ID_LENGTH); - assert!(memo - .id - .chars() - .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase())); + assert_eq!(memo.relative_path, memo.relative_path); let content = fs::read_to_string(mf.get_memo_base().join(&memo.filename)).unwrap(); - assert_eq!(super::frontmatter::extract_frontmatter_key(&content), None); + assert!(!content.contains("flowix_key:")); } /// 读 memo index 原始 JSON 字符串 (不反序列化, 用于 schema 断言)。 #[test] -fn notebook_json_is_migrated_to_index_db() { +fn notebook_json_is_migrated_to_local_db() { let (mf, _tmp) = fresh_memo_file(); let configs = mf.read_notebook_configs().expect("read notebooks"); @@ -154,80 +149,6 @@ fn selected_notebook_state_is_shared_and_rejects_unknown_ids() { assert_eq!(other_process.read_selected_notebook_id().unwrap(), None); } -#[test] -fn content_revision_is_created_and_advanced_only_for_changed_bytes() { - let (mf, _tmp) = fresh_memo_file(); - let memo = mf.create_memo("Revision", "# Revision", None).unwrap(); - - let first = mf - .commit_memo_content_revision(&memo.id, "nb_test", "hash-a", "change-a") - .unwrap(); - assert!(first.changed); - assert_eq!(first.state.revision, 1); - assert_eq!(first.state.change_id, "change-a"); - - let duplicate = mf - .commit_memo_content_revision(&memo.id, "nb_test", "hash-a", "discarded") - .unwrap(); - assert!(!duplicate.changed); - assert_eq!(duplicate.state.revision, 1); - assert_eq!(duplicate.state.change_id, "change-a"); - - let second = mf - .commit_memo_content_revision(&memo.id, "nb_test", "hash-b", "change-b") - .unwrap(); - assert!(second.changed); - assert_eq!(second.state.revision, 2); - assert_eq!(second.state.change_id, "change-b"); - - let reverted = mf - .commit_memo_content_revision(&memo.id, "nb_test", "hash-a", "change-c") - .unwrap(); - assert!(reverted.changed); - assert_eq!(reverted.state.revision, 3); - assert_eq!(reverted.state.change_id, "change-c"); - assert_eq!( - mf.read_memo_content_revision(&memo.id).unwrap(), - Some(reverted.state) - ); - - // Full metadata index rewrites must not reset the independent content - // revision stream. - let index = mf.read_index().unwrap(); - mf.write_index(&index).unwrap(); - assert_eq!( - mf.read_memo_content_revision(&memo.id) - .unwrap() - .unwrap() - .revision, - 3, - ); -} - -#[test] -fn opening_legacy_index_creates_content_revision_table() { - let (mf, _tmp) = fresh_memo_file(); - // Legacy memo tables live in the per-notebook notebook.db, not the registry. - let notebook_db = mf.notebook_db_path("nb_test").unwrap(); - let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - conn.execute_batch("DROP TABLE IF EXISTS memo_content_revisions;") - .unwrap(); - drop(conn); - - // Any memo-index operation runs the additive schema migration. - mf.invalidate_caches(); - let _ = mf.read_index(); - let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - let exists: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'memo_content_revisions'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(exists, 1); -} - #[test] fn pending_data_migrations_run_once_and_persist_version() { let (mf, base) = fresh_memo_file(); @@ -247,167 +168,8 @@ fn pending_data_migrations_run_once_and_persist_version() { ); } -/// Global indexes written before the lifecycle refactor have `memos` but no -/// `memo_lifecycles`. The legacy import must skip the missing table instead of -/// failing startup data migrations (regression for -/// "no such table: legacy_index.memo_lifecycles"). -#[test] -fn legacy_global_index_without_memo_lifecycles_imports_cleanly() { - let (mf, base) = fresh_memo_file(); - // Seed the registry db with a pre-lifecycle global index: `memos` exists, - // `memo_lifecycles` does not. - let legacy_path = mf.registry_db_path(); - let legacy = rusqlite::Connection::open(&legacy_path).unwrap(); - legacy - .execute_batch( - r#" - CREATE TABLE memos ( - id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - filename TEXT NOT NULL, - relative_path TEXT NOT NULL, - preview TEXT, - thumbnail TEXT, - thumbnail_checked INTEGER NOT NULL DEFAULT 0, - agents_checked INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - favorited INTEGER NOT NULL DEFAULT 0, - icon TEXT, - properties TEXT - ); - INSERT INTO memos (id, notebook_id, filename, relative_path, created_at, updated_at) - VALUES ('memo-legacy-1', 'nb_test', 'Old.md', 'Old.md', 1, 1); - "#, - ) - .unwrap(); - drop(legacy); - - let report = mf.run_pending_data_migrations().unwrap(); - assert_eq!(report.to_version, super::LATEST_DATA_MIGRATION_VERSION); - - // The legacy memo row is imported into the notebook-local index, and the - // lifecycle table is backfilled from the imported memos. - let notebook_db = mf.notebook_db_path("nb_test").unwrap(); - let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - let memos: i64 = conn - .query_row( - "SELECT COUNT(*) FROM memos WHERE notebook_id = 'nb_test'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(memos, 1); - let lifecycles: i64 = conn - .query_row( - "SELECT COUNT(*) FROM memo_lifecycles WHERE memo_id = 'memo-legacy-1'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(lifecycles, 1); - assert!(base.join(".flowix/notebook.json").is_file()); -} - -/// Real-world pre-refactor global indexes (observed in the field) lack -/// `memos.relative_path`, lack `memo_todos.todo_id`, and lack -/// `memo_lifecycles` entirely. The import must adapt: filename becomes the -/// relative path, todo ids are synthesized from rowids, and lifecycle rows -/// are backfilled. -#[test] -fn legacy_global_index_with_pre_relative_path_schema_imports_cleanly() { - let (mf, _tmp) = fresh_memo_file(); - let legacy_path = mf.registry_db_path(); - let legacy = rusqlite::Connection::open(&legacy_path).unwrap(); - legacy - .execute_batch( - r#" - CREATE TABLE memos ( - id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - filename TEXT NOT NULL, - preview TEXT NOT NULL, - thumbnail TEXT, - thumbnail_checked INTEGER NOT NULL DEFAULT 0, - agents_checked INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - favorited INTEGER NOT NULL, - icon TEXT, - properties TEXT NOT NULL DEFAULT '{}' - ); - CREATE TABLE memo_todos ( - memo_id TEXT NOT NULL, - content TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL, - time_range TEXT NOT NULL, - owner TEXT NOT NULL, - assignee TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - position INTEGER NOT NULL - ); - CREATE TABLE memo_content_revisions ( - memo_id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - content_hash TEXT NOT NULL, - local_revision INTEGER NOT NULL, - change_id TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - INSERT INTO memos (id, notebook_id, filename, preview, created_at, updated_at, favorited) - VALUES ('memo-legacy-1', 'nb_test', 'Old.md', '# Old', 1, 1, 0); - INSERT INTO memo_todos (memo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position) - VALUES ('memo-legacy-1', 'task', 'open', '', '', '', '', 1, 1, 0); - INSERT INTO memo_content_revisions (memo_id, notebook_id, content_hash, local_revision, change_id, updated_at) - VALUES ('memo-legacy-1', 'nb_test', 'hash-a', 1, 'change-a', 1); - "#, - ) - .unwrap(); - drop(legacy); - - let report = mf.run_pending_data_migrations().unwrap(); - assert_eq!(report.to_version, super::LATEST_DATA_MIGRATION_VERSION); - - let notebook_db = mf.notebook_db_path("nb_test").unwrap(); - let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - let (imported_relative_path, imported_preview): (String, String) = conn - .query_row( - "SELECT relative_path, preview FROM memos WHERE id = 'memo-legacy-1'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(imported_relative_path, "Old.md"); - assert_eq!(imported_preview, "# Old"); - let todo_id: String = conn - .query_row( - "SELECT todo_id FROM memo_todos WHERE memo_id = 'memo-legacy-1'", - [], - |row| row.get(0), - ) - .unwrap(); - assert!(todo_id.starts_with("todo-legacy-")); - let lifecycles: i64 = conn - .query_row( - "SELECT COUNT(*) FROM memo_lifecycles WHERE memo_id = 'memo-legacy-1'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(lifecycles, 1); - // The revision row survives: its lifecycle FK target was backfilled. - let revisions: i64 = conn - .query_row( - "SELECT COUNT(*) FROM memo_content_revisions WHERE memo_id = 'memo-legacy-1'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(revisions, 1); -} - +/// Global index rows are deliberately not imported into the rebuilt notebook +/// database. Markdown files remain the note source of truth. #[test] fn pending_data_migrations_skip_unavailable_notebooks() { let (mf, base) = fresh_memo_file(); @@ -478,7 +240,7 @@ fn relative_path_migration_recovers_populated_replacement_table() { mf.invalidate_caches(); let recovered = mf.read_index().expect("half-migrated index should recover"); assert_eq!(recovered.memos.len(), 1); - assert_eq!(recovered.memos[0].id, memo.id); + assert_eq!(recovered.memos[0].relative_path, memo.relative_path); let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); let replacement_exists: i64 = conn @@ -510,56 +272,6 @@ fn relative_path_migration_recovers_populated_replacement_table() { ); } -#[test] -fn late_revision_observation_cannot_overwrite_a_newer_commit() { - let (store, root) = fresh_memo_file(); - let other = MemoFile::new(root.join("config")); - let first = store - .commit_memo_content_revision("memo", "nb_test", "old", "first") - .unwrap(); - let newer = other - .commit_memo_content_revision("memo", "nb_test", "new", "second") - .unwrap(); - assert!(store - .commit_memo_content_revision_if_current( - "memo", - "nb_test", - "old", - "late", - Some(&first.state) - ) - .unwrap() - .is_none()); - assert_eq!( - store.read_memo_content_revision("memo").unwrap(), - Some(newer.state) - ); -} - -#[test] -fn missing_revision_observation_cannot_replace_a_first_commit() { - let (store, _) = fresh_memo_file(); - let first = store - .commit_memo_content_revision_if_current("memo", "nb_test", "first", "first", None) - .unwrap() - .unwrap(); - assert!(store - .commit_memo_content_revision_if_current("memo", "nb_test", "stale", "late", None) - .unwrap() - .is_none()); - let next = store - .commit_memo_content_revision_if_current( - "memo", - "nb_test", - "next", - "next", - Some(&first.state), - ) - .unwrap() - .unwrap(); - assert_eq!(next.state.revision, first.state.revision + 1); -} - #[test] fn read_memos_for_notebook_id_does_not_switch_current_notebook() { let (mut mf, tmp) = fresh_memo_file(); @@ -591,9 +303,9 @@ fn read_memos_for_notebook_id_does_not_switch_current_notebook() { assert_eq!(mf.current_notebook_id_value().as_deref(), Some("nb_test")); assert_eq!(other_memos.len(), 1); - assert_eq!(other_memos[0].id, other_memo.id); + assert_eq!(other_memos[0].relative_path, other_memo.relative_path); assert_eq!(current_memos.len(), 1); - assert_eq!(current_memos[0].id, current_memo.id); + assert_eq!(current_memos[0].relative_path, current_memo.relative_path); } #[test] @@ -628,18 +340,18 @@ fn register_existing_file_for_other_notebook_does_not_switch_current_notebook() let other_memos = mf.read_all_memos_for_notebook_id(Some("nb_other")); assert_eq!(other_memos.len(), 1); - assert_eq!(other_memos[0].id, registered.id); + assert_eq!(other_memos[0].relative_path, registered.relative_path); assert_eq!(other_memos[0].filename, "External Agent Note.md"); let current_memos = mf.read_all_memos_for_notebook_id(Some("nb_test")); assert_eq!(current_memos.len(), 1); - assert_eq!(current_memos[0].id, current_memo.id); + assert_eq!(current_memos[0].relative_path, current_memo.relative_path); let conn = rusqlite::Connection::open(mf.notebook_db_path("nb_other").unwrap()).unwrap(); let notebook_id: String = conn .query_row( - "SELECT notebook_id FROM memos WHERE id = ?1", - rusqlite::params![registered.id], + "SELECT notebook_id FROM memos WHERE relative_path = ?1", + rusqlite::params![registered.relative_path], |row| row.get(0), ) .unwrap(); @@ -675,16 +387,15 @@ fn importing_a_copy_with_a_cross_notebook_key_rekeys_the_file() { .register_existing_file_for_notebook_id("nb_other", &imported_path) .unwrap(); - assert_ne!(imported.id, original.id); - assert_eq!( - crate::memo_file::extract_frontmatter_key(&fs::read_to_string(&imported_path).unwrap()), - None - ); + assert_ne!(imported.relative_path, original.relative_path); + assert!(!fs::read_to_string(&imported_path) + .unwrap() + .contains("flowix_key:")); assert!(mf - .read_memo_for_notebook_id("nb_test", &original.id) + .read_memo_for_notebook_id("nb_test", &original.relative_path) .is_some()); assert!(mf - .read_memo_for_notebook_id("nb_other", &imported.id) + .read_memo_for_notebook_id("nb_other", &imported.relative_path) .is_some()); } @@ -709,18 +420,15 @@ fn startup_reconcile_treats_a_copy_as_new_memo_when_original_still_exists() { let copied = mf .find_memo_by_relative_path("Copied from original.md") .unwrap(); - assert_ne!(copied.id, original.id); - assert_eq!( - crate::memo_file::extract_frontmatter_key( - &fs::read_to_string(base.join("Copied from original.md")).unwrap() - ), - None - ); + assert_ne!(copied.relative_path, original.relative_path); + assert!(!fs::read_to_string(base.join("Copied from original.md")) + .unwrap() + .contains("flowix_key:")); assert!(base.join(&original.filename).exists()); } #[test] -fn read_memo_by_id_resolves_global_notebook_location() { +fn read_memo_for_explicit_notebook_resolves_notebook_location() { let (mut mf, tmp) = fresh_memo_file(); let current_memo = mf.create_memo("Current", "# Current", None).unwrap(); @@ -745,26 +453,22 @@ fn read_memo_by_id_resolves_global_notebook_location() { mf.set_current_notebook(Some("nb_test".to_string())); assert!( - mf.read_memo(&other_memo.id).is_none(), + mf.read_memo(&other_memo.relative_path).is_none(), "current-local read should not find other notebook memo" ); let resolved = mf - .read_memo_global(&other_memo.id) - .expect("global memo found"); - let path = mf - .find_memo_file_path(&other_memo.id) - .expect("global path found"); - let (_entry, body) = mf - .read_memo_with_body_global(&other_memo.id) - .expect("global body found"); + .read_memo_for_notebook_id("nb_other", &other_memo.relative_path) + .expect("memo found in the requested notebook"); + let path = other_dir.join(&other_memo.filename); + let body = fs::read_to_string(&path).expect("memo body found"); assert_eq!(mf.current_notebook_id_value().as_deref(), Some("nb_test")); - assert_eq!(resolved.id, other_memo.id); + assert_eq!(resolved.relative_path, other_memo.relative_path); assert_eq!(path, other_dir.join(&other_memo.filename)); assert!(body.contains("# Other")); assert_eq!( - mf.read_all_memos_filtered("all", "createdAt", None)[0].id, - current_memo.id + mf.read_all_memos_filtered("all", "createdAt", None)[0].relative_path, + current_memo.relative_path ); } @@ -794,7 +498,7 @@ fn write_memo_by_id_updates_global_notebook_without_switching_current() { mf.set_current_notebook(Some("nb_test".to_string())); let updated = mf - .write_memo_renaming_on_title_change_global(&other_memo.id, "Renamed Other\nBody") + .write_memo_renaming_on_title_change_global(&other_memo.relative_path, "Renamed Other\nBody") .expect("global write ok"); let other_list = mf .read_index_for_notebook_id(Some("nb_other")) @@ -804,12 +508,12 @@ fn write_memo_by_id_updates_global_notebook_without_switching_current() { let final_content = fs::read_to_string(other_dir.join(&updated.filename)).unwrap(); assert_eq!(mf.current_notebook_id_value().as_deref(), Some("nb_test")); - assert_eq!(updated.id, other_memo.id); + assert_eq!(updated.relative_path, other_memo.relative_path); assert_eq!(updated.filename, "Renamed Other.md"); assert!(final_content.contains("Renamed Other")); - assert!(other_list.memos.iter().any(|memo| memo.id == other_memo.id)); + assert!(other_list.memos.iter().any(|memo| memo.relative_path == other_memo.relative_path)); assert_eq!(current_list.memos.len(), 1); - assert_eq!(current_list.memos[0].id, current_memo.id); + assert_eq!(current_list.memos[0].relative_path, current_memo.relative_path); } #[test] @@ -842,7 +546,7 @@ fn write_memo_global_ignores_generated_conflict_suffix() { mf.set_current_notebook(Some("nb_test".to_string())); let updated = mf - .write_memo_renaming_on_title_change_global(&memo.id, "Stable\nnew body\n") + .write_memo_renaming_on_title_change_global(&memo.relative_path, "Stable\nnew body\n") .expect("global write ok"); assert_eq!(updated.filename, "Stable-1.md"); @@ -878,7 +582,7 @@ fn pasted_file_with_key_from_other_notebook_gets_new_id_in_current_notebook() { let pasted_path = tmp.join("Copied.md"); fs::write( &pasted_path, - format!("---\nflowix_key: {}\n---\n# Copied\n", other_memo.id), + format!("---\nflowix_key: {}\n---\n# Copied\n", other_memo.relative_path), ) .unwrap(); let copied = mf @@ -886,14 +590,11 @@ fn pasted_file_with_key_from_other_notebook_gets_new_id_in_current_notebook() { .expect("register copied file"); let copied_content = fs::read_to_string(&pasted_path).unwrap(); - assert_ne!(copied.id, other_memo.id); + assert_ne!(copied.relative_path, other_memo.relative_path); assert_eq!(copied.filename, "Copied.md"); - assert!(mf.read_memo(&other_memo.id).is_none()); - assert!(mf.read_memo_global(&other_memo.id).is_some()); - assert_eq!( - super::frontmatter::extract_frontmatter_key(&copied_content), - Some(other_memo.id) - ); + assert!(mf.read_memo(&other_memo.relative_path).is_none()); + assert!(mf.read_memo_for_notebook_id("nb_other", &other_memo.relative_path).is_some()); + assert!(copied_content.contains(&format!("flowix_key: {}", other_memo.relative_path))); } // ===================================================================== @@ -965,10 +666,10 @@ fn create_memo_writes_file_and_entry() { assert!(path.exists(), "file should exist at {}", path.display()); // entry 跟磁盘文件名一致, 含 .md assert_eq!(memo.filename, "Hello.md"); - assert!(!memo.id.is_empty()); - assert_eq!(memo.id.len(), super::MEMO_ID_LENGTH); + assert!(!memo.relative_path.is_empty()); + assert_eq!(memo.relative_path, memo.relative_path); // memo index 已注册 - let queried = mf.read_memo(&memo.id).expect("memo in list"); + let queried = mf.read_memo(&memo.relative_path).expect("memo in list"); assert_eq!(queried.filename, "Hello.md"); // preview = 派生第二行 body, 单行 body 时为空 } @@ -994,11 +695,10 @@ fn create_memo_preserves_authored_frontmatter_without_identity() { 1, "content must have one closing frontmatter fence: {content}" ); - assert_eq!(super::frontmatter::extract_frontmatter_key(&content), None); } #[test] -fn template_note_with_existing_flowix_key_creates_after_prior_nested_notes() { +fn template_notes_with_historical_flowix_keys_are_addressed_by_path() { let (mf, base) = fresh_memo_file(); let people_dir = base.join("人物/主要人物"); fs::create_dir_all(&people_dir).unwrap(); @@ -1047,9 +747,9 @@ fn template_note_with_existing_flowix_key_creates_after_prior_nested_notes() { .expect("create next template note"); let content = fs::read_to_string(base.join(&memo.relative_path)).unwrap(); - assert_ne!(memo.id, template_key); + assert_eq!(memo.relative_path, memo.relative_path); assert_eq!(memo.relative_path, "人物/当前人物状态.md"); - assert_eq!(super::frontmatter::extract_frontmatter_key(&content), Some(template_key.to_string())); + assert!(content.contains(&format!("flowix_key: {template_key}"))); } #[test] @@ -1059,26 +759,26 @@ fn read_index_backfills_missing_properties_from_frontmatter() { .create_memo("Backfill", "---\nstatus: review\n---\n# Backfill\n", None) .expect("create ok"); - // Legacy memo rows live in the per-notebook notebook.db. + // Path-keyed memo rows live in the per-notebook notebook.db. let notebook_db = mf.notebook_db_path("nb_test").unwrap(); { let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); conn.execute( - "UPDATE memos SET properties = '{}' WHERE id = ?1", - rusqlite::params![memo.id], + "UPDATE memos SET properties = '{}' WHERE relative_path = ?1", + rusqlite::params![memo.relative_path], ) .unwrap(); } mf.invalidate_caches(); - let from_index = mf.read_memo(&memo.id).expect("memo in index"); + let from_index = mf.read_memo(&memo.relative_path).expect("memo in index"); assert_eq!(from_index.properties["status"], "review"); let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); let properties: String = conn .query_row( - "SELECT properties FROM memos WHERE id = ?1", - rusqlite::params![memo.id], + "SELECT properties FROM memos WHERE relative_path = ?1", + rusqlite::params![memo.relative_path], |row| row.get(0), ) .unwrap(); @@ -1093,7 +793,7 @@ fn create_memo_handles_title_conflict() { let b = mf.create_memo("Hello", "y", None).unwrap(); assert_eq!(a.filename, "Hello.md"); assert_eq!(b.filename, "Hello-1.md"); - assert_ne!(a.id, b.id); + assert_ne!(a.relative_path, b.relative_path); assert!(base.join("Hello.md").exists()); assert!(base.join("Hello-1.md").exists()); } @@ -1122,7 +822,7 @@ fn occupied_names_and_cache_follow_only_the_target_directory() { .read_index_for_notebook_id(Some("nb_test")) .unwrap() .unwrap(); - assert!(index.memos.iter().any(|entry| entry.id == conflict.id)); + assert!(index.memos.iter().any(|entry| entry.relative_path == conflict.relative_path)); } #[test] @@ -1133,11 +833,11 @@ fn delayed_remove_cannot_unregister_a_path_that_reappeared() { // A queued Remove may be delivered after a template writes this path. assert!(!mf.unregister_memo_by_path_for_notebook_id("nb_test", &path)); - assert!(mf.read_memo_for_notebook_id("nb_test", &memo.id).is_some()); + assert!(mf.read_memo_for_notebook_id("nb_test", &memo.relative_path).is_some()); fs::remove_file(&path).unwrap(); assert!(mf.unregister_memo_by_path_for_notebook_id("nb_test", &path)); - assert!(mf.read_memo_for_notebook_id("nb_test", &memo.id).is_none()); + assert!(mf.read_memo_for_notebook_id("nb_test", &memo.relative_path).is_none()); } #[test] @@ -1191,11 +891,11 @@ fn rename_memo_renames_file_and_updates_list() { let memo = mf.create_memo("Old", "body", None).unwrap(); assert!(base.join("Old.md").exists()); - let updated = mf.rename_memo(&memo.id, "New").expect("rename ok"); + let updated = mf.rename_memo(&memo.relative_path, "New").expect("rename ok"); assert_eq!(updated.filename, "New.md"); assert!(!base.join("Old.md").exists(), "old file should be gone"); assert!(base.join("New.md").exists()); - let queried = mf.read_memo(&memo.id).expect("still in list"); + let queried = mf.read_memo(&memo.relative_path).expect("still in list"); assert_eq!(queried.filename, "New.md"); } @@ -1207,7 +907,7 @@ fn rename_memo_preserves_nested_directory() { fs::write(nested.join("Old.md"), "# Old\n").unwrap(); let memo = mf.register_existing_file(&nested.join("Old.md")).unwrap(); - let updated = mf.rename_memo(&memo.id, "New").unwrap(); + let updated = mf.rename_memo(&memo.relative_path, "New").unwrap(); assert_eq!(updated.relative_path, "projects/backend/New.md"); assert!(!nested.join("Old.md").exists()); assert!(nested.join("New.md").exists()); @@ -1219,18 +919,17 @@ fn rename_memo_handles_conflict() { let a = mf.create_memo("First", "a", None).unwrap(); let _b = mf.create_memo("Second", "b", None).unwrap(); // 把 a 改名为 "Second" → 冲突 → 用 Second-1.md - let updated = mf.rename_memo(&a.id, "Second").unwrap(); + let updated = mf.rename_memo(&a.relative_path, "Second").unwrap(); assert_eq!(updated.filename, "Second-1.md"); assert!(base.join("Second-1.md").exists()); } #[test] -fn rename_memo_renames_disk_file_and_keeps_key_in_frontmatter() { - // 新 frontmatter 语义: rename 只改磁盘文件名 + memo index.filename, - // frontmatter 块内 `key` 保持与 memo index id 一致, 不再有 `filename` 字段。 +fn rename_memo_does_not_inject_identity_metadata() { + // Renaming changes the path projection and disk filename, not authored YAML. let (mf, _base) = fresh_memo_file(); let memo = mf.create_memo("First", "body\n", None).unwrap(); - let _ = mf.rename_memo(&memo.id, "Second").unwrap(); + let _ = mf.rename_memo(&memo.relative_path, "Second").unwrap(); let content = fs::read_to_string(_base.join("Second.md")).unwrap(); assert!(!content.contains("flowix_key:")); assert!( @@ -1243,7 +942,7 @@ fn rename_memo_renames_disk_file_and_keeps_key_in_frontmatter() { fn rename_memo_unchanged_title_is_noop() { let (mf, base) = fresh_memo_file(); let memo = mf.create_memo("Same", "body", None).unwrap(); - let updated = mf.rename_memo(&memo.id, "Same").unwrap(); + let updated = mf.rename_memo(&memo.relative_path, "Same").unwrap(); assert_eq!(updated.filename, "Same.md"); assert!(base.join("Same.md").exists()); } @@ -1263,7 +962,7 @@ fn rename_memo_not_found_returns_io_error() { fn write_memo_updates_body_and_keeps_filename() { let (mf, base) = fresh_memo_file(); let memo = mf.create_memo("Title", "old\n", None).unwrap(); - let updated = mf.write_memo(&memo.id, "new\n").expect("write ok"); + let updated = mf.write_memo(&memo.relative_path, "new\n").expect("write ok"); assert_eq!(updated.filename, "Title.md"); // single-line body → preview empty let content = fs::read_to_string(base.join("Title.md")).unwrap(); @@ -1275,7 +974,7 @@ fn write_memo_updates_body_and_keeps_filename() { fn write_memo_does_not_inject_identity() { let (mf, base) = fresh_memo_file(); let memo = mf.create_memo("Stable", "body\n", None).unwrap(); - let _ = mf.write_memo(&memo.id, "more\n").unwrap(); + let _ = mf.write_memo(&memo.relative_path, "more\n").unwrap(); let content = fs::read_to_string(base.join("Stable.md")).unwrap(); assert!(!content.contains("flowix_key:")); assert!( @@ -1302,14 +1001,14 @@ fn write_rename_changes_disk_when_first_line_changes() { assert!(base.join("Old.md").exists()); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "Renamed\nbody line 2\n") + .write_memo_renaming_on_title_change(&memo.relative_path, "Renamed\nbody line 2\n") .expect("write+rename ok"); // 派生 title = 整行 "Renamed" (经 strip_markdown 清洗, 无装饰), filename = "Renamed.md" assert_eq!(updated.filename, "Renamed.md"); assert!(!base.join("Old.md").exists(), "old file should be gone"); assert!(base.join("Renamed.md").exists()); - let queried = mf.read_memo(&memo.id).expect("still in list"); + let queried = mf.read_memo(&memo.relative_path).expect("still in list"); assert_eq!(queried.filename, "Renamed.md"); } @@ -1322,10 +1021,10 @@ fn write_rename_noop_when_first_line_unchanged() { // 首行仍是 "Stable" (加新行不影响派生 title) let new_body = "Stable\nnew line 2\nnew line 3\n"; let _ = mf - .write_memo_renaming_on_title_change(&memo.id, new_body) + .write_memo_renaming_on_title_change(&memo.relative_path, new_body) .expect("write ok"); assert!(base.join("Stable.md").exists()); - let queried = mf.read_memo(&memo.id).expect("still in list"); + let queried = mf.read_memo(&memo.relative_path).expect("still in list"); assert_eq!(queried.filename, "Stable.md"); } @@ -1341,7 +1040,7 @@ fn write_rename_ignores_generated_conflict_suffix_when_title_is_base() { assert_eq!(memo.filename, "Stable-1.md"); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "Stable\nnew body\n") + .write_memo_renaming_on_title_change(&memo.relative_path, "Stable\nnew body\n") .expect("write ok"); assert_eq!(updated.filename, "Stable-1.md"); @@ -1357,7 +1056,7 @@ fn write_rename_treats_suffix_in_new_title_as_a_title_change() { .unwrap(); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "Stable-1\nnew body\n") + .write_memo_renaming_on_title_change(&memo.relative_path, "Stable-1\nnew body\n") .expect("write+rename ok"); assert_eq!(updated.filename, "Stable-1.md"); @@ -1377,7 +1076,7 @@ fn write_rename_changes_generated_suffix_when_title_changes() { assert_eq!(memo.filename, "Stable-1.md"); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "Stable-2\nnew body\n") + .write_memo_renaming_on_title_change(&memo.relative_path, "Stable-2\nnew body\n") .expect("write+rename ok"); assert_eq!(updated.filename, "Stable-2.md"); @@ -1397,7 +1096,7 @@ fn write_rename_keeps_filename_when_title_includes_same_suffix() { assert_eq!(memo.filename, "Stable-1.md"); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "Stable-1\nnew body\n") + .write_memo_renaming_on_title_change(&memo.relative_path, "Stable-1\nnew body\n") .expect("write ok"); assert_eq!(updated.filename, "Stable-1.md"); @@ -1414,7 +1113,7 @@ fn write_rename_handles_conflict_with_dash_suffix() { let b = mf.create_memo("Second", "b\n", None).unwrap(); // 把 b 的首行改成 "First" → 跟 a 冲突 → 走 First-1.md let updated = mf - .write_memo_renaming_on_title_change(&b.id, "First\nnew body\n") + .write_memo_renaming_on_title_change(&b.relative_path, "First\nnew body\n") .expect("write+rename ok"); assert_eq!(updated.filename, "First-1.md"); assert!(base.join("First-1.md").exists()); @@ -1427,7 +1126,7 @@ fn write_rename_does_not_inject_identity() { let (mf, base) = fresh_memo_file(); let memo = mf.create_memo("Start", "Start\n", None).unwrap(); let _ = mf - .write_memo_renaming_on_title_change(&memo.id, "End\nnew body\n") + .write_memo_renaming_on_title_change(&memo.relative_path, "End\nnew body\n") .expect("write+rename ok"); let content = fs::read_to_string(base.join("End.md")).unwrap(); assert!(!content.contains("flowix_key:")); @@ -1439,13 +1138,13 @@ fn write_rename_empty_body_uses_untitled_memo() { let (mf, base) = fresh_memo_file(); let memo = mf.create_memo("Keep", "body\n", None).unwrap(); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "") + .write_memo_renaming_on_title_change(&memo.relative_path, "") .expect("write ok"); assert_eq!(updated.filename, "Untitled Memo.md"); assert!(!base.join("Keep.md").exists()); assert!(base.join("Untitled Memo.md").exists()); - let queried = mf.read_memo(&memo.id).expect("still in list"); + let queried = mf.read_memo(&memo.relative_path).expect("still in list"); assert_eq!(queried.filename, "Untitled Memo.md"); assert_eq!(queried.preview, ""); } @@ -1456,7 +1155,7 @@ fn write_rename_empty_body_uses_untitled_memo_dash_suffix_on_conflict() { let _existing = mf.create_memo("Untitled Memo", "existing\n", None).unwrap(); let memo = mf.create_memo("Keep", "body\n", None).unwrap(); let updated = mf - .write_memo_renaming_on_title_change(&memo.id, "") + .write_memo_renaming_on_title_change(&memo.relative_path, "") .expect("write ok"); assert_eq!(updated.filename, "Untitled Memo-1.md"); @@ -1474,10 +1173,10 @@ fn delete_memo_removes_file_and_index_entry() { let memo = mf.create_memo("Del", "x", None).unwrap(); assert!(base.join("Del.md").exists()); - let removed = mf.delete_memo(&memo.id); + let removed = mf.delete_memo(&memo.relative_path); assert!(removed); assert!(!base.join("Del.md").exists()); - assert!(mf.read_memo(&memo.id).is_none()); + assert!(mf.read_memo(&memo.relative_path).is_none()); } #[test] @@ -1486,9 +1185,9 @@ fn delete_memo_handles_orphan_index_entry() { let memo = mf.create_memo("Orphan", "x", None).unwrap(); // 模拟外部 rm: 物理文件先没 fs::remove_file(base.join("Orphan.md")).unwrap(); - let removed = mf.delete_memo(&memo.id); + let removed = mf.delete_memo(&memo.relative_path); assert!(removed, "should clear memo index orphan entry"); - assert!(mf.read_memo(&memo.id).is_none()); + assert!(mf.read_memo(&memo.relative_path).is_none()); } #[test] @@ -1520,11 +1219,11 @@ fn scoped_delete_does_not_remove_same_id_from_another_notebook() { let other_path = other_dir.join(&other_memo.filename); assert!(!mf - .delete_memo_result_for_notebook_id("nb_test", &other_memo.id) + .delete_memo_result_for_notebook_id("nb_test", &other_memo.relative_path) .unwrap()); assert!(other_path.exists()); assert!(mf - .read_memo_for_notebook_id("nb_other", &other_memo.id) + .read_memo_for_notebook_id("nb_other", &other_memo.relative_path) .is_some()); } @@ -1540,7 +1239,7 @@ fn registration_never_rewrites_markdown() { let memo = mf.register_existing_file(&abs).expect("register ok"); assert_eq!(memo.filename, "PreExisting.md"); - assert!(!memo.id.is_empty()); + assert!(!memo.relative_path.is_empty()); let content = fs::read_to_string(&abs).unwrap(); assert!(!content.contains("flowix_key:")); assert!( @@ -1548,7 +1247,7 @@ fn registration_never_rewrites_markdown() { "original body must be preserved: {content}" ); let queried = mf.find_memo_by_filename("PreExisting.md").expect("in list"); - assert_eq!(queried.id, memo.id); + assert_eq!(queried.relative_path, memo.relative_path); } #[test] @@ -1577,7 +1276,7 @@ fn register_existing_file_reload_when_already_in_list() { // 改盘 + 再次 register → 应走 reload, 不重复 push fs::write(&abs, "# title\nv2 body\n").unwrap(); let memo2 = mf.register_existing_file(&abs).unwrap(); - assert_eq!(memo1.id, memo2.id); + assert_eq!(memo1.relative_path, memo2.relative_path); let list = mf.read_index().expect("list"); assert_eq!(list.memos.len(), 1); assert_eq!(memo2.preview, "v2 body"); // 第二行= @@ -1696,7 +1395,7 @@ fn unobserved_directory_move_rebuilds_paths_without_guessing_identity() { let ids = mf .read_all_memos() .into_iter() - .map(|memo| (memo.filename, memo.id)) + .map(|memo| (memo.filename, memo.relative_path)) .collect::>(); fs::rename(base.join("docs/guide"), base.join("docs/manual")).unwrap(); @@ -1707,7 +1406,7 @@ fn unobserved_directory_move_rebuilds_paths_without_guessing_identity() { assert_eq!(report.removed, 2); for memo in mf.read_all_memos() { assert!(memo.relative_path.starts_with("docs/manual/")); - assert_ne!(ids.get(&memo.filename), Some(&memo.id)); + assert_ne!(ids.get(&memo.filename), Some(&memo.relative_path)); } } @@ -1739,7 +1438,7 @@ fn reconcile_bidirectional_adds_orphans_and_removes_ghosts() { fs::remove_file(&ghost_path).unwrap(); assert!(!ghost_path.exists(), "外部 rm 删了物理文件"); // memo index 仍残留 ghost entry - assert!(mf.read_memo(&ghost.id).is_some()); + assert!(mf.read_memo(&ghost.relative_path).is_some()); // 准备: 在盘上建 NewOnDisk.md, 不通过 API 注册 (模拟外部 drop) fs::write(base.join("NewOnDisk.md"), "fresh content").unwrap(); @@ -1754,7 +1453,7 @@ fn reconcile_bidirectional_adds_orphans_and_removes_ghosts() { assert_eq!(report.removed, 1, "应清 Ghost.md 幽灵条目"); // memo index: Ghost entry 已清, NewOnDisk 已注册 - assert!(mf.read_memo(&ghost.id).is_none(), "ghost entry 应被清"); + assert!(mf.read_memo(&ghost.relative_path).is_none(), "ghost entry 应被清"); assert!( mf.find_memo_by_filename("NewOnDisk.md").is_some(), "新文件应被注册" @@ -1779,7 +1478,7 @@ fn reconcile_bidirectional_is_idempotent() { let second = mf.reconcile_with_disk_bidirectional().unwrap(); assert_eq!(second.added, 0); assert_eq!(second.removed, 0); - assert!(mf.read_memo(&ghost.id).is_none()); + assert!(mf.read_memo(&ghost.relative_path).is_none()); } #[test] @@ -1807,7 +1506,7 @@ fn unobserved_move_is_delete_and_create() { let old_path = base.join("Original.md"); fs::write(&old_path, "# Hello\nbody\n").unwrap(); let initial = mf.register_existing_file(&old_path).expect("register"); - let original_id = initial.id.clone(); + let original_relative_path = initial.relative_path.clone(); assert_eq!(initial.filename, "Original.md"); let new_path = base.join("Renamed.md"); fs::rename(&old_path, &new_path).unwrap(); @@ -1817,7 +1516,7 @@ fn unobserved_move_is_delete_and_create() { let list = mf.read_index().expect("list"); assert_eq!(list.memos.len(), 1, "exactly one entry"); assert_ne!( - list.memos[0].id, original_id, + list.memos[0].relative_path, original_relative_path, "unobserved move gets a new cache association" ); assert_eq!(list.memos[0].filename, "Renamed.md", "filename updated"); @@ -1864,13 +1563,10 @@ fn reconcile_bidirectional_as_new_rekeys_existing_markdown() { assert_eq!(report.added, 1); let imported = mf.find_memo_by_filename("Imported.md").unwrap(); - assert_ne!(imported.id, original.id); + assert_ne!(imported.relative_path, original.relative_path); let imported_content = fs::read_to_string(imported_path).unwrap(); - assert_eq!( - super::frontmatter::extract_frontmatter_key(&imported_content), - None - ); + assert!(!imported_content.contains("flowix_key:")); } #[test] @@ -1881,7 +1577,7 @@ fn unregister_clears_ghost_entry_when_file_gone() { let memo = mf.create_memo("Ghost", "x", None).unwrap(); fs::remove_file(base.join("Ghost.md")).unwrap(); assert!( - mf.read_memo(&memo.id).is_some(), + mf.read_memo(&memo.relative_path).is_some(), "memo index 仍有 ghost entry" ); @@ -1889,7 +1585,7 @@ fn unregister_clears_ghost_entry_when_file_gone() { let abs = base.join("Ghost.md"); let removed = mf.unregister_memo_by_path(&abs); assert!(removed); - assert!(mf.read_memo(&memo.id).is_none(), "ghost entry 已清"); + assert!(mf.read_memo(&memo.relative_path).is_none(), "ghost entry 已清"); } #[test] @@ -1902,7 +1598,7 @@ fn unregister_refuses_when_filename_differs() { let bogus_abs = base.join("Bogus.md"); let removed = mf.unregister_memo_by_path(&bogus_abs); assert!(!removed, "filename 不匹配应拒绝"); - assert!(mf.read_memo(&memo.id).is_some(), "entry 仍保留"); + assert!(mf.read_memo(&memo.relative_path).is_some(), "entry 仍保留"); } // ===================================================================== @@ -1918,7 +1614,7 @@ fn reload_refreshes_preview_after_external_edit() { "---\nfilename: Reload\n---\n# title\nchanged body\n", ) .unwrap(); - let updated = mf.reload_memo_from_disk(&memo.id).expect("reload ok"); + let updated = mf.reload_memo_from_disk(&memo.relative_path).expect("reload ok"); assert_eq!(updated.filename, "Reload.md"); assert!(updated.preview.contains("changed")); } @@ -1934,7 +1630,7 @@ fn unregister_by_path_removes_index_entry() { let abs = base.join("Del.md"); let removed = mf.unregister_memo_by_path(&abs); assert!(removed); - assert!(mf.read_memo(&memo.id).is_none()); + assert!(mf.read_memo(&memo.relative_path).is_none()); } #[test] @@ -1955,13 +1651,13 @@ fn sync_metadata_only_persists_markdown_and_rebuilds_index() { let memo = mf.create_memo("Meta", "body\n", None).unwrap(); let mut updated = memo.clone(); updated.favorited = true; - updated.colors = vec![super::types::MemoColor::Red]; + updated.colors = vec![super::types::NoteColor::Red]; mf.sync_metadata_only(&updated).expect("sync ok"); // memo index 字段已更新 - let queried = mf.read_memo(&memo.id).unwrap(); + let queried = mf.read_memo(&memo.relative_path).unwrap(); assert!(queried.favorited); - assert_eq!(queried.colors, vec![super::types::MemoColor::Red]); + assert_eq!(queried.colors, vec![super::types::NoteColor::Red]); // Markdown is the source of truth for user-visible metadata. let after = fs::read_to_string(base.join("Meta.md")).unwrap(); assert!(after.contains("flowix_favorited: true")); @@ -1971,7 +1667,7 @@ fn sync_metadata_only_persists_markdown_and_rebuilds_index() { assert!(mf.unregister_memo_by_path(&path)); let rebuilt = mf.register_existing_file(&path).unwrap(); assert!(rebuilt.favorited); - assert_eq!(rebuilt.colors, vec![super::types::MemoColor::Red]); + assert_eq!(rebuilt.colors, vec![super::types::NoteColor::Red]); } // ===================================================================== @@ -2000,11 +1696,10 @@ fn index_filename_is_disk_filename_with_md_suffix() { } #[test] -fn memo_struct_has_no_path_field() { - // 编译期保证: types::Memo 不应有 path 字段。本测试若通过, 表明 schema 正确。 +fn memo_struct_uses_relative_path_as_its_address() { let memo: super::types::Memo = serde_json::from_value(serde_json::json!({ - "id": "abc", "filename": "X.md", + "relativePath": "X.md", "preview": "", "tags": [], "todos": [], @@ -2016,6 +1711,7 @@ fn memo_struct_has_no_path_field() { })) .unwrap(); assert_eq!(memo.filename, "X.md"); + assert_eq!(memo.relative_path, "X.md"); } // ===================================================================== @@ -2031,8 +1727,8 @@ fn read_all_memos_returns_list_entries_sorted_by_created_desc() { let memos = mf.read_all_memos(); assert_eq!(memos.len(), 2); // 后建的排前 - assert_eq!(memos[0].id, b.id); - assert_eq!(memos[1].id, a.id); + assert_eq!(memos[0].relative_path, b.relative_path); + assert_eq!(memos[1].relative_path, a.relative_path); } #[test] @@ -2048,7 +1744,7 @@ fn read_all_memos_with_body_returns_bodies() { fn read_memo_with_body_returns_full_content() { let (mf, _base) = fresh_memo_file(); let memo = mf.create_memo("Y", "body-of-y\n", None).unwrap(); - let (entry, body) = mf.read_memo_with_body(&memo.id).unwrap(); + let (entry, body) = mf.read_memo_with_body(&memo.relative_path).unwrap(); assert_eq!(entry.filename, "Y.md"); assert!(body.contains("body-of-y")); } @@ -2057,7 +1753,7 @@ fn read_memo_with_body_returns_full_content() { fn find_memo_file_path_returns_disk_path() { let (mf, base) = fresh_memo_file(); let memo = mf.create_memo("Find", "x", None).unwrap(); - let p = mf.find_memo_file_path(&memo.id).expect("path found"); + let p = mf.find_memo_file_path(&memo.relative_path).expect("path found"); assert_eq!(p, base.join("Find.md")); } @@ -2072,13 +1768,13 @@ fn read_all_memos_filtered_favorited() { let only_fav = mf.read_all_memos_filtered("favorited", "createdAt", None); assert_eq!(only_fav.len(), 1); - assert_eq!(only_fav[0].id, b.id); + assert_eq!(only_fav[0].relative_path, b.relative_path); let all = mf.read_all_memos_filtered("all", "createdAt", None); assert_eq!(all.len(), 2); // favorited 置顶 - assert_eq!(all[0].id, b.id); - assert_eq!(all[1].id, a.id); + assert_eq!(all[0].relative_path, b.relative_path); + assert_eq!(all[1].relative_path, a.relative_path); } // 回归测试: 带标签筛选时 (filter == "tagged"), 置顶 memo 仍需靠前. @@ -2103,15 +1799,15 @@ fn read_all_memos_filtered_tagged_pins_favorited() { let tagged = mf.read_all_memos_filtered("tagged", "createdAt", Some("t1")); assert_eq!(tagged.len(), 2); assert_eq!( - tagged[0].id, older.id, + tagged[0].relative_path, older.relative_path, "favorited tag 必须在 tagged 视图最前" ); - assert_eq!(tagged[1].id, newer.id); + assert_eq!(tagged[1].relative_path, newer.relative_path); let tagged_by_updated = mf.read_all_memos_filtered("tagged", "updatedAt", Some("t1")); assert_eq!(tagged_by_updated.len(), 2); - assert_eq!(tagged_by_updated[0].id, older.id); - assert_eq!(tagged_by_updated[1].id, newer.id); + assert_eq!(tagged_by_updated[0].relative_path, older.relative_path); + assert_eq!(tagged_by_updated[1].relative_path, newer.relative_path); } // ===================================================================== @@ -2177,7 +1873,7 @@ fn no_duplicate_ids_after_concurrent_creates() { h.join().expect("join"); } let list = mf.read().unwrap().read_index().expect("list"); - let mut ids: Vec = list.memos.iter().map(|e| e.id.clone()).collect(); + let mut ids: Vec = list.memos.iter().map(|e| e.relative_path.clone()).collect(); ids.sort(); ids.dedup(); assert_eq!(ids.len(), N, "all ids unique"); @@ -2236,10 +1932,7 @@ fn independent_instances_create_without_lost_rows_or_file_overwrites() { assert_eq!(filenames.len(), PER_INSTANCE * 2); for memo in &list.memos { let content = fs::read_to_string(base.join(&memo.filename)).expect("memo file exists"); - assert_eq!( - super::frontmatter::extract_frontmatter_key(&content).as_deref(), - None - ); + assert!(!content.contains("flowix_key:")); } } @@ -2260,7 +1953,7 @@ fn independent_instance_writes_are_visible_after_local_cache_was_populated() { .expect("peer create"); let refreshed = first.read_index().expect("peer row visible"); - assert!(refreshed.memos.iter().any(|memo| memo.id == created.id)); + assert!(refreshed.memos.iter().any(|memo| memo.relative_path == created.relative_path)); } // ===================================================================== @@ -2305,22 +1998,16 @@ fn _dummy_memo_list_file() -> MemoIndexFile { // ===================================================================== #[test] -fn rename_memo_file_updates_list_json_filename_preserves_id() { - // 场景: memo index 里有一条 entry { id, filename: "Hello.md" }, - // 物理文件被外部 mv 成 "Hello-renamed.md"。rename_memo_file 应该: - // 1. 改 entry.filename → "Hello-renamed.md" - // 2. 保留 entry.id (register 时生成的) - // 3. 返回新的 Memo, 字段一致 +fn rename_memo_file_rekeys_entry_to_new_relative_path() { let (mf, _tmp) = fresh_memo_file(); - // 先 register 一个文件。 register_existing_file 会自己生成 6 位 id 并注入 - // frontmatter, 我们记下这个 id 用作断言。 + // Register the file, then move it to a different relative path. let old_path = mf.get_memo_base().join("Hello.md"); fs::write(&old_path, "# body content\n").unwrap(); let initial = mf.register_existing_file(&old_path).expect("register ok"); - let original_id = initial.id.clone(); + let old_relative_path = initial.relative_path.clone(); assert_eq!(initial.filename, "Hello.md"); - assert!(!original_id.is_empty()); + assert_eq!(initial.relative_path, "Hello.md"); // 模拟物理 mv (外部工具 rename) let new_path = mf.get_memo_base().join("Hello-renamed.md"); @@ -2332,10 +2019,8 @@ fn rename_memo_file_updates_list_json_filename_preserves_id() { let updated = mf .rename_memo_file(&old_path, &new_path) .expect("rename_memo_file should succeed"); - assert_eq!( - updated.id, original_id, - "id must be preserved across physical rename" - ); + assert_eq!(updated.relative_path, "Hello-renamed.md"); + assert_ne!(updated.relative_path, old_relative_path); assert_eq!(updated.filename, "Hello-renamed.md"); // memo index 已经被同步: 旧 filename 不应再出现在 list 里 @@ -2343,17 +2028,17 @@ fn rename_memo_file_updates_list_json_filename_preserves_id() { let entry = list .memos .iter() - .find(|e| e.id == original_id) - .expect("entry still in list under original id"); + .find(|e| e.relative_path == "Hello-renamed.md") + .expect("entry is indexed at the new path"); assert_eq!(entry.filename, "Hello-renamed.md"); assert!( list.memos.iter().all(|e| e.filename != "Hello.md"), "old filename must be gone from memo index" ); - // 按 id 读 memo 仍能读到, filename 已是新值 - let by_id = mf.read_memo(&original_id).expect("read by id"); - assert_eq!(by_id.filename, "Hello-renamed.md"); + assert!(list.memos.iter().all(|entry| entry.relative_path != old_relative_path)); + let by_path = mf.read_memo("Hello-renamed.md").expect("read by path"); + assert_eq!(by_path.filename, "Hello-renamed.md"); } #[test] @@ -2384,7 +2069,7 @@ fn rename_memo_file_rejects_when_new_filename_occupied() { fs::rename(&b, &b_tmp).unwrap(); fs::rename(&a, &b).unwrap(); - // rename_memo_file 应当拒绝: new filename "B.md" 已被另一条 entry (id=bbb) 占用 + // rename_memo_file 应当拒绝: new path "B.md" 已被另一条 entry 占用 let result = mf.rename_memo_file(&a, &b); assert!( result.is_err(), @@ -2403,107 +2088,48 @@ fn rename_memo_file_rejects_when_new_filename_occupied() { // (例如重启后第一次 notify, 或 Windows 上没有稳定 inode)。processor // 走原 Create 分支, 调 `register_existing_file(new_path)`。 // -// 期望: memo index 里应该仍然是原来 id=abc123 的那条 entry, 仅仅 -// filename 字段从 "Hello.md" 变成 "Hello-renamed.md"。 -// -// 实际 (用户报告 bug): memo index 出现 id=xyz789 的新 entry, 旧的 -// abc123 还在但 filename 还是 "Hello.md" (指向不存在的文件)。 +// 期望: 索引从旧相对路径迁到新相对路径,不残留指向不存在文件的旧行。 #[test] -fn explicit_path_rename_preserves_cache_association() { +fn explicit_path_rename_rekeys_note_index() { let (mf, _tmp) = fresh_memo_file(); let old_path = mf.get_memo_base().join("Hello.md"); fs::write(&old_path, "# Hello\nworld\n").unwrap(); let initial = mf.register_existing_file(&old_path).expect("register ok"); - let original_id = initial.id.clone(); + let old_relative_path = initial.relative_path.clone(); assert_eq!(initial.filename, "Hello.md"); let new_path = mf.get_memo_base().join("Hello-renamed.md"); fs::rename(&old_path, &new_path).unwrap(); let rereg = mf .rename_memo_file(&old_path, &new_path) .expect("explicit rename pair"); - assert_eq!( - rereg.id, original_id, - "explicit rename must preserve the cache association" - ); + assert_eq!(rereg.relative_path, "Hello-renamed.md"); + assert_ne!(rereg.relative_path, old_relative_path); assert_eq!(rereg.filename, "Hello-renamed.md"); let list = mf.read_index().expect("memo index"); - let with_id = list.memos.iter().filter(|e| e.id == original_id).count(); - assert_eq!(with_id, 1, "exactly one entry with original id"); - let entry = list.memos.iter().find(|e| e.id == original_id).unwrap(); + assert_eq!(list.memos.len(), 1); + let entry = list.memos.iter().find(|e| e.relative_path == rereg.relative_path).unwrap(); assert_eq!(entry.filename, "Hello-renamed.md"); } -// ===================================================================== -// 端到端: 物理 rename 后磁盘 frontmatter 的 key 是否真的保持不变 -// ===================================================================== -#[test] -fn physical_rename_does_not_change_frontmatter_key_on_disk() { - let (mf, _tmp) = fresh_memo_file(); - // 1. register - let old_path = mf.get_memo_base().join("Hello.md"); - fs::write(&old_path, "# Hello\nworld\n").unwrap(); - let m = mf.register_existing_file(&old_path).expect("register"); - let original_id = m.id.clone(); - let key_after_register = read_key_from_disk(&old_path); - assert_eq!( - key_after_register.as_deref(), - None, - "after register, key on disk should match id" - ); - - // 2. physical rename - let new_path = mf.get_memo_base().join("Hello-renamed.md"); - fs::rename(&old_path, &new_path).unwrap(); - let key_after_rename = read_key_from_disk(&new_path); - assert_eq!( - key_after_rename.as_deref(), - None, - "after physical rename, key on disk should still be the original id" - ); - - // 3. what does rename_memo_file do to the disk? - mf.rename_memo_file(&old_path, &new_path).expect("rename"); - let key_after_rename_memo_file = read_key_from_disk(&new_path); - eprintln!("original_id={:?}", original_id); - eprintln!( - "key on disk after rename_memo_file = {:?}", - key_after_rename_memo_file - ); - // 这个断言**会失败**如果 rename_memo_file 把 disk key 改了 - assert_eq!( - key_after_rename_memo_file.as_deref(), - None, - "rename_memo_file must NOT change frontmatter key on disk (it's id-bound)" - ); -} - -fn read_key_from_disk(path: &Path) -> Option { - use super::frontmatter::extract_frontmatter_key; - let content = std::fs::read_to_string(path).ok()?; - extract_frontmatter_key(&content) -} - // ===================================================================== // v2 inode-rename: 模拟 Remove(old) + Create(new) 事件序列 (不通过 notify) -// 验证 memo index 的 entry 仍然只有 1 条, id 保留。 +// 验证 memo index 仅保留新相对路径的 entry。 // ===================================================================== #[test] -fn rename_via_remove_create_pair_preserves_id() { +fn rename_via_remove_create_pair_rekeys_relative_path() { // 这测的是 inode tracker 命中时 Remove 事件被拦截 + Create 走 rename 配对的 // 协同行为, 但 MemoFile 层不直接持有 inode tracker, 测的是 memo index // 同步结果。 processor 层的 inode 检查是 desktop 端。 - // 替代方案: 直接测 register_existing_file + unregister_memo_by_path 在 - // inode 命中 (即 tracker 里有该 inode) 时的等价行为: 不 unregister, - // 让 register_existing_file 抽 frontmatter key 反查保留 id。 + // 直接测物理 rename 事件配对后更新索引路径。 let (mf, _tmp) = fresh_memo_file(); // 1. 原始文件 let old_path = mf.get_memo_base().join("Hello.md"); fs::write(&old_path, "# Hello\nworld\n").unwrap(); let initial = mf.register_existing_file(&old_path).expect("register ok"); - let original_id = initial.id.clone(); + let _old_relative_path = initial.relative_path.clone(); // 2. 物理 rename let new_path = mf.get_memo_base().join("Hello-renamed.md"); @@ -2515,10 +2141,10 @@ fn rename_via_remove_create_pair_preserves_id() { mf.rename_memo_file(&old_path, &new_path) .expect("rename should succeed"); - // 4. 关键断言: memo index 只有一条 entry, id 保留 + // 4. 关键断言: memo index 只有一条 entry,键为新路径 let list = mf.read_index().expect("list"); assert_eq!(list.memos.len(), 1, "exactly one entry remains"); - assert_eq!(list.memos[0].id, original_id, "id preserved"); + assert_eq!(list.memos[0].relative_path, "Hello-renamed.md"); assert_eq!( list.memos[0].filename, "Hello-renamed.md", "filename updated" @@ -2526,12 +2152,12 @@ fn rename_via_remove_create_pair_preserves_id() { } #[test] -fn unpaired_remove_create_cannot_resurrect_old_identity() { +fn unpaired_remove_create_indexes_only_the_new_relative_path() { let (mf, _tmp) = fresh_memo_file(); let old_path = mf.get_memo_base().join("Hello.md"); fs::write(&old_path, "# Hello\nworld\n").unwrap(); let initial = mf.register_existing_file(&old_path).expect("register ok"); - let original_id = initial.id.clone(); + let old_relative_path = initial.relative_path.clone(); let new_path = mf.get_memo_base().join("Hello-renamed.md"); fs::rename(&old_path, &new_path).unwrap(); @@ -2541,16 +2167,14 @@ fn unpaired_remove_create_cannot_resurrect_old_identity() { assert_eq!(list.memos.len(), 0, "list is empty after remove"); let rereg = mf .register_existing_file(&new_path) - .expect("register should succeed via frontmatter key fallback"); - assert_ne!( - rereg.id, original_id, - "unpaired events must not infer an old cache association" - ); + .expect("register should recreate the path-keyed entry"); + assert_eq!(rereg.relative_path, "Hello-renamed.md"); + assert_ne!(rereg.relative_path, old_relative_path); assert_eq!(rereg.filename, "Hello-renamed.md"); let list = mf.read_index().expect("list"); assert_eq!(list.memos.len(), 1, "exactly one entry restored"); - assert_eq!(list.memos[0].id, rereg.id); + assert_eq!(list.memos[0].relative_path, rereg.relative_path); assert_eq!(list.memos[0].filename, "Hello-renamed.md"); } @@ -2694,9 +2318,9 @@ fn index_cache_disk_and_memory_coherent_after_write() { let memo = mf.create_memo("Hello", "body1", None).unwrap(); // write_memo 内部走 sync_index_on_write_locked → write_index (cache 同步) - let _ = mf.write_memo(&memo.id, "body2\n").expect("write ok"); + let _ = mf.write_memo(&memo.relative_path, "body2\n").expect("write ok"); - // cache 内容跟 index.db 一致 + // cache 内容跟 local.db 一致 let cached = mf.read_index().expect("cached list"); mf.invalidate_caches(); let from_db = mf.read_index().expect("db list"); @@ -2749,7 +2373,7 @@ fn notebook_configs_cache_survives_notebook_switch() { let _ = mf.read_notebook_configs().expect("read ok"); assert!(mf.registry.configs_cache.read().unwrap().is_some()); - // notebook registry 位置固定 (`index.db`), 切 notebook 不应清 cache + // notebook registry 位置固定 (`local.db`), 切 notebook 不应清 cache let mut mf = mf; mf.set_current_notebook(Some("nb_test".to_string())); mf.set_current_notebook(Some("nb_other".to_string())); @@ -2791,13 +2415,12 @@ fn read_index_returns_none_for_missing_file_without_caching() { fn read_note_tags(mf: &MemoFile, relative_path: &str) -> Vec { let conn = rusqlite::Connection::open(mf.notebook_db_path("nb_test").unwrap()).unwrap(); - let mut stmt = conn - .prepare("SELECT tag FROM note_tags WHERE relative_path = ?1 ORDER BY tag ASC") - .unwrap(); - stmt.query_map([relative_path], |row| row.get(0)) - .unwrap() - .collect::, _>>() - .unwrap() + let tags: String = conn.query_row( + "SELECT tags FROM notes WHERE relative_path = ?1", [relative_path], |row| row.get(0), + ).unwrap(); + let mut tags: Vec = serde_json::from_str(&tags).unwrap(); + tags.sort(); + tags } fn read_body(mf: &MemoFile, filename: &str) -> String { @@ -2839,7 +2462,7 @@ fn move_tag_rewrites_exact_match_in_both_sources() { vec!["中国/曼谷".to_string()] ); - // 验证 note_tags 同步 + // 验证 notes.tags 同步 let tags = read_note_tags(&mf, &relative_path); assert_eq!(tags, vec!["中国/曼谷".to_string()]); } @@ -3057,7 +2680,7 @@ fn move_tag_handles_multiple_memos() { } #[test] -fn move_tag_preserves_frontmatter_key() { +fn move_tag_updates_body_without_frontmatter_identity() { let (mf, _base) = fresh_memo_file(); let relative_path = mf .create_note_by_path( @@ -3072,9 +2695,7 @@ fn move_tag_preserves_frontmatter_key() { .unwrap(); let body = read_body(&mf, &relative_path); - // frontmatter key 必须保留 (跟原 memo id 一致) - let key = super::frontmatter::extract_frontmatter_key(&body); - assert_eq!(key, None, "frontmatter key 必须在改写后保留: body = {body}"); + assert!(body.contains("中国/曼谷")); } #[test] @@ -3094,59 +2715,14 @@ fn move_tag_no_match_returns_zero_affected() { // Step 3+: 路径式 tag 选中某 segment 时 (e.g. `中国`), filter 应 // 包含所有前缀匹配的 memo (`中国` / `中国/湖南` / `中国/湖南/长沙` 都命中)。 #[test] -fn empty_notebook_tag_can_be_created_and_listed() { - let (mf, _base) = fresh_memo_file(); - let created = mf - .create_notebook_tag("nb_test", "projects/flowix") - .unwrap(); - - assert_eq!(created, "projects/flowix"); - assert_eq!( - mf.read_notebook_tag_paths(Some("nb_test")).unwrap(), - vec!["projects".to_string(), "projects/flowix".to_string()] - ); - assert!(mf - .read_all_memos_filtered("tagged", "createdAt", Some("projects/flowix")) - .is_empty()); -} - -#[test] -fn deleting_last_tagged_memo_keeps_notebook_tag() { +fn deleting_last_tagged_memo_removes_derived_tag() { let (mf, _base) = fresh_memo_file(); let memo = mf .create_memo("Tagged", "body", Some("keep/empty")) .unwrap(); - assert!(mf.delete_memo(&memo.id)); - assert_eq!( - mf.read_notebook_tag_paths(Some("nb_test")).unwrap(), - vec!["keep".to_string(), "keep/empty".to_string()] - ); -} - -#[test] -fn empty_notebook_tag_can_be_moved_and_deleted() { - let (mf, _base) = fresh_memo_file(); - mf.create_notebook_tag("nb_test", "empty/old").unwrap(); - - let moved = mf - .move_memo_tag_locked(Some("nb_test"), "empty/old", "empty/new") - .unwrap(); - assert_eq!(moved.affected_memos, 0); - assert_eq!( - moved.renamed_tags, - vec![("empty/old".to_string(), "empty/new".to_string())] - ); - - let deleted = mf - .delete_memo_tag_locked(Some("nb_test"), "empty/new") - .unwrap(); - assert_eq!(deleted.affected_memos, 0); - assert_eq!(deleted.deleted_tags, vec!["empty/new".to_string()]); - assert_eq!( - mf.read_notebook_tag_paths(Some("nb_test")).unwrap(), - vec!["empty".to_string()] - ); + assert!(mf.delete_memo(&memo.relative_path)); + assert!(mf.read_notebook_tag_paths(Some("nb_test")).unwrap().is_empty()); } #[test] @@ -3171,37 +2747,37 @@ fn read_all_memos_filtered_tagged_with_path_prefix() { // 选 `中国` → 命中 3 条 (中国, 中国/湖南, 中国/湖南/长沙) let hit = mf.read_all_memos_filtered("tagged", "createdAt", Some("中国")); - let hit_ids: Vec<_> = hit.iter().map(|m| m.id.clone()).collect(); + let hit_ids: Vec<_> = hit.iter().map(|m| m.relative_path.clone()).collect(); assert_eq!(hit_ids.len(), 3, "中国 prefix 应命中 3 条: {hit_ids:?}"); - assert!(hit_ids.contains(&m_china.id)); - assert!(hit_ids.contains(&m_hunan.id)); - assert!(hit_ids.contains(&m_changsha.id)); + assert!(hit_ids.contains(&m_china.relative_path)); + assert!(hit_ids.contains(&m_hunan.relative_path)); + assert!(hit_ids.contains(&m_changsha.relative_path)); assert!( - !hit_ids.contains(&m_thailand.id), + !hit_ids.contains(&m_thailand.relative_path), "泰国 不应被中国 prefix 命中" ); - assert!(!hit_ids.contains(&m_unrelated.id)); + assert!(!hit_ids.contains(&m_unrelated.relative_path)); // 选 `中国/湖南` → 命中 2 条 (中国/湖南, 中国/湖南/长沙) let hit = mf.read_all_memos_filtered("tagged", "createdAt", Some("中国/湖南")); - let hit_ids: Vec<_> = hit.iter().map(|m| m.id.clone()).collect(); + let hit_ids: Vec<_> = hit.iter().map(|m| m.relative_path.clone()).collect(); assert_eq!( hit_ids.len(), 2, "中国/湖南 prefix 应命中 2 条: {hit_ids:?}" ); - assert!(hit_ids.contains(&m_hunan.id)); - assert!(hit_ids.contains(&m_changsha.id)); + assert!(hit_ids.contains(&m_hunan.relative_path)); + assert!(hit_ids.contains(&m_changsha.relative_path)); assert!( - !hit_ids.contains(&m_china.id), + !hit_ids.contains(&m_china.relative_path), "中国 自身不应被 中国/湖南 命中" ); // 选 `中国/湖南/长沙` → 命中 1 条 (精确匹配) let hit = mf.read_all_memos_filtered("tagged", "createdAt", Some("中国/湖南/长沙")); - let hit_ids: Vec<_> = hit.iter().map(|m| m.id.clone()).collect(); + let hit_ids: Vec<_> = hit.iter().map(|m| m.relative_path.clone()).collect(); assert_eq!(hit_ids.len(), 1); - assert_eq!(hit_ids[0], m_changsha.id); + assert_eq!(hit_ids[0], m_changsha.relative_path); // 子串不命中: 选 `中` 不应匹配任何 (整段前缀, 不做 contains 模糊) let hit = mf.read_all_memos_filtered("tagged", "createdAt", Some("中")); @@ -3371,9 +2947,9 @@ fn next_notebook_sort_returns_step_above_max() { } #[test] -fn open_index_db_upgrades_legacy_schema_and_normalizes_sort() { - // 模拟 v3 之前的 index.db: 没有 sort 列, 已存在多条笔记本 sort=0 (默认)。 - // 第一次 open_index_db 应该自动 ALTER + normalize, 让旧行 sort 变成 10/20/30...。 +fn local_db_initialization_upgrades_schema_and_normalizes_sort() { + // 模拟旧 local.db: 没有 sort 列, 已存在多条笔记本 sort=0 (默认)。 + // 显式初始化应该 ALTER + normalize, 让旧行 sort 变成 10/20/30...。 let tmp = std::env::temp_dir().join(format!( "flowix-notebook-legacy-{}-{}", std::process::id(), @@ -3384,9 +2960,10 @@ fn open_index_db_upgrades_legacy_schema_and_normalizes_sort() { let config_dir = tmp.join("config"); fs::create_dir_all(&config_dir).unwrap(); + fs::create_dir_all(config_dir.join("db")).unwrap(); // 直接打开一个 SQLite db, 用旧 schema 写两行 (不带 sort 列) { - let conn = rusqlite::Connection::open(config_dir.join("index.db")).unwrap(); + let conn = rusqlite::Connection::open(config_dir.join("db/local.db")).unwrap(); conn.execute_batch( r#" CREATE TABLE notebooks ( @@ -3434,6 +3011,7 @@ fn open_index_db_upgrades_legacy_schema_and_normalizes_sort() { // 现在用 MemoFile 打开同一份 db, 应该触发 ALTER + normalize。 let mf = MemoFile::new(config_dir.clone()); + mf.initialize_registry_storage().unwrap(); let configs = mf.read_notebook_configs().expect("read ok"); // normalize 走 ORDER BY created_at ASC, 所以 nb_beta (50) 在前, sort=10; @@ -3458,7 +3036,7 @@ fn open_index_db_upgrades_legacy_schema_and_normalizes_sort() { }); mf.write_notebook_configs(&next_configs).unwrap(); - let conn = rusqlite::Connection::open(config_dir.join("index.db")).unwrap(); + let conn = rusqlite::Connection::open(config_dir.join("db/local.db")).unwrap(); let sort_alpha: i64 = conn .query_row( "SELECT sort FROM notebooks WHERE id = 'nb_alpha'", @@ -3553,7 +3131,7 @@ fn removing_yaml_tag_keeps_membership_when_body_still_contains_it() { .unwrap(); let updated = mf - .write_memo(&memo.id, "---\ntags: []\n---\nBody #shared") + .write_memo(&memo.relative_path, "---\ntags: []\n---\nBody #shared") .unwrap(); assert_eq!(updated.tags, vec!["shared"]); @@ -3579,7 +3157,7 @@ fn tag_union_index_upgrade_rebuilds_index_without_touching_markdown() { .unwrap(); assert_eq!(updated, 0); - // note_tags is a set; assert membership without depending on row order. + // The tags JSON column is a set-like projection; assert membership without depending on order. let mut tags = read_note_tags(&mf, &memo.relative_path); tags.sort(); assert_eq!( diff --git a/app/flowix-core/src/memo_file/types.rs b/app/flowix-core/src/memo_file/types.rs index fb68f9f5e..a8c5f457c 100644 --- a/app/flowix-core/src/memo_file/types.rs +++ b/app/flowix-core/src/memo_file/types.rs @@ -1,20 +1,12 @@ //! 公开 DTO 类型 — `Memo` / `Notebook` / `TodoItem` / `MemoTag` / -//! `NotebookConfig` / `MemoIndexFile` / `MemoIndexEntry` / `MemoTodoEntry` / -//! `MemoMetadataFile`。 +//! `NotebookConfig` / notebook-relative-path projections。 //! //! 拆分理由: 旧 `memo_file.rs` 把全部 DTO 堆在文件顶部, 跟 IO/CRUD/registration //! 混在一起, 1654 行 god module。DTO 是稳定边界 (前端 TS 镜像直接读这些字段), //! 单独放 `types.rs` 后改 IO 不影响类型签名。 //! -//! ## v3 — 2026/06 重构: `filename` 作为 path -//! -//! 旧版: `MemoIndexEntry` 不存物理路径, 物理文件名由 `entry.filename + entry.id` 拼成 -//! `{title}#xxxxxx.md`。`Memo` 额外带 `path` 字段 (内存拼出来的相对路径)。 -//! -//! 现版: 物理文件 = `/.md`, `entry.filename` 直接是磁盘文件名 -//! (含 `.md`)。`Memo` / `MemoIndexEntry` 都不再有 `path` 字段; 物理路径运行时 -//! 拼 `get_memo_base() + filename`。`Memo` 的 `filename` 字段即"磁盘文件名", -//! 前端展示时去掉 `.md` 后缀即可。 +//! 笔记由 `(notebook_id, relative_path)` 定位。`filename` 是相对路径的末级文件名, +//! 用于列表显示和文件树操作;索引不为笔记生成独立 ID。 use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -25,7 +17,7 @@ use serde_json::Value; /// `"orange"` / ...), 数组形式存, 空数组即"无颜色"。 /// 索引只缓存从 Markdown 解析出的值, 与 `icon` 字段同形。 /// -/// 旧版用单值 `Option`, 现在切到 `Vec`。memo index +/// 旧版用单值 `Option`, 现在切到 `Vec`。memo index /// 老数据若含 `"color": "red"` / `"color": null` 会反序列化失败, 但本字段 /// 是这次会话新加的, 没有真实数据, 走纯 breaking change 即可。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -40,20 +32,15 @@ pub enum NoteColor { Gray, } -/// Compatibility name for older Memo-ID models and downstream callers. -pub type MemoColor = NoteColor; - -/// 跨 IPC 边界返回的 memo 完整形态 (前端 TS `MemoItem` 镜像)。 +/// 跨 IPC 边界返回的笔记投影 (前端 TS `MemoItem` 镜像)。 /// /// 字段语义: -/// - `id`: 旧版兼容标识, 仍供深链及 noteReference 使用;路径是笔记索引主键。 +/// - `relative_path`: 相对于 notebook 根目录的笔记路径,也是索引键。 /// - `filename`: 磁盘文件名, 含 `.md` 后缀 (如 `Hello.md` / `Hello-1.md`)。 -/// 列表展示前端去掉 `.md`; 编辑器内展示用 `MemoItem.filename` 直接可。 -/// - `preview` / `tags` / `todos` / `agents`: 从 body 派生的 memo index 缓存字段。 +/// - `preview` / `tags` / `todos` / `agents`: from Markdown content, cached in the index. /// - `favorited` / `icon` / `colors`: 从 Markdown frontmatter 读取的持久属性。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memo { - pub id: String, /// 文件名本身,不包含笔记本内的目录部分。 pub filename: String, /// 相对于所属笔记本根目录的规范化路径,使用 `/` 作为分隔符。 @@ -76,7 +63,7 @@ pub struct Memo { pub favorited: bool, pub icon: Option, #[serde(default)] - pub colors: Vec, + pub colors: Vec, #[serde(default)] pub properties: Value, } @@ -150,17 +137,46 @@ pub struct NotebookConfig { #[serde(rename_all = "camelCase")] pub struct NotebookSetupJob { pub notebook_id: String, + /// Stable identity for this logical setup task. Retries keep the same ID; + /// replacing or recreating a task gets a new ID. + #[serde(default)] + pub job_id: String, pub template_id: Option, #[serde(default)] pub overwrite_existing: bool, #[serde(rename = "status")] pub status: NotebookSetupJobStatus, - pub stage: String, - pub completed_files: usize, - pub total_files: usize, + pub stage: NotebookSetupStage, + #[serde(default)] + pub revision: i64, + #[serde(default)] + pub run_id: String, pub message: Option, pub report: Option, pub updated_at: i64, + /// Persisted after the workspace is ready to let this job execute. + #[serde(default)] + pub execution_requested: bool, +} + +/// Consistent registry snapshot used to reconcile missed events and removed +/// notebook setup tasks in a WebView. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookSetupSnapshot { + pub revision: i64, + pub jobs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotebookSetupStage { + #[serde(alias = "queued")] + Preparing, + #[serde(alias = "template")] + Writing, + Indexing, + Completed, } /// Aggregate result of a best-effort template application. Existing files are @@ -204,15 +220,9 @@ pub struct MemoLocation { pub notebook: NotebookConfig, } -// ============================================ -// Legacy memo-ID list entry persisted in the notebook-local database. -// ============================================ - -/// memo index 单条 memo 元数据。`filename` 即磁盘文件名, 含 `.md`。 -/// 旧版有 `path` 字段, 现版删除, 物理路径运行时拼。 +/// Rebuildable note projection row. The notebook-relative path is its key. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoIndexEntry { - pub id: String, /// 磁盘文件名, 含 `.md` 后缀。冲突时由 ops 层自动追加 `-1` / `-2`。 pub filename: String, /// 相对于 notebook 根目录的规范化路径,使用 `/` 作为分隔符。 @@ -233,7 +243,7 @@ pub struct MemoIndexEntry { #[serde(rename = "icon")] pub icon: Option, #[serde(default)] - pub colors: Vec, + pub colors: Vec, #[serde(default)] pub properties: Value, } @@ -255,46 +265,6 @@ impl Default for MemoIndexFile { } } -// ============================================ -// Legacy memo-ID todo metadata persisted in the notebook-local database. -// ============================================ - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemoTodoEntry { - #[serde(rename = "todoId", default)] - pub todo_id: String, - pub content: String, - pub status: String, - #[serde(rename = "memoId")] - pub memo_id: String, - pub priority: String, - #[serde(rename = "timeRange")] - pub time_range: String, - pub owner: String, - pub assignee: String, - #[serde(rename = "createdAt")] - pub created_at: i64, - #[serde(rename = "updatedAt")] - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemoMetadataFile { - pub version: u32, - pub last_updated: i64, - pub todos: Vec, -} - -impl Default for MemoMetadataFile { - fn default() -> Self { - Self { - version: 1, - last_updated: chrono::Utc::now().timestamp_millis(), - todos: Vec::new(), - } - } -} - /// [`crate::memo_file::MemoFile::reconcile_with_disk_bidirectional`] 的返回报告。 /// /// - `added`: 磁盘上有但 `memo index` 没有的 .md 文件, 被注册进 memo index 的条数。 @@ -316,7 +286,7 @@ pub struct ReconcileReport { /// [`crate::memo_file::MemoFile::move_memo_tag_locked`] 的返回报告。 /// -/// - `affected_memos`: 实际被批量改写 YAML `tags` + 同步 memo index 的 memo +/// - `affected_memos`: 实际被批量改写 YAML `tags` + 同步索引的笔记数 /// 条数; 当 `old_path` 在任何 memo 都没出现时为 0。 /// - `renamed_tags`: 操作涉及的 `(old, new)` tag name 对 (去重)。`old` /// 可以是 `old_path` 本身, 也可以是 `old_path/<...>` 的子树 tag。 @@ -331,7 +301,7 @@ pub struct MoveTagReport { /// [`crate::memo_file::MemoFile::delete_memo_tag_locked`] 的返回报告。 /// -/// - `affected_memos`: 实际被批量改写 YAML `tags` + 同步 memo index 的 memo +/// - `affected_memos`: 实际被批量改写 YAML `tags` + 同步索引的笔记数 /// 条数; 当 `tag_path` 在任何 memo 都没出现时为 0。 /// - `deleted_tags`: 操作涉及的 (被删除的) tag 路径, 含 `tag_path` 自身 /// 加上所有以 `tag_path/` 为前缀的子树 tag, **去重**。 前端用它把 diff --git a/app/flowix-core/src/memo_file/versions.rs b/app/flowix-core/src/memo_file/versions.rs index 592ba57ec..f9fbc2a15 100644 --- a/app/flowix-core/src/memo_file/versions.rs +++ b/app/flowix-core/src/memo_file/versions.rs @@ -1,6 +1,11 @@ use std::fs; use std::path::{Path, PathBuf}; +const VERSION_ID_ALPHABET: [char; 36] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', + 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', +]; + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -276,7 +281,7 @@ impl MemoFile { let id = format!( "v_{}_{}", now.format("%Y%m%d_%H%M%S"), - nanoid::nanoid!(6, &super::MEMO_ID_ALPHABET) + nanoid::nanoid!(6, &VERSION_ID_ALPHABET) ); let meta = PathVersionMeta { id: id.clone(), diff --git a/app/flowix-core/src/memo_file/workspace.rs b/app/flowix-core/src/memo_file/workspace.rs new file mode 100644 index 000000000..43857a87c --- /dev/null +++ b/app/flowix-core/src/memo_file/workspace.rs @@ -0,0 +1,450 @@ +//! Device-local notebook repository attachments. JSON files are upgrade inputs only. +use super::notebook::sqlite_to_io; +use super::{MemoFile, NotebookConfig}; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fs, path::Path}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NotebookAddDir { + pub id: String, + pub path: String, + pub label: String, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub missing: bool, +} +fn default_enabled() -> bool { + true +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookWorkspaceConfig { + #[serde(default = "config_version")] + pub version: u32, + #[serde(default)] + pub revision: u64, + #[serde(default)] + pub add_dirs: Vec, +} +fn config_version() -> u32 { + 1 +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookWorkspaceSnapshot { + pub notebooks: Vec, + pub notebook_configs: HashMap, +} + +impl MemoFile { + pub(super) fn workspace_connection(&self) -> std::io::Result { + let conn = self.open_registry_db()?; + conn.execute_batch("CREATE TABLE IF NOT EXISTS notebook_workspace_state ( + notebook_id TEXT PRIMARY KEY REFERENCES notebooks(id) ON DELETE CASCADE, + revision INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS notebook_add_dirs ( + id TEXT NOT NULL, notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, + path TEXT NOT NULL, path_key TEXT NOT NULL, label TEXT NOT NULL, enabled INTEGER NOT NULL, + sort INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + PRIMARY KEY(notebook_id,id), UNIQUE(notebook_id,path_key));").map_err(sqlite_to_io)?; + Ok(conn) + } + + /// This is the only remaining reader of old global Agent runtime defaults. + pub fn read_legacy_agent_runtime(&self) -> std::io::Result> { + Ok(self + .read_legacy_agent_access()? + .get("defaults") + .and_then(|d| d.get("runtime")) + .cloned()) + } + fn read_legacy_agent_access(&self) -> std::io::Result { + let path = self.registry.config_dir.join("agent-access.json"); + match fs::read(&path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(value) => Ok(value), + Err(_) => { + super::backup_storage_file(&path)?; + Ok(serde_json::Value::Null) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Value::Null), + Err(e) => Err(e), + } + } + pub fn read_notebook_workspace(&self) -> std::io::Result { + let notebooks = self.read_notebook_configs()?; + let conn = self.workspace_connection()?; + let mut notebook_configs = HashMap::new(); + for notebook in ¬ebooks { + let revision = conn + .query_row( + "SELECT revision FROM notebook_workspace_state WHERE notebook_id=?1", + [¬ebook.id], + |r| r.get(0), + ) + .optional() + .map_err(sqlite_to_io)? + .unwrap_or(0); + let mut stmt = conn.prepare("SELECT id,path,label,enabled FROM notebook_add_dirs WHERE notebook_id=?1 ORDER BY sort,id").map_err(sqlite_to_io)?; + let add_dirs = stmt + .query_map([¬ebook.id], |r| { + let path: String = r.get(1)?; + let missing = !Path::new(&path).is_dir(); + Ok(NotebookAddDir { + id: r.get(0)?, + path, + label: r.get(2)?, + enabled: r.get(3)?, + missing, + }) + }) + .map_err(sqlite_to_io)? + .collect::>() + .map_err(sqlite_to_io)?; + notebook_configs.insert( + notebook.id.clone(), + NotebookWorkspaceConfig { + version: 1, + revision, + add_dirs, + }, + ); + } + Ok(NotebookWorkspaceSnapshot { + notebooks, + notebook_configs, + }) + } + pub fn update_notebook_add_dir( + &self, + notebook_id: &str, + path: &str, + attach: bool, + ) -> std::io::Result { + let notebook = self + .read_notebook_configs()? + .into_iter() + .find(|n| n.id == notebook_id) + .ok_or_else(|| std::io::Error::other("notebook not found"))?; + let normalized = normalized_directory(path); + let stored_path = normalized.to_string_lossy(); + let key = directory_key(path); + if attach { + validate_add_dir(¬ebook.path, path, true)?; + } + let mut conn = self.workspace_connection()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + let now = chrono::Utc::now().timestamp_millis(); + let changed = if attach { + let label = Path::new(path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + tx.execute("INSERT INTO notebook_add_dirs (id,notebook_id,path,path_key,label,enabled,sort,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,1,COALESCE((SELECT MAX(sort)+1 FROM notebook_add_dirs WHERE notebook_id=?2),0),?6,?6) ON CONFLICT(notebook_id,path_key) DO UPDATE SET enabled=1,updated_at=excluded.updated_at WHERE enabled=0", params![uuid::Uuid::new_v4().to_string(),notebook_id,stored_path,key,label,now]).map_err(sqlite_to_io)? + } else { + // Exact stored paths also allow removing a missing symlink after its target disappeared. + tx.execute( + "DELETE FROM notebook_add_dirs WHERE notebook_id=?1 AND (path_key=?2 OR path=?3)", + params![notebook_id, key, path], + ) + .map_err(sqlite_to_io)? + }; + if changed > 0 { + tx.execute("INSERT INTO notebook_workspace_state VALUES (?1,1) ON CONFLICT(notebook_id) DO UPDATE SET revision=revision+1",[notebook_id]).map_err(sqlite_to_io)?; + } + tx.commit().map_err(sqlite_to_io)?; + self.read_notebook_workspace() + } + pub fn attached_directory_paths(&self) -> std::io::Result> { + let conn = self.workspace_connection()?; + let mut stmt = conn + .prepare("SELECT DISTINCT path FROM notebook_add_dirs WHERE enabled=1") + .map_err(sqlite_to_io)?; + let paths = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(sqlite_to_io)? + .collect::, _>>() + .map_err(sqlite_to_io)?; + Ok(paths + .into_iter() + .map(std::path::PathBuf::from) + .filter(|path| path.is_dir()) + .collect()) + } +} +fn normalized_directory(path: &str) -> std::path::PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| { + let mut normalized = std::path::PathBuf::new(); + for component in Path::new(path).components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized + }) +} +fn directory_key(path: &str) -> String { + let key = normalized_directory(path).to_string_lossy().into_owned(); + #[cfg(target_os = "windows")] + let key = key.to_lowercase(); + key +} +fn validate_add_dir(notebook: &str, path: &str, require_exists: bool) -> std::io::Result<()> { + if !Path::new(path).is_absolute() || (require_exists && !Path::new(path).is_dir()) { + return Err(std::io::Error::other("add-dir is unavailable")); + } + let root = normalized_directory(notebook); + let target = normalized_directory(path); + if root.starts_with(&target) || target.starts_with(root.join(".flowix")) { + return Err(std::io::Error::other("invalid notebook add-dir scope")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (tempfile::TempDir, MemoFile, NotebookConfig) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notes"); + fs::create_dir(&root).unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + let notebook = NotebookConfig { + id: "one".into(), + name: "One".into(), + icon: None, + path: root.to_string_lossy().into(), + is_default: true, + sort: 0, + created_at: 1, + updated_at: 1, + }; + memo.write_notebook_configs(std::slice::from_ref(¬ebook)) + .unwrap(); + (temp, memo, notebook) + } + #[test] + fn imports_local_mounts_once_and_does_not_grant_orphan_global_entries() { + let (temp, memo, notebook) = fixture(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + let missing = temp.path().join("missing"); + let source = Path::new(¬ebook.path).join(".flowix/agent.json"); + fs::write( + &source, + serde_json::json!({"version":1,"revision":4,"addDirs":[ + {"id":"repo","path":repo,"label":"Repo","enabled":true}, + {"id":"missing","path":missing,"label":"Missing","enabled":true} + ]}) + .to_string(), + ) + .unwrap(); + fs::write( + temp.path().join("config/agent-access.json"), + r#"{"entries":[{"kind":"folder","path":"/orphan","enabled":true}]}"#, + ) + .unwrap(); + let first = memo.read_notebook_workspace().unwrap(); + assert_eq!(first.notebook_configs["one"].add_dirs.len(), 2); + assert!(first.notebook_configs["one"].add_dirs[1].missing); + assert_eq!( + memo.attached_directory_paths().unwrap(), + vec![dunce::canonicalize(&repo).unwrap()] + ); + memo.update_notebook_add_dir("one", repo.to_str().unwrap(), false) + .unwrap(); + fs::write(&source, "invalid JSON").unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 1 + ); + assert!(memo.attached_directory_paths().unwrap().is_empty()); + } + #[test] + fn shares_paths_across_notebooks_and_cascades_deleted_notebook_mounts() { + let (temp, memo, one) = fixture(); + let mut two = one.clone(); + two.id = "two".into(); + two.path = temp.path().join("two").to_string_lossy().into(); + fs::create_dir(&two.path).unwrap(); + memo.write_notebook_configs(&[one.clone(), two.clone()]) + .unwrap(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + for id in ["one", "two"] { + memo.update_notebook_add_dir(id, repo.to_str().unwrap(), true) + .unwrap(); + } + memo.update_notebook_add_dir("one", repo.to_str().unwrap(), true) + .unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 1 + ); + memo.write_notebook_configs(&[two]).unwrap(); + let conn = memo.workspace_connection().unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM notebook_add_dirs", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + memo.attached_directory_paths().unwrap(), + vec![dunce::canonicalize(&repo).unwrap()] + ); + } + #[test] + fn imports_global_legacy_defaults_and_defers_unavailable_notebooks() { + let (temp, memo, notebook) = fixture(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + fs::write( + temp.path().join("config/agent-access.json"), + serde_json::json!({"defaults":{"files":{"one":{"folders":[repo]}}}}).to_string(), + ) + .unwrap(); + fs::rename(¬ebook.path, temp.path().join("parked")).unwrap(); + assert!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .is_empty() + ); + fs::rename(temp.path().join("parked"), ¬ebook.path).unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 1 + ); + assert!(memo + .update_notebook_add_dir("one", temp.path().to_str().unwrap(), true) + .is_err()); + } + #[test] + fn invalid_legacy_json_remains_retryable() { + let (temp, memo, notebook) = fixture(); + let source = Path::new(¬ebook.path).join(".flowix/agent.json"); + fs::write(&source, "broken").unwrap(); + assert!(memo.read_notebook_workspace().is_err()); + fs::write(&source, r#"{"version":1,"addDirs":[]}"#).unwrap(); + assert!(memo.read_notebook_workspace().is_ok()); + assert!(temp.path().join("config/db/local.db").is_file()); + } +} + +#[cfg(test)] +mod concurrency_tests { + use super::*; + #[test] + fn concurrent_process_connections_preserve_independent_mounts() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config"); + let notes = temp.path().join("notes"); + fs::create_dir(¬es).unwrap(); + let memo = MemoFile::new(config.clone()); + memo.write_notebook_configs(&[NotebookConfig { + id: "one".into(), + name: "One".into(), + icon: None, + path: notes.to_string_lossy().into(), + is_default: true, + sort: 0, + created_at: 1, + updated_at: 1, + }]) + .unwrap(); + memo.read_notebook_workspace().unwrap(); + let paths: Vec<_> = (0..4) + .map(|i| { + let p = temp.path().join(format!("repo{i}")); + fs::create_dir(&p).unwrap(); + p + }) + .collect(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(paths.len())); + let threads: Vec<_> = paths + .iter() + .cloned() + .map(|path| { + let config = config.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let memo = MemoFile::new(config); + barrier.wait(); + memo.update_notebook_add_dir("one", path.to_str().unwrap(), true) + .unwrap(); + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 4 + ); + assert_eq!(memo.attached_directory_paths().unwrap().len(), 4); + } +} + +#[cfg(test)] +mod legacy_global_defaults_tests { + use super::*; + #[test] + fn stale_global_defaults_apply_only_to_preupgrade_notebooks() { + let temp = tempfile::tempdir().unwrap(); + let notes = temp.path().join("old"); + fs::create_dir(¬es).unwrap(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + let config = temp.path().join("config"); + let memo = MemoFile::new(config.clone()); + let old = NotebookConfig { + id: "old".into(), + name: "Old".into(), + icon: None, + path: notes.to_string_lossy().into(), + is_default: true, + sort: 0, + created_at: 0, + updated_at: 0, + }; + memo.write_notebook_configs(std::slice::from_ref(&old)) + .unwrap(); + fs::write(config.join("agent-access.json"),serde_json::json!({"entries":[{"id":"old","kind":"notebook"}],"defaults":{"files":{"_global":{"folders":[repo]}}}}).to_string()).unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["old"] + .add_dirs + .len(), + 1 + ); + let mut new = old.clone(); + new.id = "new".into(); + new.path = temp.path().join("new").to_string_lossy().into(); + fs::create_dir(&new.path).unwrap(); + memo.write_notebook_configs(&[old, new]).unwrap(); + assert!( + memo.read_notebook_workspace().unwrap().notebook_configs["new"] + .add_dirs + .is_empty() + ); + } +} diff --git a/app/flowix-core/src/secret/mod.rs b/app/flowix-core/src/secret/mod.rs index 028791bdc..e550ce940 100644 --- a/app/flowix-core/src/secret/mod.rs +++ b/app/flowix-core/src/secret/mod.rs @@ -3,9 +3,8 @@ //! //! ## 存储 //! -//! - 数据库文件: `/default.db` (生产环境 `~/.flowix/default.db`), -//! 与 `index.db` 同目录, 受 `~/.flowix` 目录 `0o700` 权限保护; unix 下 -//! db 文件本身再设一次 `0o600`。 +//! - 数据库文件: `/db/default.db` (生产环境 `~/.flowix/db/default.db`), +//! Unix 下 db 文件权限为 `0o600`。 //! - 表 `secret_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, 通用 //! key-value 形态, 未来可承载其它敏感 / 关键配置, 不止 API key。 //! @@ -167,18 +166,14 @@ impl DbBackend { if self.db_path.exists() { set_file_owner_only_perms(&self.db_path)?; } - let conn = Connection::open(&self.db_path) - .map_err(|e| SecretStoreError::Platform(format!("open db: {e}")))?; - conn.execute_batch( - r#" - PRAGMA secure_delete = ON; - CREATE TABLE IF NOT EXISTS secret_kv ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - "#, - ) - .map_err(|e| SecretStoreError::Platform(format!("init table: {e}")))?; + let conn = crate::memo_file::with_database_rebuild(&self.db_path, || { + let conn = Connection::open(&self.db_path).map_err(std::io::Error::other)?; + conn.execute_batch("PRAGMA secure_delete = ON; + CREATE TABLE IF NOT EXISTS secret_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL); + SELECT key, value FROM secret_kv LIMIT 0;") + .map_err(std::io::Error::other)?; + Ok(conn) + }).map_err(|error| SecretStoreError::Platform(format!("initialize secret database: {error}")))?; set_file_owner_only_perms(&self.db_path)?; Ok(conn) } @@ -225,7 +220,7 @@ impl SecretBackend for DbBackend { /// /// 用法: /// ```ignore -/// let store = SecretStore::new("~/.flowix/default.db"); +/// let store = SecretStore::new("~/.flowix/db/default.db"); /// store.save("openai_responses::default", "sk-...")?; /// let key = store.load("openai_responses::default")?.unwrap(); /// ``` @@ -235,7 +230,7 @@ pub struct SecretStore { impl SecretStore { /// 构造真实 SQLite 后端的 SecretStore ── db 路径由调用方 (UserConfigStore) - /// 按 config_dir 派生 (`~/.flowix/default.db`), desktop / CLI 共用。 + /// 按 config_dir 派生 (`~/.flowix/db/default.db`), desktop / CLI 共用。 pub fn new(db_path: impl AsRef) -> Self { Self { backend: Box::new(DbBackend::new(db_path)), @@ -360,3 +355,20 @@ impl SecretBackend for MockBackend { self.backend_kind } } + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + + #[test] + fn invalid_secret_database_is_preserved_before_creating_empty_credentials() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("default.db"); + std::fs::write(&path, b"invalid credential database").unwrap(); + let backend = DbBackend::new(path); + assert!(backend.load("missing-account").unwrap().is_none()); + backend.save("new-account", "new-value").unwrap(); + assert!(backend.load("new-account").unwrap().is_some()); + assert!(temp.path().join("rebuild-backups").exists()); + } +} diff --git a/app/flowix-core/src/service.rs b/app/flowix-core/src/service.rs index a54acf745..24314cb45 100644 --- a/app/flowix-core/src/service.rs +++ b/app/flowix-core/src/service.rs @@ -1,56 +1,24 @@ //! Application service boundary shared by desktop, CLI, MCP, and future transports. //! //! `MemoFile` remains the shared storage façade. `NoteService` is the public -//! path-keyed Note API; `MemoService` retains Memo-ID compatibility use cases. +//! path-keyed Note API. -use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::memo_file::{ - base_filename, normalize_search_tag_filter, notebook_path_from_relative, - resolve_filename_conflict, FileLockIntent, FileWriteGuard, Memo, MemoColor, MemoFile, MemoIndexEntry, + notebook_path_from_relative, MemoFile, NoteEntry, NoteSearchHit, NoteWriteOutcome, NotebookConfig, }; -const MAX_SEARCH_LIMIT: usize = 200; const DEFAULT_LIST_PAGE_SIZE: usize = 50; const MAX_LIST_PAGE_SIZE: usize = 100; const MAX_LIST_CURSOR_BYTES: usize = 4096; -pub struct MemoSaveReceipt { - pub edited: EditedMemo, - pub content: String, - pub notebook_id: String, - pub commit: Option, -} - -type TagUsageSummary = (Vec, Vec<(String, usize)>, usize, usize, usize); - -/// Opaque-to-transport cursor for a memo list query. The query identity is -/// included so a cursor cannot accidentally be reused after changing notebook, -/// filter, tag, color, or sort. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct MemoListCursor { - notebook_id: Option, - filter: String, - sort: String, - tag_id: Option, - color: Option, - favorited: bool, - sort_value: i64, - #[serde(default)] - sort_text: Option, - #[serde(default)] - sort_tiebreaker: Option, - id: String, -} - -/// Opaque cursor for the path-keyed notebook list. The path, rather than a -/// legacy memo id, is the stable tie breaker for every ordering. +/// Opaque cursor for the path-keyed notebook list. Relative path is the stable +/// tie breaker for every ordering. #[derive(Debug, Clone, Serialize, Deserialize)] struct NoteListCursor { notebook_id: String, @@ -65,13 +33,6 @@ struct NoteListCursor { relative_path: String, } -#[derive(Debug, Clone)] -pub struct MemoPage { - pub memos: Vec, - pub next_cursor: Option, - pub has_more: bool, -} - #[derive(Debug, Clone)] pub struct NotePage { pub notes: Vec, @@ -97,22 +58,6 @@ pub enum FlowixError { Internal(String), } -#[derive(Debug, Clone)] -pub struct ResolvedMemo { - pub id: String, - pub entry: MemoIndexEntry, - pub notebook: NotebookConfig, - pub path: PathBuf, -} - -#[derive(Debug, Clone)] -pub struct MemoDocument { - pub entry: MemoIndexEntry, - pub notebook: NotebookConfig, - pub path: PathBuf, - pub body: String, -} - #[derive(Debug, Clone)] pub struct NoteDocument { pub entry: NoteEntry, @@ -127,32 +72,8 @@ pub enum NoteSaveOutcome { Conflict { disk_content: String }, } -#[derive(Debug, Clone)] -pub struct CreatedMemo { - pub memo: Memo, - pub notebook: NotebookConfig, - pub path: PathBuf, -} - -#[derive(Debug, Clone)] -pub struct EditedMemo { - pub id: String, - pub memo: Option, - pub path: PathBuf, - pub old_bytes: usize, - pub new_bytes: usize, - pub dry_run: bool, -} - -#[derive(Debug, Clone)] -pub struct DeletedMemo { - pub id: String, - pub path: PathBuf, - pub file_removed: bool, -} - -/// Path-identified Note use cases. This service does not resolve or retain -/// Memo IDs; the path projection and Markdown file are its only identities. +/// Path-identified Note use cases. The path projection and Markdown file are +/// the note's only identities. pub struct NoteService<'a> { memo_file: &'a MemoFile, } @@ -188,88 +109,8 @@ impl<'a> NoteService<'a> { } } -/// Compatibility use-case facade over one `MemoFile` instance. -/// -/// The service borrows the store instead of owning it, so Desktop can construct it from -/// its managed `MemoFile` while CLI/MCP can construct it from a short-lived instance. -/// Public path-identified Note operations belong to `NoteService`; this facade -/// keeps Memo-ID lookups and legacy command use cases. -pub struct MemoService<'a> { - memo_file: &'a MemoFile, -} - -impl<'a> MemoService<'a> { - pub fn new(memo_file: &'a MemoFile) -> Self { - Self { memo_file } - } - - fn lock_resolved_memo(&self, resolved: &ResolvedMemo, operation: &str) -> Result { - let guard = self.memo_file.operation_locks().file_write( - &resolved.notebook.id, - Path::new(&resolved.notebook.path), - &resolved.path, - FileLockIntent::Existing, - operation, - )?; - let current = self.memo_file.get_notebook_config_by_id(&resolved.notebook.id) - .ok_or_else(|| FlowixError::Conflict("notebook was removed while waiting for the file lock".into()))?; - if std::fs::canonicalize(¤t.path)? != std::fs::canonicalize(&resolved.notebook.path)? { - return Err(FlowixError::Conflict("notebook root changed while waiting for the file lock".into())); - } - Ok(guard) - } - - pub fn list_notebooks(&mut self) -> Result, FlowixError> { - self.memo_file - .read_notebook_configs() - .map_err(FlowixError::Io) - } - - pub fn notebook_note_counts( - &mut self, - configs: &[NotebookConfig], - ) -> Result, FlowixError> { - let mut counts = self.memo_file.memo_counts_by_notebook()?; - let notebook_ids = configs - .iter() - .map(|config| config.id.as_str()) - .collect::>(); - counts.retain(|notebook_id, _| notebook_ids.contains(notebook_id.as_str())); - for config in configs { - counts.entry(config.id.clone()).or_insert(0); - } - Ok(counts) - } - - pub fn list_memos(&mut self, notebook_key: &str) -> Result, FlowixError> { - let notebook = self.resolve_notebook(notebook_key)?; - let mut entries = match self - .memo_file - .list_note_entries_with_legacy_ids(¬ebook.id)? - { - Some(entries) => entries, - None => { - self.memo_file - .read_index_for_notebook_id(Some(¬ebook.id))? - .unwrap_or_default() - .memos - } - }; - let policy = self.memo_file.file_management_policy(¬ebook.id); - entries.retain(|entry| { - let relative_path = if entry.relative_path.is_empty() { - &entry.filename - } else { - &entry.relative_path - }; - !policy.is_index_ignored_at(Path::new(¬ebook.path), Path::new(relative_path)) - }); - Ok(entries) - } -} - impl NoteService<'_> { - /// List notes by notebook-relative path, without legacy memo identities. + /// List notes by notebook-relative path. pub(crate) fn list_notes_by_path( &mut self, notebook_key: &str, @@ -446,7 +287,7 @@ impl NoteService<'_> { }) } - /// Open a Markdown note by notebook and relative path, with no memo ID lookup. + /// Open a Markdown note by notebook and relative path. pub(crate) fn get_note_by_path( &mut self, notebook_key: &str, @@ -560,884 +401,125 @@ impl NoteService<'_> { } } -impl MemoService<'_> { - pub fn list_memos_filtered( - &mut self, - notebook_id: Option<&str>, - filter: &str, - sort: &str, - tag_id: Option<&str>, - ) -> Vec { - self.memo_file - .read_all_memos_filtered_for_notebook_id(notebook_id, filter, sort, tag_id) +impl<'a> NoteService<'a> { + pub fn list(&mut self, notebook: &str) -> Result, FlowixError> { + self.list_notes_by_path(notebook) } - /// Return one stable page from a memo query. - /// - /// The current index implementation still scans the local metadata index - /// to apply the existing filters, but only the requested page crosses the - /// transport boundary. This is intentionally layered on top of the - /// existing list query so filtering and ordering semantics stay identical. - pub fn list_memos_filtered_page( + pub fn list_page( &mut self, - notebook_id: Option<&str>, + notebook: &str, filter: &str, sort: &str, tag_id: Option<&str>, color: Option<&str>, cursor: Option<&str>, - requested_limit: Option, - ) -> Result { - let limit = requested_limit - .unwrap_or(DEFAULT_LIST_PAGE_SIZE) - .clamp(1, MAX_LIST_PAGE_SIZE); - let normalized_color = color.map(str::to_string); - if let Some(value) = normalized_color.as_deref() { - match value { - "any" | "none" | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" - | "gray" => {} - _ => { - return Err(FlowixError::InvalidInput(format!( - "unsupported memo color filter `{value}`" - ))) - } - } - } - - let mut all = self.list_memos_filtered(notebook_id, filter, sort, tag_id); - if let Some(color) = normalized_color.as_deref() { - all.retain(|memo| match color { - "any" => !memo.colors.is_empty(), - "none" => memo.colors.is_empty(), - value => memo - .colors - .iter() - .any(|memo_color| memo_color_name(*memo_color) == value), - }); - } - - let parsed_cursor = cursor - .map(|value| { - if value.len() > MAX_LIST_CURSOR_BYTES { - return Err(FlowixError::InvalidInput( - "memo list cursor is too large".to_string(), - )); - } - serde_json::from_str::(value) - .map_err(|_| FlowixError::InvalidInput("invalid memo list cursor".to_string())) - }) - .transpose()?; - if let Some(ref page_cursor) = parsed_cursor { - if page_cursor.notebook_id.as_deref() != notebook_id - || page_cursor.filter != filter - || page_cursor.sort != sort - || page_cursor.tag_id.as_deref() != tag_id - || page_cursor.color != normalized_color - { - return Err(FlowixError::InvalidInput( - "memo list cursor does not match the current query".to_string(), - )); - } - } - - let start = parsed_cursor - .as_ref() - .map(|page_cursor| { - all.iter() - .position(|memo| memo_is_after_cursor(memo, page_cursor, sort)) - .unwrap_or(all.len()) - }) - .unwrap_or(0); - let end = start.saturating_add(limit).min(all.len()); - let memos = all[start..end].to_vec(); - let has_more = end < all.len(); - let next_cursor = if has_more { - memos.last().map(|memo| { - serde_json::to_string(&MemoListCursor { - notebook_id: notebook_id.map(str::to_string), - filter: filter.to_string(), - sort: sort.to_string(), - tag_id: tag_id.map(str::to_string), - color: normalized_color.clone(), - favorited: memo.favorited, - sort_value: memo_sort_value(memo, sort), - sort_text: memo_sort_text(memo, sort), - sort_tiebreaker: memo_sort_tiebreaker(memo, sort), - id: memo.id.clone(), - }) - .expect("memo list cursor serialization cannot fail") - }) - } else { - None - }; - - Ok(MemoPage { - memos, - next_cursor, - has_more, - }) - } - - pub fn list_all_memos(&mut self, notebook_id: Option<&str>) -> Vec { - self.memo_file.read_all_memos_for_notebook_id(notebook_id) - } - - pub fn memo_metadata(&mut self, id_or_filename: &str) -> Result { - let resolved = self.resolve_memo(id_or_filename)?; - Ok(MemoFile::index_entry_to_memo(&resolved.entry)) - } - - pub fn get_memo(&mut self, id_or_filename: &str) -> Result { - let resolved = self.resolve_memo(id_or_filename)?; - let body = std::fs::read_to_string(&resolved.path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - FlowixError::NotFound(format!( - "note `{}` is indexed but its Markdown file is missing", - resolved.id - )) - } else { - FlowixError::Io(error) - } - })?; - Ok(MemoDocument { - entry: resolved.entry, - notebook: resolved.notebook, - path: resolved.path, - body, - }) - } - - pub fn create_memo_named_with_tag_in_directory( - &mut self, - notebook_key: Option<&str>, - parent_relative_path: Option<&str>, - title: &str, - body: &str, - tag: Option<&str>, - ) -> Result { - let _write_guard = self.memo_file.acquire_cross_process_write_lock()?; - let notebook = match notebook_key { - Some(key) => Some(self.resolve_notebook(key)?), - None => None, - }; - let memo = if let Some(notebook) = notebook.as_ref() { - match parent_relative_path.filter(|path| !path.is_empty()) { - Some(parent) => self.memo_file.create_memo_for_notebook_id_in_directory( - ¬ebook.id, - parent, - title, - body, - tag, - ), - None => self - .memo_file - .create_memo_for_notebook_id(¬ebook.id, title, body, tag), - } - } else { - if parent_relative_path.is_some_and(|path| !path.is_empty()) { - return Err(FlowixError::InvalidInput( - "a parent directory requires an explicit notebook".into(), - )); - } - self.memo_file.create_memo(title, body, tag) - } - .map_err(FlowixError::Io)?; - // Explicit notebook creation already resolved the target config, and the - // storage operation returns the memo it just persisted. Avoid a global - // cross-notebook id lookup here: a missed/stale catalog lookup used to - // report failure after both the Markdown file and its local index row - // had already been created. - let notebook = match notebook { - Some(notebook) => notebook, - None => { - self.memo_file - .resolve_memo_location(&memo.id)? - .ok_or_else(|| { - FlowixError::Internal(format!( - "created note `{}` could not be resolved from the index", - memo.id - )) - })? - .notebook - } - }; - let path = notebook_path_from_relative(&PathBuf::from(¬ebook.path), &memo.relative_path) - .unwrap_or_else(|_| PathBuf::from(¬ebook.path).join(&memo.filename)); - Ok(CreatedMemo { - memo, - notebook, - path, - }) + limit: Option, + ) -> Result { + self.list_notes_by_path_page(notebook, filter, sort, tag_id, color, cursor, limit) } - pub fn move_memo_to_directory( + pub fn search( &mut self, - id_or_path: &str, - notebook_key: &str, - parent_relative_path: &str, - ) -> Result { - self.move_memo_to_directory_checked(id_or_path, notebook_key, parent_relative_path, None) + notebook: &str, + query: &str, + limit: usize, + ) -> Result, FlowixError> { + self.search_with_tag_filter(notebook, query, None, limit) } - pub fn move_memo_to_directory_checked( + pub fn search_with_tag_filter( &mut self, - id_or_path: &str, - notebook_key: &str, - parent_relative_path: &str, - expected_cache_id: Option<&str>, - ) -> Result { - let _write_guard = self.memo_file.acquire_cross_process_write_lock()?; - let resolved = self.resolve_memo(id_or_path)?; - if expected_cache_id.is_some_and(|expected| expected != resolved.id) { - return Err(FlowixError::Conflict( - "document at path was replaced".into(), - )); - } - let notebook = self.resolve_notebook(notebook_key)?; - if resolved.notebook.id != notebook.id { - return Err(FlowixError::Conflict( - "memo does not belong to the selected notebook".into(), - )); - } - let (memo, _old_path, path) = self + notebook: &str, + query: &str, + tag_filter: Option<&str>, + limit: usize, + ) -> Result, FlowixError> { + let notebook = self.resolve_notebook(notebook)?; + Ok(self .memo_file - .move_memo_to_directory_for_notebook_id( - ¬ebook.id, - &resolved.id, - parent_relative_path, - ) - .map_err(FlowixError::InvalidInput)?; - Ok(EditedMemo { - id: resolved.id, - memo: Some(memo), - path, - old_bytes: 0, - new_bytes: 0, - dry_run: false, - }) - } - - /// Resolve the path that the next named create is expected to use. Desktop uses - /// this immediately before creation to suppress its own filesystem watcher event. - pub fn preview_create_path( - &mut self, - notebook_key: Option<&str>, - title: &str, - ) -> Result { - self.preview_create_path_in_directory(notebook_key, None, title) + .search_notes_with_tag_filter(¬ebook.id, query, tag_filter, limit)?) } - pub fn preview_create_path_in_directory( + pub fn create( &mut self, - notebook_key: Option<&str>, - parent_relative_path: Option<&str>, + notebook: &str, + parent: Option<&str>, title: &str, - ) -> Result { - let (base, notebook_id) = if let Some(key) = notebook_key { - let notebook = self.resolve_notebook(key)?; - (PathBuf::from(notebook.path), notebook.id) - } else { - ( - self.memo_file.get_memo_base(), - self.memo_file.current_notebook_id_for_index(), - ) - }; - let create_base = match parent_relative_path.filter(|path| !path.is_empty()) { - Some(relative) => { - notebook_path_from_relative(&base, relative).map_err(FlowixError::InvalidInput)? - } - None => base, - }; - let candidate = base_filename(title); - let occupied = self.memo_file.occupied_filenames_in_directory( - ¬ebook_id, - parent_relative_path.filter(|path| !path.is_empty()), - )?; - Ok(create_base.join(resolve_filename_conflict( - &create_base, - &candidate, - &occupied, - ))) + content: &str, + ) -> Result { + self.create_note_by_path(notebook, parent, title, content) } - pub fn edit_memo_exact( + pub fn get( &mut self, - id_or_filename: &str, - old: &str, - new: &str, - dry_run: bool, - ) -> Result { - if old.is_empty() { - return Err(FlowixError::InvalidInput( - "edit: old_string cannot be empty".into(), - )); - } - let resolved = self.resolve_memo(id_or_filename)?; - let _file_guard = self.lock_resolved_memo(&resolved, "edit_memo_exact")?; - let locked = self.resolve_memo(id_or_filename)?; - if locked.id != resolved.id || locked.path != resolved.path { - return Err(FlowixError::Conflict("document moved while waiting for the file lock".into())); - } - let current = std::fs::read_to_string(&resolved.path)?; - let matches = current.matches(old).count(); - if matches == 0 { - return Err(FlowixError::Conflict(format!( - "edit: old_string not found in `{}` (whitespace, indentation, and line endings must match)", - resolved.id - ))); - } - if matches > 1 { - return Err(FlowixError::Conflict(format!( - "edit: old_string matched {matches} times in `{}`; provide more surrounding context to make it unique", - resolved.id - ))); - } - - if dry_run { - return Ok(EditedMemo { - id: resolved.id, - memo: None, - path: resolved.path, - old_bytes: old.len(), - new_bytes: new.len(), - dry_run: true, - }); - } - - let body = current.replacen(old, new, 1); - let memo = self - .memo_file - .write_memo_preserving_filename_under_file_lock(&resolved.id, &body, &_file_guard)?; - let path = notebook_path_from_relative( - &PathBuf::from(&resolved.notebook.path), - &memo.relative_path, - ) - .unwrap_or_else(|_| PathBuf::from(&resolved.notebook.path).join(&memo.filename)); - Ok(EditedMemo { - id: resolved.id, - memo: Some(memo), - path, - old_bytes: old.len(), - new_bytes: new.len(), - dry_run: false, - }) + notebook: &str, + relative_path: &str, + ) -> Result { + self.get_note_by_path(notebook, relative_path) } - pub fn replace_memo( + pub fn save( &mut self, - id_or_filename: &str, - body: &str, - ) -> Result { - if body.trim().is_empty() { - return Err(FlowixError::InvalidInput( - "empty body, note not modified".into(), - )); - } - self.save_memo(id_or_filename, body) + notebook: &str, + relative_path: &str, + content: &str, + expected_content: Option<&str>, + ) -> Result { + self.save_note_by_path(notebook, relative_path, content, expected_content) } - /// Save Desktop editor content, including an intentionally empty document. - pub fn save_memo( + pub fn rename( &mut self, - id_or_filename: &str, - body: &str, - ) -> Result { - self.save_memo_with_validation(id_or_filename, body, |_, _| Ok(())) + notebook: &str, + relative_path: &str, + title: &str, + expected_content: Option<&str>, + ) -> Result { + self.rename_note_by_path(notebook, relative_path, title, expected_content) } - pub fn save_memo_with_validation( + pub fn move_to( &mut self, - id_or_filename: &str, - body: &str, - validate: impl FnOnce(&ResolvedMemo, &str) -> Result<(), FlowixError>, - ) -> Result { - self.save_memo_with_snapshot(id_or_filename, body, validate) - .map(|(edited, _)| edited) + notebook: &str, + relative_path: &str, + parent: &str, + ) -> Result { + self.move_note_by_path(notebook, relative_path, parent) } - pub fn save_memo_with_snapshot( - &mut self, - id_or_filename: &str, - body: &str, - validate: impl FnOnce(&ResolvedMemo, &str) -> Result<(), FlowixError>, - ) -> Result<(EditedMemo, String), FlowixError> { - self.save_memo_with_receipt(id_or_filename, body, validate) - .map(|receipt| (receipt.edited, receipt.content)) + pub fn delete(&mut self, notebook: &str, relative_path: &str) -> Result { + self.delete_note_by_path(notebook, relative_path) } - pub fn save_memo_with_receipt( - &mut self, - id_or_filename: &str, - body: &str, - validate: impl FnOnce(&ResolvedMemo, &str) -> Result<(), FlowixError>, - ) -> Result { - use sha2::{Digest, Sha256}; - let resolved = self.resolve_memo(id_or_filename)?; - let _file_guard = self.lock_resolved_memo(&resolved, "save_memo_with_receipt")?; - let locked = self.resolve_memo(id_or_filename)?; - if locked.id != resolved.id || locked.path != resolved.path { - return Err(FlowixError::Conflict("document moved while waiting for the file lock".into())); - } - let current = std::fs::read_to_string(&resolved.path)?; - validate(&resolved, ¤t)?; - let old_bytes = current.len(); - let memo = self - .memo_file - .write_memo_preserving_filename_under_file_lock(&resolved.id, body, &_file_guard)?; - let path = notebook_path_from_relative( - &PathBuf::from(&resolved.notebook.path), - &memo.relative_path, - ) - .unwrap_or_else(|_| PathBuf::from(&resolved.notebook.path).join(&memo.filename)); - let content = std::fs::read_to_string(&path)?; - let content_hash = format!("{:x}", Sha256::digest(content.as_bytes())); - let commit = match self.memo_file.commit_note_content_revision( - &resolved.notebook.id, - &memo.relative_path, - &content_hash, - &uuid::Uuid::new_v4().to_string(), - ) { - Ok(commit) => Some(commit.state), - Err(error) => { - tracing::warn!("Memo saved but revision persistence failed: {error}"); - None + pub fn delete_checked(&mut self, notebook: &str, relative_path: &str, expected_content: &str) -> Result { + let notebook = self.resolve_notebook(notebook)?; + match self.memo_file.delete_note_by_path_checked(¬ebook.id, relative_path, Some(expected_content)) { + Ok(removed) => Ok(removed), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + Err(FlowixError::Conflict("note changed before delete".into())) } - }; - Ok(MemoSaveReceipt { - edited: EditedMemo { - id: resolved.id, - memo: Some(memo), - path, - old_bytes, - new_bytes: content.len(), - dry_run: false, - }, - content, - notebook_id: resolved.notebook.id, - commit, - }) - } - - pub fn save_memo_preserving_filename( - &mut self, - id_or_filename: &str, - body: &str, - ) -> Result { - let resolved = self.resolve_memo(id_or_filename)?; - let _file_guard = self.lock_resolved_memo(&resolved, "save_memo_preserving_filename")?; - let locked = self.resolve_memo(id_or_filename)?; - if locked.id != resolved.id || locked.path != resolved.path { - return Err(FlowixError::Conflict("document moved while waiting for the file lock".into())); + Err(error) => Err(FlowixError::Io(error)), } - let old_bytes = std::fs::metadata(&resolved.path) - .map(|metadata| metadata.len() as usize) - .unwrap_or(0); - let memo = self - .memo_file - .write_memo_preserving_filename_under_file_lock(&resolved.id, body, &_file_guard)?; - let path = notebook_path_from_relative( - &PathBuf::from(&resolved.notebook.path), - &memo.relative_path, - ) - .unwrap_or_else(|_| PathBuf::from(&resolved.notebook.path).join(&memo.filename)); - Ok(EditedMemo { - id: resolved.id, - memo: Some(memo), - path, - old_bytes, - new_bytes: body.len(), - dry_run: false, - }) } +} - pub fn rename_memo( - &mut self, - id_or_filename: &str, - new_title: &str, - ) -> Result { - self.rename_memo_with_validation(id_or_filename, new_title, |_| Ok(())) - } +fn path_note_filename(relative_path: &str) -> &str { + relative_path.rsplit('/').next().unwrap_or(relative_path) +} - pub fn rename_memo_with_validation( - &mut self, - id_or_filename: &str, - new_title: &str, - validate: impl FnOnce(&ResolvedMemo) -> Result<(), FlowixError>, - ) -> Result { - let _write_guard = self.memo_file.acquire_cross_process_write_lock()?; - let resolved = self.resolve_memo(id_or_filename)?; - validate(&resolved)?; - let memo = self.memo_file.rename_memo(&resolved.id, new_title)?; - let path = notebook_path_from_relative( - &PathBuf::from(&resolved.notebook.path), - &memo.relative_path, - ) - .unwrap_or_else(|_| PathBuf::from(&resolved.notebook.path).join(&memo.filename)); - Ok(EditedMemo { - id: resolved.id, - memo: Some(memo), - path, - old_bytes: 0, - new_bytes: 0, - dry_run: false, - }) +fn note_sort_value(note: &NoteEntry, sort: &str) -> i64 { + if sort == "updatedAt" { + note.updated_at + } else { + note.created_at } +} - pub fn sync_memo_metadata(&mut self, memo: &Memo) -> Result<(), FlowixError> { - let _write_guard = self.memo_file.acquire_cross_process_write_lock()?; - self.memo_file.sync_metadata_only_global(memo)?; - Ok(()) - } - - pub fn delete_memo(&mut self, id_or_filename: &str) -> Result { - self.delete_memo_checked(id_or_filename, None) - } - - pub fn delete_memo_checked( - &mut self, - id_or_filename: &str, - expected_cache_id: Option<&str>, - ) -> Result { - let _write_guard = self.memo_file.acquire_cross_process_write_lock()?; - let resolved = self.resolve_memo(id_or_filename)?; - if expected_cache_id.is_some_and(|expected| expected != resolved.id) { - return Err(FlowixError::Conflict( - "document at path was replaced".into(), - )); - } - let file_removed = self.memo_file.delete_memo_result_global(&resolved.id)?; - Ok(DeletedMemo { - id: resolved.id, - path: resolved.path, - file_removed, - }) - } - - pub fn tag_usage_summary( - &mut self, - notebook_id: Option<&str>, - ) -> Result { - self.memo_file - .read_tag_usage_summary_for_notebook_id(notebook_id) - .map_err(FlowixError::Io) - } - - pub fn resolve_notebook(&mut self, key: &str) -> Result { - let notebooks = self.list_notebooks()?; - notebooks - .iter() - .find(|config| config.id == key) - .or_else(|| notebooks.iter().find(|config| config.name == key)) - .cloned() - .ok_or_else(|| FlowixError::NotFound(format!("notebook `{key}` not found"))) - } - - pub fn resolve_memo(&mut self, id_or_filename: &str) -> Result { - let requested = PathBuf::from(id_or_filename); - if requested.is_absolute() { - // Indexed ghosts have no file to canonicalize. Preserve exact path - // lookup so they can be removed without consulting a memo ID. - let requested = if requested.exists() { - dunce::canonicalize(&requested)? - } else { - requested - }; - for notebook in self.list_notebooks()? { - let base = PathBuf::from(¬ebook.path); - let canonical_base = if requested.exists() { - dunce::canonicalize(&base).unwrap_or(base.clone()) - } else { - base.clone() - }; - let Ok(relative) = - crate::memo_file::notebook_relative_path(&canonical_base, &requested) - else { - continue; - }; - let list = self - .memo_file - .read_index_for_notebook_id(Some(¬ebook.id))? - .unwrap_or_default(); - if let Some(entry) = list - .memos - .into_iter() - .find(|entry| entry.relative_path == relative) - { - return Ok(ResolvedMemo { - id: entry.id.clone(), - entry, - notebook, - path: requested, - }); - } - } - return Err(FlowixError::NotFound(format!( - "indexed note path {id_or_filename} not found" - ))); - } - if let Some(location) = self.memo_file.resolve_memo_location(id_or_filename)? { - let path = notebook_path_from_relative( - &PathBuf::from(&location.notebook.path), - &location.memo.relative_path, - ) - .unwrap_or_else(|_| { - PathBuf::from(&location.notebook.path).join(&location.memo.filename) - }); - return Ok(ResolvedMemo { - id: location.memo.id.clone(), - entry: location.memo, - notebook: location.notebook, - path, - }); - } - - let wanted = if id_or_filename.ends_with(".md") { - id_or_filename.to_string() - } else { - format!("{id_or_filename}.md") - }; - for notebook in self.list_notebooks()? { - let list = self - .memo_file - .read_index_for_notebook_id(Some(¬ebook.id))? - .unwrap_or_default(); - if let Some(entry) = list - .memos - .into_iter() - .find(|entry| entry.relative_path == wanted || entry.filename == wanted) - { - let path = notebook_path_from_relative( - &PathBuf::from(¬ebook.path), - &entry.relative_path, - ) - .unwrap_or_else(|_| PathBuf::from(¬ebook.path).join(&entry.filename)); - return Ok(ResolvedMemo { - id: entry.id.clone(), - entry, - notebook, - path, - }); - } - } - Err(FlowixError::NotFound(format!( - "note `{id_or_filename}` not found" - ))) - } - - /// A title request may carry the path from before another view renamed - /// this memo. Recover only when its stable memo ID and expected filename - /// still identify the current indexed entry. - pub fn resolve_memo_for_title_rename( - &mut self, - requested_path: &str, - expected_id: Option<&str>, - expected_filename: Option<&str>, - ) -> Result { - let resolved = match self.resolve_memo(requested_path) { - Ok(resolved) => resolved, - Err(FlowixError::NotFound(_)) - if expected_id.is_some() && expected_filename.is_some() => - { - self.resolve_memo(expected_id.unwrap())? - } - Err(error) => return Err(error), - }; - if expected_id.is_some_and(|id| id != resolved.id) { - return Err(FlowixError::Conflict( - "document at path was replaced".into(), - )); - } - if expected_filename.is_some_and(|name| name != resolved.entry.filename) { - return Err(FlowixError::Conflict( - "memo filename changed before rename".into(), - )); - } - Ok(resolved) - } -} - -impl<'a> NoteService<'a> { - pub fn list(&mut self, notebook: &str) -> Result, FlowixError> { - self.list_notes_by_path(notebook) - } - - pub fn list_page( - &mut self, - notebook: &str, - filter: &str, - sort: &str, - tag_id: Option<&str>, - color: Option<&str>, - cursor: Option<&str>, - limit: Option, - ) -> Result { - self.list_notes_by_path_page(notebook, filter, sort, tag_id, color, cursor, limit) - } - - pub fn search( - &mut self, - notebook: &str, - query: &str, - limit: usize, - ) -> Result, FlowixError> { - self.search_with_tag_filter(notebook, query, None, limit) - } - - pub fn search_with_tag_filter( - &mut self, - notebook: &str, - query: &str, - tag_filter: Option<&str>, - limit: usize, - ) -> Result, FlowixError> { - let notebook = self.resolve_notebook(notebook)?; - Ok(self - .memo_file - .search_notes_with_tag_filter(¬ebook.id, query, tag_filter, limit)?) - } - - pub fn create( - &mut self, - notebook: &str, - parent: Option<&str>, - title: &str, - content: &str, - ) -> Result { - self.create_note_by_path(notebook, parent, title, content) - } - - pub fn get( - &mut self, - notebook: &str, - relative_path: &str, - ) -> Result { - self.get_note_by_path(notebook, relative_path) - } - - pub fn save( - &mut self, - notebook: &str, - relative_path: &str, - content: &str, - expected_content: Option<&str>, - ) -> Result { - self.save_note_by_path(notebook, relative_path, content, expected_content) - } - - pub fn rename( - &mut self, - notebook: &str, - relative_path: &str, - title: &str, - expected_content: Option<&str>, - ) -> Result { - self.rename_note_by_path(notebook, relative_path, title, expected_content) - } - - pub fn move_to( - &mut self, - notebook: &str, - relative_path: &str, - parent: &str, - ) -> Result { - self.move_note_by_path(notebook, relative_path, parent) - } - - pub fn delete(&mut self, notebook: &str, relative_path: &str) -> Result { - self.delete_note_by_path(notebook, relative_path) - } - - pub fn delete_checked(&mut self, notebook: &str, relative_path: &str, expected_content: &str) -> Result { - let notebook = self.resolve_notebook(notebook)?; - match self.memo_file.delete_note_by_path_checked(¬ebook.id, relative_path, Some(expected_content)) { - Ok(removed) => Ok(removed), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - Err(FlowixError::Conflict("note changed before delete".into())) - } - Err(error) => Err(FlowixError::Io(error)), - } - } -} - -fn memo_sort_value(memo: &Memo, sort: &str) -> i64 { - if sort == "updatedAt" { - memo.updated_at - } else { - memo.created_at - } -} - -fn memo_sort_text(memo: &Memo, sort: &str) -> Option { - match sort { - "filenameAsc" | "filenameDesc" => Some(memo.filename.to_lowercase()), - _ => None, - } -} - -fn memo_sort_tiebreaker(memo: &Memo, sort: &str) -> Option { - match sort { - "filenameAsc" | "filenameDesc" => Some(memo.filename.clone()), - _ => None, - } -} - -fn memo_color_name(color: MemoColor) -> &'static str { - match color { - MemoColor::Red => "red", - MemoColor::Orange => "orange", - MemoColor::Yellow => "yellow", - MemoColor::Green => "green", - MemoColor::Cyan => "cyan", - MemoColor::Blue => "blue", - MemoColor::Gray => "gray", - } -} - -fn memo_is_after_cursor(memo: &Memo, cursor: &MemoListCursor, sort: &str) -> bool { - if memo.favorited != cursor.favorited { - return memo.favorited < cursor.favorited; - } - - if sort == "filenameAsc" || sort == "filenameDesc" { - let memo_key = memo.filename.to_lowercase(); - let cursor_key = cursor.sort_text.as_deref().unwrap_or_default(); - let filename_order = memo_key - .as_str() - .cmp(cursor_key) - .then_with(|| { - memo.filename - .as_str() - .cmp(cursor.sort_tiebreaker.as_deref().unwrap_or_default()) - }) - .then_with(|| memo.id.as_str().cmp(cursor.id.as_str())); - return if sort == "filenameDesc" { - filename_order == Ordering::Less - } else { - filename_order == Ordering::Greater - }; - } - - // The date list is sorted descending, so a lower key is later. - memo_sort_value(memo, sort) < cursor.sort_value - || (memo_sort_value(memo, sort) == cursor.sort_value && memo.id < cursor.id) -} - -fn path_note_filename(relative_path: &str) -> &str { - relative_path.rsplit('/').next().unwrap_or(relative_path) -} - -fn note_sort_value(note: &NoteEntry, sort: &str) -> i64 { - if sort == "updatedAt" { - note.updated_at - } else { - note.created_at - } -} - -fn note_sort_text(note: &NoteEntry, sort: &str) -> Option { - matches!(sort, "filenameAsc" | "filenameDesc") - .then(|| path_note_filename(¬e.relative_path).to_lowercase()) -} +fn note_sort_text(note: &NoteEntry, sort: &str) -> Option { + matches!(sort, "filenameAsc" | "filenameDesc") + .then(|| path_note_filename(¬e.relative_path).to_lowercase()) +} fn note_sort_tiebreaker(note: &NoteEntry, sort: &str) -> Option { matches!(sort, "filenameAsc" | "filenameDesc") @@ -1482,11 +564,6 @@ mod tests { ) .unwrap(); } - // Path creation does not write any memo-ID projection. - assert!(store - .list_note_entries_with_legacy_ids("work") - .unwrap() - .is_none()); assert_eq!( store.read_note_entries("work").unwrap_err().kind(), std::io::ErrorKind::WouldBlock, @@ -1611,681 +688,5 @@ mod tests { assert_eq!(paths, ["Alpha.md", "Beta.md", "Gamma.md"]); } - #[test] - fn path_delete_guards_replacement_and_removes_missing_index_entry() { - let (_temp, store) = service_fixture(); - let mut service = MemoService::new(&store); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Plain\n", None).unwrap(); - let path = created.path.to_string_lossy().into_owned(); - assert!(service - .delete_memo_checked(&path, Some("anotherid")) - .is_err()); - assert!(created.path.exists()); - std::fs::remove_file(&created.path).unwrap(); - assert!( - service - .delete_memo_checked(&path, Some(&created.memo.id)) - .unwrap() - .file_removed - ); - assert!(service.resolve_memo(&path).is_err()); - } - - #[test] - fn path_save_and_rename_need_no_frontmatter_identity() { - let (_temp, store) = service_fixture(); - let mut service = MemoService::new(&store); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Plain\nbody\n", None).unwrap(); - assert_eq!( - std::fs::read_to_string(&created.path).unwrap(), - "# Plain\nbody\n" - ); - let old = created.path.to_string_lossy().into_owned(); - let renamed = service.rename_memo(&old, "Renamed").unwrap(); - assert!(!created.path.exists()); - assert!(service.save_memo(&old, "stale overwrite").is_err()); - assert!(!created.path.exists(), "stale path must never be recreated"); - let content = "---\nflowix_colors: [blue]\n---\n# Plain\nupdated\n"; - service - .save_memo(&renamed.path.to_string_lossy(), content) - .unwrap(); - assert_eq!(std::fs::read_to_string(&renamed.path).unwrap(), content); - let memo = store.read_memo_global(&created.memo.id).unwrap(); - assert_eq!(memo.colors.len(), 1); - assert_eq!(memo.relative_path, "Renamed.md"); - } - - #[test] - fn title_rename_recovers_a_stale_path_only_with_matching_id_and_filename() { - let (_temp, store) = service_fixture(); - let mut service = MemoService::new(&store); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Plain\n", None).unwrap(); - let original = created.path.to_string_lossy().into_owned(); - let id = created.memo.id; - let renamed = service.rename_memo(&original, "Second").unwrap(); - let renamed_filename = renamed.memo.as_ref().unwrap().filename.clone(); - let current = service - .resolve_memo_for_title_rename(&original, Some(&id), Some(&renamed_filename)) - .unwrap(); - assert_eq!(current.id, id); - assert_eq!(current.path, renamed.path); - let third = service - .rename_memo_with_validation(¤t.id, "Third", |_| Ok(())) - .unwrap(); - assert!(third.path.exists()); - assert!(!renamed.path.exists()); - assert!(service - .resolve_memo_for_title_rename(&original, Some(&id), Some("Second.md")) - .is_err()); - assert!(service - .resolve_memo_for_title_rename(&original, Some("wrong-id"), Some("Third.md")) - .is_err()); - assert!(service - .resolve_memo_for_title_rename(&original, None, None) - .is_err()); - std::fs::write(&original, "# Replacement\n").unwrap(); - let replacement = store - .register_existing_file_for_notebook_id("work", &PathBuf::from(&original)) - .unwrap(); - assert_ne!(replacement.id, id); - assert!(service - .resolve_memo_for_title_rename(&original, Some(&id), Some("Third.md")) - .is_err()); - } - - #[test] - fn directory_move_rebases_all_children_without_touching_markdown() { - let (temp, store) = service_fixture(); - let base = temp.path().join("notes"); - let source = base.join("docs"); - std::fs::create_dir_all(source.join("nested")).unwrap(); - let content = "---\nflowix_colors: [green]\n---\n# Body\n"; - let file = source.join("nested/One.md"); - std::fs::write(&file, content).unwrap(); - let original = store - .register_existing_file_for_notebook_id("work", &file) - .unwrap(); - let target = base.join("manual"); - let changes = store.rename_indexed_path(&source, &target).unwrap(); - assert_eq!(changes.len(), 1); - let memo = store.read_memo_global(&original.id).unwrap(); - assert_eq!(memo.relative_path, "manual/nested/One.md"); - assert_eq!( - std::fs::read_to_string(target.join("nested/One.md")).unwrap(), - content - ); - assert!(MemoService::new(&store) - .save_memo(&file.to_string_lossy(), "stale") - .is_err()); - assert!(!source.exists()); - } - - #[test] - fn path_resolution_never_falls_back_to_same_filename() { - let (temp, store) = service_fixture(); - let mut service = MemoService::new(&store); - service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Same\noriginal", None).unwrap(); - let other = temp.path().join("outside/Same.md"); - std::fs::create_dir_all(other.parent().unwrap()).unwrap(); - std::fs::write(&other, "other").unwrap(); - assert!(service - .save_memo(&other.to_string_lossy(), "wrong") - .is_err()); - assert_eq!(std::fs::read_to_string(other).unwrap(), "other"); - } - - #[test] - fn save_snapshot_does_not_change_when_a_later_writer_saves() { - let (_directory, store) = service_fixture(); - let mut service = MemoService::new(&store); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Note\noriginal\n", None).unwrap(); - let (edited, snapshot) = service - .save_memo_with_snapshot(&created.memo.id, "# Note\nfirst\n", |_, _| Ok(())) - .unwrap(); - service - .save_memo(&created.memo.id, "# Note\nsecond\n") - .unwrap(); - assert!(snapshot.ends_with("# Note\nfirst\n")); - assert!(std::fs::read_to_string(edited.path) - .unwrap() - .ends_with("# Note\nsecond\n")); - } - #[test] - fn save_receipt_binds_revision_version_and_content_before_the_next_writer() { - use sha2::{Digest, Sha256}; - let (_directory, store) = service_fixture(); - let mut service = MemoService::new(&store); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Note\noriginal\n", None).unwrap(); - let first = service - .save_memo_with_receipt(&created.memo.id, "# Note\nfirst\n", |_, _| Ok(())) - .unwrap(); - let first_commit = first.commit.unwrap(); - let second = service - .save_memo_with_receipt(&created.memo.id, "# Note\nsecond\n", |_, _| Ok(())) - .unwrap(); - let second_commit = second.commit.unwrap(); - assert_eq!( - first_commit.content_hash, - format!("{:x}", Sha256::digest(first.content.as_bytes())) - ); - assert_eq!( - second_commit.content_hash, - format!("{:x}", Sha256::digest(second.content.as_bytes())) - ); - assert!(second_commit.revision > first_commit.revision); - assert_ne!(first_commit.change_id, second_commit.change_id); - assert_eq!(first.notebook_id, "work"); - } - - #[test] - fn rejected_save_preserves_file_name_content_and_index() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - let created = service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Original\n\nimportant\n", None) - .unwrap(); - let original = std::fs::read_to_string(&created.path).unwrap(); - let before = service.get_memo(&created.memo.id).unwrap(); - let result = service.save_memo_with_validation( - &created.memo.id, - "# Renamed\n\nreplacement\n", - |_, _| Err(FlowixError::Conflict("stale snapshot".to_string())), - ); - assert!(matches!(result, Err(FlowixError::Conflict(_)))); - assert_eq!(std::fs::read_to_string(&created.path).unwrap(), original); - let after = service.get_memo(&created.memo.id).unwrap(); - assert_eq!(after.entry.filename, before.entry.filename); - assert_eq!(after.entry.updated_at, before.entry.updated_at); - assert!(!created.path.parent().unwrap().join("Renamed.md").exists()); - } - - #[test] - fn validation_holds_the_file_lock_and_allows_another_file() { - let (temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Note\nold\n", None).unwrap(); - let other = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Other\nold\n", None).unwrap(); - let lock_path = temp.path().join("config/.memo-write.lock"); - let result = service - .save_memo_with_validation(&created.memo.id, "# Note\nnew\n", |_, current| { - assert!(current.contains("old")); - let probe = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(&lock_path) - .unwrap(); - assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err()); - let _other_guard = memo_file.operation_locks().file_write( - "work", other.path.parent().unwrap(), &other.path, - crate::memo_file::FileLockIntent::Existing, "other_file_probe", - ).unwrap(); - Ok(()) - }) - .unwrap(); - assert!(std::fs::read_to_string(result.path) - .unwrap() - .contains("new")); - } - - #[test] - fn concurrent_memo_saves_with_one_expected_snapshot_have_one_winner() { - use std::sync::{Arc, Barrier}; - let (temp, memo_file) = service_fixture(); - let created = MemoService::new(&memo_file) - .create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Note\nold\n", None) - .unwrap(); - let expected = std::fs::read_to_string(&created.path).unwrap(); - let barrier = Arc::new(Barrier::new(2)); - let workers: Vec<_> = ["# Note\nfirst\n", "# Note\nsecond\n"] - .into_iter() - .map(|content| { - let store = MemoFile::new(temp.path().join("config")); - let id = created.memo.id.clone(); - let expected = expected.clone(); - let barrier = barrier.clone(); - std::thread::spawn(move || { - barrier.wait(); - ( - MemoService::new(&store).save_memo_with_validation( - &id, - content, - |_, current| { - if current != expected { - return Err(FlowixError::Conflict("stale snapshot".into())); - } - Ok(()) - }, - ), - content, - ) - }) - }) - .collect(); - let results: Vec<_> = workers - .into_iter() - .map(|worker| worker.join().unwrap()) - .collect(); - assert_eq!( - results.iter().filter(|(result, _)| result.is_ok()).count(), - 1 - ); - assert_eq!( - results - .iter() - .filter(|(result, _)| matches!(result, Err(FlowixError::Conflict(_)))) - .count(), - 1 - ); - let winner = results.iter().find(|(result, _)| result.is_ok()).unwrap().1; - let stored = std::fs::read_to_string(&created.path).unwrap(); - assert!(stored.ends_with(winner)); - } - - #[test] - fn service_covers_memo_lifecycle_and_filename_resolution() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - let created = service - .create_memo_named_with_tag_in_directory(Some("Work Notes"), None, "Untitled", "# Service note\n\nold text\n", None) - .unwrap(); - assert!(created.path.exists()); - - let document = service.get_memo(&created.memo.id).unwrap(); - assert_eq!(document.entry.id, created.memo.id); - assert!(document.body.contains("old text")); - - let edited = service - .edit_memo_exact(&created.memo.id, "old text", "new text", false) - .unwrap(); - assert!(!edited.dry_run); - assert!(service - .get_memo(&created.memo.id) - .unwrap() - .body - .contains("new text")); - - let deleted = service.delete_memo(&created.memo.id).unwrap(); - assert!(deleted.file_removed); - assert!(!deleted.path.exists()); - } - - #[test] - fn create_memo_keeps_filename_independent_from_markdown_first_line() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - let created = service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Body heading\n\ncontent", None) - .unwrap(); - - assert!(created.memo.filename.starts_with("Untitled")); - assert!(!created.memo.filename.starts_with("Body heading")); - assert!(service - .get_memo(&created.memo.id) - .unwrap() - .body - .contains("# Body heading")); - } - - #[test] - fn creates_memo_in_existing_notebook_subdirectory() { - let (temp, memo_file) = service_fixture(); - std::fs::create_dir_all(temp.path().join("notes/projects/alpha")).unwrap(); - let mut service = MemoService::new(&memo_file); - - let created = service - .create_memo_named_with_tag_in_directory( - Some("work"), - Some("projects/alpha"), - "Nested", - "", - None, - ) - .unwrap(); - - assert_eq!(created.memo.relative_path, "projects/alpha/Nested.md"); - assert_eq!( - created.path, - temp.path().join("notes/projects/alpha/Nested.md") - ); - assert!(created.path.is_file()); - assert_eq!( - service - .resolve_memo(&created.memo.id) - .unwrap() - .entry - .relative_path, - "projects/alpha/Nested.md" - ); - } - - #[test] - fn rejects_create_parent_outside_notebook() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - - let error = service - .create_memo_named_with_tag_in_directory( - Some("work"), - Some("../outside"), - "Escaped", - "", - None, - ) - .unwrap_err(); - - assert!(matches!(error, FlowixError::Io(ref source) - if source.kind() == std::io::ErrorKind::InvalidInput)); - } - - #[test] - fn moves_memo_to_directory_and_updates_index_path() { - let (temp, memo_file) = service_fixture(); - std::fs::create_dir_all(temp.path().join("notes/projects")).unwrap(); - let mut service = MemoService::new(&memo_file); - let created = service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Move me\n", None).unwrap(); - let old_path = created.path.clone(); - - let moved = service - .move_memo_to_directory(&created.memo.id, "work", "projects") - .unwrap(); - - assert_eq!(moved.path, temp.path().join("notes/projects/Untitled.md")); - assert!(!old_path.exists()); - assert!(moved.path.exists()); - assert_eq!(moved.memo.unwrap().relative_path, "projects/Untitled.md"); - } - - #[test] - fn memo_pages_are_stable_and_do_not_repeat_items() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - for index in 0..5 { - service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", &format!("# Page note {index}\n"), None) - .unwrap(); - } - - let first = service - .list_memos_filtered_page(Some("work"), "all", "createdAt", None, None, None, Some(2)) - .unwrap(); - assert_eq!(first.memos.len(), 2); - assert!(first.has_more); - let second = service - .list_memos_filtered_page( - Some("work"), - "all", - "createdAt", - None, - None, - first.next_cursor.as_deref(), - Some(2), - ) - .unwrap(); - assert_eq!(second.memos.len(), 2); - assert!(second.has_more); - assert!(first - .memos - .iter() - .all(|memo| !second.memos.iter().any(|next| next.id == memo.id))); - - let third = service - .list_memos_filtered_page( - Some("work"), - "all", - "createdAt", - None, - None, - second.next_cursor.as_deref(), - Some(2), - ) - .unwrap(); - assert_eq!(third.memos.len(), 1); - assert!(!third.has_more); - assert!(third.next_cursor.is_none()); - } - - #[test] - fn memo_pages_sort_by_filename_in_both_directions() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Zulu", "# Zulu\n", None) - .unwrap(); - service - .create_memo_named_with_tag_in_directory(Some("work"), None, "alpha", "# alpha\n", None) - .unwrap(); - service - .create_memo_named_with_tag_in_directory(Some("work"), None, "middle", "# middle\n", None) - .unwrap(); - - let asc = service - .list_memos_filtered_page( - Some("work"), - "all", - "filenameAsc", - None, - None, - None, - Some(2), - ) - .unwrap(); - assert_eq!( - asc.memos - .iter() - .map(|memo| memo.filename.as_str()) - .collect::>(), - vec!["alpha.md", "middle.md"] - ); - let asc_next = service - .list_memos_filtered_page( - Some("work"), - "all", - "filenameAsc", - None, - None, - asc.next_cursor.as_deref(), - Some(2), - ) - .unwrap(); - assert_eq!( - asc_next - .memos - .iter() - .map(|memo| memo.filename.as_str()) - .collect::>(), - vec!["Zulu.md"] - ); - - let desc = service - .list_memos_filtered_page( - Some("work"), - "all", - "filenameDesc", - None, - None, - None, - Some(2), - ) - .unwrap(); - assert_eq!( - desc.memos - .iter() - .map(|memo| memo.filename.as_str()) - .collect::>(), - vec!["Zulu.md", "middle.md"] - ); - let desc_next = service - .list_memos_filtered_page( - Some("work"), - "all", - "filenameDesc", - None, - None, - desc.next_cursor.as_deref(), - Some(2), - ) - .unwrap(); - assert_eq!( - desc_next - .memos - .iter() - .map(|memo| memo.filename.as_str()) - .collect::>(), - vec!["alpha.md"] - ); - } - - #[test] - fn memo_page_rejects_a_cursor_from_another_query() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Cursor note\n", None).unwrap(); - service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Cursor note two\n", None).unwrap(); - let first = service - .list_memos_filtered_page(Some("work"), "all", "createdAt", None, None, None, Some(1)) - .unwrap(); - let error = service - .list_memos_filtered_page( - Some("work"), - "favorited", - "createdAt", - None, - None, - first.next_cursor.as_deref(), - Some(1), - ) - .unwrap_err(); - assert!(matches!(error, FlowixError::InvalidInput(_))); - } - - #[test] - fn notebook_note_counts_returns_every_notebook_from_one_index_read() { - let (temp, memo_file) = service_fixture(); - let personal_path = temp.path().join("personal"); - std::fs::create_dir_all(&personal_path).unwrap(); - let mut configs = memo_file.read_notebook_configs().unwrap(); - configs.push(NotebookConfig { - id: "personal".into(), - name: "Personal".into(), - icon: None, - path: format!("{}/", personal_path.display()), - is_default: false, - sort: 10, - created_at: 2, - updated_at: 2, - }); - memo_file.write_notebook_configs(&configs).unwrap(); - - let mut service = MemoService::new(&memo_file); - service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Work one\n", None).unwrap(); - service.create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Work two\n", None).unwrap(); - service.create_memo_named_with_tag_in_directory(Some("personal"), None, "Untitled", "# Personal one\n", None).unwrap(); - - let configs = service.list_notebooks().unwrap(); - let counts = service.notebook_note_counts(&configs).unwrap(); - assert_eq!(counts.get("work"), Some(&2)); - assert_eq!(counts.get("personal"), Some(&1)); - } - - #[test] - fn exact_edit_reports_typed_conflicts() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - let created = service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Conflict\n\nrepeat repeat\n", None) - .unwrap(); - let error = service - .edit_memo_exact(&created.memo.id, "repeat", "changed", false) - .unwrap_err(); - assert!(matches!(error, FlowixError::Conflict(_))); - } - - #[test] - fn desktop_service_preserves_explicit_titles_empty_content_and_metadata() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - - let preview = service - .preview_create_path(Some("work"), "Imported title") - .unwrap(); - let created = service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Imported title", "", None) - .unwrap(); - assert_eq!(created.path, preview); - assert_eq!(created.memo.filename, "Imported title.md"); - - let saved = service.save_memo(&created.memo.id, "").unwrap(); - assert_eq!(saved.memo.unwrap().filename, "Imported title.md"); - - let mut metadata = service.memo_metadata(&created.memo.id).unwrap(); - metadata.favorited = true; - service.sync_memo_metadata(&metadata).unwrap(); - assert!(service.memo_metadata(&created.memo.id).unwrap().favorited); - - } - - #[test] - fn rename_validation_rejects_a_stale_filename_before_mutation() { - let (_temp, memo_file) = service_fixture(); - let mut service = MemoService::new(&memo_file); - let created = service - .create_memo_named_with_tag_in_directory(Some("work"), None, "Original", "body", None) - .unwrap(); - - let error = service - .rename_memo_with_validation(&created.memo.id, "Renamed", |resolved| { - if resolved.entry.filename != "Stale.md" { - return Err(FlowixError::Conflict("stale filename".into())); - } - Ok(()) - }) - .unwrap_err(); - - assert!(matches!(error, FlowixError::Conflict(_))); - assert_eq!( - service.memo_metadata(&created.memo.id).unwrap().filename, - "Original.md" - ); - assert!(created.path.exists()); - } - - #[test] - fn independent_services_serialize_exact_edits_to_the_same_memo() { - use std::sync::{Arc, Barrier}; - use std::thread; - - let (temp, memo_file) = service_fixture(); - let created = MemoService::new(&memo_file) - .create_memo_named_with_tag_in_directory(Some("work"), None, "Untitled", "# Shared\n\nalpha beta\n", None) - .unwrap(); - let memo_id = created.memo.id; - let config_dir = temp.path().join("config"); - let barrier = Arc::new(Barrier::new(2)); - - let edits = [("alpha", "ALPHA"), ("beta", "BETA")]; - let handles = edits - .into_iter() - .map(|(old, new)| { - let barrier = barrier.clone(); - let config_dir = config_dir.clone(); - let memo_id = memo_id.clone(); - thread::spawn(move || { - let memo_file = MemoFile::new(config_dir); - let mut service = MemoService::new(&memo_file); - barrier.wait(); - service - .edit_memo_exact(&memo_id, old, new, false) - .expect("serialized edit"); - }) - }) - .collect::>(); - for handle in handles { - handle.join().expect("join"); - } - - let verifier = MemoFile::new(config_dir); - let body = MemoService::new(&verifier).get_memo(&memo_id).unwrap().body; - assert!(body.contains("ALPHA BETA"), "final body: {body}"); - } } diff --git a/app/flowix-core/tests/notebook_creation_performance.rs b/app/flowix-core/tests/notebook_creation_performance.rs index ace3f8926..f501caffe 100644 --- a/app/flowix-core/tests/notebook_creation_performance.rs +++ b/app/flowix-core/tests/notebook_creation_performance.rs @@ -1,5 +1,5 @@ -use flowix_core::memo_file::{notebook_path_from_relative, MemoFile, NotebookConfig}; -use std::{fs, time::Instant}; +use flowix_core::memo_file::{MemoFile, NotebookConfig}; +use std::fs; fn fixture(count: usize) -> (tempfile::TempDir, MemoFile, Vec) { let dir = tempfile::tempdir().unwrap(); @@ -26,56 +26,22 @@ fn scoped_revision_preserves_cas_and_notebook_isolation() { let (_dir, store, notebooks) = fixture(2); let a = ¬ebooks[0].id; let b = ¬ebooks[1].id; - assert!(store.read_memo_content_revision_for_notebook(a, "new-note").unwrap().is_none()); - let first = store.commit_memo_content_revision("new-note", a, "hash-a", "first").unwrap(); - let expected = store.read_memo_content_revision_for_notebook(a, "new-note").unwrap().unwrap(); + assert!(store.read_note_content_revision_for_notebook(a, "new-note").unwrap().is_none()); + let first = store.commit_note_content_revision(a, "new-note", "hash-a", "first").unwrap(); + let expected = store.read_note_content_revision_for_notebook(a, "new-note").unwrap().unwrap(); assert_eq!(expected, first.state); - assert!(store.read_memo_content_revision_for_notebook(b, "new-note").unwrap().is_none()); - let next = store.commit_memo_content_revision_if_current("new-note", a, "hash-b", "next", Some(&expected)).unwrap().unwrap(); + assert!(store.read_note_content_revision_for_notebook(b, "new-note").unwrap().is_none()); + let next = store.commit_note_content_revision_if_current(a, "new-note", "hash-b", "next", Some(&expected)).unwrap().unwrap(); assert_eq!(next.state.revision, 2); - assert!(store.commit_memo_content_revision_if_current("new-note", a, "stale", "late", Some(&expected)).unwrap().is_none()); + assert!(store.commit_note_content_revision_if_current(a, "new-note", "stale", "late", Some(&expected)).unwrap().is_none()); } #[test] fn scoped_revision_does_not_open_an_unrelated_broken_notebook() { let (_dir, store, notebooks) = fixture(2); let target = ¬ebooks[1].id; - let expected = store.commit_memo_content_revision("note", target, "hash", "change").unwrap(); + let expected = store.commit_note_content_revision(target, "note", "hash", "change").unwrap(); fs::write(std::path::Path::new(¬ebooks[0].path).join(".flowix/notebook.db"), b"broken database").unwrap(); - assert_eq!(store.read_memo_content_revision_for_notebook(target, "note").unwrap(), Some(expected.state)); - assert!(store.read_memo_content_revision_for_notebook(target, "fresh").unwrap().is_none()); -} - -/// Compare the old Created-event lookups with the optimized path, on isolated -/// notebook databases. This is a component benchmark, not desktop E2E timing. -#[test] -#[ignore = "manual notebook creation lookup benchmark"] -fn benchmark_notebook_creation_event_lookups() { - let (_dir, store, notebooks) = fixture(8); - let target = notebooks.last().unwrap(); - let memo = store.create_memo_for_notebook_id(&target.id, "Template note", "body", None).unwrap(); - let mut old = std::time::Duration::ZERO; - let mut new = std::time::Duration::ZERO; - for pass in 0..6 { - for optimized in if pass % 2 == 0 { [false, true] } else { [true, false] } { - let start = Instant::now(); - for _ in 0..12 { - let (path, revision) = if optimized { - let config = store.get_notebook_config_by_id(&target.id).unwrap(); - (notebook_path_from_relative(std::path::Path::new(&config.path), &memo.relative_path).unwrap(), - store.read_memo_content_revision_for_notebook(&target.id, &memo.id).unwrap()) - } else { - let location = store.resolve_memo_location(&memo.id).unwrap().unwrap(); - (notebook_path_from_relative(std::path::Path::new(&location.notebook.path), &location.memo.relative_path).unwrap(), - store.read_memo_content_revision(&memo.id).unwrap()) - }; - assert!(path.is_file()); - assert!(revision.is_none()); - } - if optimized { new += start.elapsed(); } else { old += start.elapsed(); } - } - } - println!("8 notebooks, 12 Created-event lookups, 6 alternating passes: old_mean_ms={:.1}, new_mean_ms={:.1}, speedup={:.2}x", - old.as_secs_f64() * 1000.0 / 6.0, new.as_secs_f64() * 1000.0 / 6.0, - old.as_secs_f64() / new.as_secs_f64()); + assert_eq!(store.read_note_content_revision_for_notebook(target, "note").unwrap(), Some(expected.state)); + assert!(store.read_note_content_revision_for_notebook(target, "fresh").unwrap().is_none()); } diff --git a/app/flowix-desktop/Cargo.toml b/app/flowix-desktop/Cargo.toml index 3c9a82eca..2f23b53c9 100644 --- a/app/flowix-desktop/Cargo.toml +++ b/app/flowix-desktop/Cargo.toml @@ -50,7 +50,6 @@ fs2 = { workspace = true } dunce = { workspace = true } uuid = { workspace = true } url = "2" -mime_guess = "2" hostname = "0.4" machine-uid = "0.5" sha2 = { workspace = true } @@ -65,7 +64,7 @@ tauri = { version = "2", features = ["test"] } [target.'cfg(target_os = "windows")'.dependencies] flowix-installer-helper = { path = "../flowix-installer-helper" } -windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_UI_HiDpi", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_UI_Input", "Win32_UI_WindowsAndMessaging"] } webview2-com = "0.38" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/app/flowix-desktop/build.rs b/app/flowix-desktop/build.rs index 9de34ab40..c451cd877 100644 --- a/app/flowix-desktop/build.rs +++ b/app/flowix-desktop/build.rs @@ -72,8 +72,10 @@ fn main() { let info_plist = if dev_mode { let source = std::fs::read_to_string(manifest_dir.join("Info.plist")) .expect("failed to read Info.plist"); + println!("cargo:rerun-if-changed={}", manifest_dir.join("Info.plist").display()); let dev = source.replace("flowix", "flowix-dev") - .replace("com.flowix.app.deeplink", "com.flowix.app.dev.deeplink"); + .replace("Flowix", "Flowix Dev") + .replace("com.flowix.app.deeplink", "com.flowix-dev.app.deeplink"); let generated = std::path::PathBuf::from( std::env::var_os("OUT_DIR").expect("OUT_DIR is set"), ).join("Flowix-Dev-Info.plist"); diff --git a/app/flowix-desktop/entitlements.plist b/app/flowix-desktop/entitlements.plist index 65948472e..f8afa6d70 100644 --- a/app/flowix-desktop/entitlements.plist +++ b/app/flowix-desktop/entitlements.plist @@ -66,19 +66,9 @@ --> - + com.apple.security.files.user-selected.read-write - com.apple.security.files.bookmarks.app-scope diff --git a/app/flowix-desktop/src/agent_external/claude/mod.rs b/app/flowix-desktop/src/agent_external/claude/mod.rs index 55ddd7ae4..69dc573ab 100644 --- a/app/flowix-desktop/src/agent_external/claude/mod.rs +++ b/app/flowix-desktop/src/agent_external/claude/mod.rs @@ -4,7 +4,7 @@ mod events; mod history; mod stream; -pub const AGENT_TYPE: &str = "claude"; +pub const AGENT_TYPE: &str = "claudecode"; // History API —— 读取 ~/.claude/projects//*.jsonl,还原为 ChatMessage 流。 pub use history::{get_session, get_session_page, is_claude_session_id}; diff --git a/app/flowix-desktop/src/agent_external/codex/app_server.rs b/app/flowix-desktop/src/agent_external/codex/app_server.rs index a34583bdb..841ce4a26 100644 --- a/app/flowix-desktop/src/agent_external/codex/app_server.rs +++ b/app/flowix-desktop/src/agent_external/codex/app_server.rs @@ -43,8 +43,65 @@ const MAX_PENDING_TURN_NOTIFICATIONS: usize = 4096; const CONTEXT_COMPACTION_MESSAGE_TYPE: &str = "context-compaction"; const CODEX_COMMAND_MESSAGE_TYPE: &str = "codex-command"; const CODEX_COMMENTARY_MESSAGE_TYPE: &str = "agent-commentary"; +const CODEX_MISSING_USER_ITEM_ID: &str = "flowix:codex:missing-user-item"; const MIN_PAGINATED_CODEX_VERSION: &str = "0.150.0"; +fn codex_turn_error_message_id(turn_id: &str) -> String { + format!("msg:codex:turn-error:{turn_id}") +} + +fn codex_command_message_id(turn_id: Option<&str>, source_id: &str) -> String { + match turn_id.filter(|id| !id.trim().is_empty()) { + Some(turn_id) => format!("flowix:codex:command:{turn_id}"), + // History has no shared turn key for older records that omit turn IDs. + None => format!("flowix:codex:command:unbound:{source_id}"), + } +} + +fn codex_tool_call_message_id(turn_id: Option<&str>, call_id: &str) -> String { + format!( + "flowix:codex:tool-call:{}:{call_id}", + turn_id + .filter(|id| !id.trim().is_empty()) + .unwrap_or("unbound") + ) +} + +fn codex_custom_tool_output_message_id(turn_id: Option<&str>, call_id: &str) -> String { + format!( + "flowix:codex:tool-output:{}:{call_id}", + turn_id + .filter(|id| !id.trim().is_empty()) + .unwrap_or("unbound") + ) +} + +fn codex_fallback_item_message_id( + turn_id: &str, + role: &str, + content: &str, + occurrence: usize, +) -> String { + let mut first = 0x811c_9dc5_u32; + let mut second = 0x9e37_79b9_u32; + for byte in content.as_bytes() { + first = (first ^ u32::from(*byte)).wrapping_mul(0x0100_0193); + second = (second ^ u32::from(*byte)).wrapping_mul(0x85eb_ca6b); + } + format!("flowix:codex:item:{turn_id}:{role}:{first:08x}{second:08x}:{occurrence}") +} + +fn codex_turn_error_metadata(turn_id: Option<&str>) -> AgentChunkMetadata { + let Some(turn_id) = turn_id.filter(|id| !id.trim().is_empty()) else { + return AgentChunkMetadata::default(); + }; + AgentChunkMetadata { + codex_turn_id: Some(turn_id.to_string()), + message_id: Some(codex_turn_error_message_id(turn_id)), + ..AgentChunkMetadata::default() + } +} + fn is_stale_queued_submission_error(error: &str) -> bool { error .to_ascii_lowercase() @@ -116,6 +173,7 @@ struct ActiveTurn { /// Turn-scoped notifications can beat the `turn/start` response. Ordinary /// chats keep them here until that response supplies the authoritative id. pending_notifications: Vec, + dropped_notifications: u64, turn_ready: Arc, /// The UI can stop a native command before Codex announces its provider /// turn. Keep the registry entry until that turn id arrives so the @@ -134,7 +192,7 @@ struct ActiveTurn { /// `item/completed` and in the terminal turn payload. Keep the snapshots /// per active turn so the terminal payload remains a true fallback /// instead of a second delivery. - completed_agent_messages: Arc>>, + completed_agent_messages: Arc>>, /// Runtime-owned receipts survive webview reloads until userMessage consumption. pending_steers: Vec, } @@ -149,10 +207,15 @@ struct QueuedCodexFollowUp { run_id: String, } -#[derive(Clone, Debug)] -struct CompletedAgentMessage { +struct CompletedAgentMessageCandidate { + id: Option, + text: String, +} + +struct SeenCompletedAgentMessage { id: Option, text: String, + fallback_occurrence: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -190,11 +253,33 @@ fn unbound_notification_action( fn buffer_turn_notification(active: &mut ActiveTurn, message: &Value) { if active.pending_notifications.len() == MAX_PENDING_TURN_NOTIFICATIONS { + active.dropped_notifications = active.dropped_notifications.saturating_add(1); + if active.dropped_notifications.is_power_of_two() { + tracing::warn!( + thread_id = %active.flowix_thread_id, + run_id = %active.run_id, + dropped = active.dropped_notifications, + limit = MAX_PENDING_TURN_NOTIFICATIONS, + "Codex startup notification buffer overflow; oldest notifications dropped" + ); + } active.pending_notifications.remove(0); } active.pending_notifications.push(message.clone()); } +/// Called under active_turns when registering ordinary work, so Stop cannot +/// fall between publishing a cancellation and registering the active turn. +fn take_pending_cancellation( + pending: &mut HashMap, + thread_id: &str, + run_id: &str, +) -> bool { + pending + .remove(thread_id) + .is_some_and(|expected| expected == run_id) +} + struct Inner { thread_manager: Arc, connection: Mutex>, @@ -451,6 +536,7 @@ async fn bind_automatically_started_queue_turn( codex_turn_id: codex_turn_id.to_string(), binding: TurnBinding::StartedNotification, pending_notifications: Vec::new(), + dropped_notifications: 0, turn_ready: Arc::new(tokio::sync::Notify::new()), cancel_requested: false, stopping: false, @@ -1066,26 +1152,48 @@ impl CodexAppServerManager { // so the real `item/completed(userMessage)` acknowledgement is not // dropped during that window. `turn/started` (or the RPC result) // fills in the concrete turn id below. - self.inner.active_turns.lock().await.insert(flowix_thread_id.clone(), ActiveTurn { - flowix_thread_id: flowix_thread_id.clone(), - run_id: run_id.clone(), - client_user_message_id: None, - codex_thread_id: codex_thread_id.clone(), - codex_turn_id: String::new(), - binding: TurnBinding::StartResponse, - pending_notifications: Vec::new(), - turn_ready: Arc::new(tokio::sync::Notify::new()), - cancel_requested: false, - stopping: false, - command_id: None, - command: None, - app_handle: app_handle.clone(), - started_at: chrono::Utc::now().timestamp_millis(), - last_event_at: chrono::Utc::now().timestamp_millis(), - stream_end_emitted: stream_end_emitted.clone(), - completed_agent_messages: completed_agent_messages.clone(), - pending_steers: Vec::new(), - }); + let cancelled_before_start = { + let mut turns = self.inner.active_turns.lock().await; + let cancelled = take_pending_cancellation( + &mut *self.inner.pending_cancellations.lock().await, + &flowix_thread_id, + &run_id, + ); + if !cancelled { + turns.insert(flowix_thread_id.clone(), ActiveTurn { + flowix_thread_id: flowix_thread_id.clone(), + run_id: run_id.clone(), + client_user_message_id: None, + codex_thread_id: codex_thread_id.clone(), + codex_turn_id: String::new(), + binding: TurnBinding::StartResponse, + pending_notifications: Vec::new(), + dropped_notifications: 0, + turn_ready: Arc::new(tokio::sync::Notify::new()), + cancel_requested: false, + stopping: false, + command_id: None, + command: None, + app_handle: app_handle.clone(), + started_at: chrono::Utc::now().timestamp_millis(), + last_event_at: chrono::Utc::now().timestamp_millis(), + stream_end_emitted: stream_end_emitted.clone(), + completed_agent_messages: completed_agent_messages.clone(), + pending_steers: Vec::new(), + }); + } + cancelled + }; + if cancelled_before_start { + self.emit_stream_end( + app_handle, + &flowix_thread_id, + &run_id, + Some(USER_STOPPED_REASON.to_string()), + &stream_end_emitted, + ).await; + return Ok(()); + } let mut turn_params = json!({ "threadId": codex_thread_id, "input": input, @@ -1656,6 +1764,7 @@ impl CodexAppServerManager { codex_turn_id: String::new(), binding: TurnBinding::StartResponse, pending_notifications: Vec::new(), + dropped_notifications: 0, turn_ready: Arc::new(tokio::sync::Notify::new()), cancel_requested: false, stopping: false, @@ -1925,17 +2034,16 @@ impl CodexAppServerManager { run_id: Option<&str>, app_handle: &tauri::AppHandle, ) -> bool { - if let Some(expected_run_id) = run_id.filter(|id| !id.trim().is_empty()) { - let turns = self.inner.active_turns.lock().await; - if turns - .get(thread_id) - .is_some_and(|active| active.run_id != expected_run_id) - { - return false; - } - } let active = { let mut active_turns = self.inner.active_turns.lock().await; + if let Some(expected) = run_id.filter(|id| !id.trim().is_empty()) { + if active_turns + .get(thread_id) + .is_some_and(|active| active.run_id != expected) + { + return false; + } + } match active_turns.get_mut(thread_id) { Some(active) if active.stopping => return false, Some(active) if active.codex_turn_id.is_empty() => { @@ -1945,29 +2053,27 @@ impl CodexAppServerManager { // must receive turn/interrupt. active.cancel_requested = true; active.stopping = true; - Some(active.clone()) + active.clone() } Some(active) => { active.stopping = true; - Some(active.clone()) + active.clone() + } + None => { + // Ordinary chat and slash commands can both be stopped + // before registering a native turn. Hold active_turns until + // the scoped cancellation is visible to the startup path. + if let Some(run_id) = run_id.filter(|id| !id.trim().is_empty()) { + self.inner + .pending_cancellations + .lock() + .await + .insert(thread_id.to_string(), run_id.to_string()); + return true; + } + return false; } - None => None, - } - }; - let Some(active) = active else { - // The frontend can expose the command stop control before the - // slash-command task has entered active_turns. Remember only a - // scoped command run id; execute_slash_command consumes it before - // issuing any Codex RPC. - if let Some(run_id) = run_id.filter(|id| !id.trim().is_empty()) { - self.inner - .pending_cancellations - .lock() - .await - .insert(thread_id.to_string(), run_id.to_string()); - return true; } - return false; }; if !active.codex_turn_id.is_empty() { if let Err(error) = self @@ -2545,24 +2651,53 @@ impl CodexAppServerManager { .filter(|id| !id.is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("codex-command-{}", uuid::Uuid::new_v4())); + let waits_for_provider_turn = + is_codex_compact_command(command) || is_codex_goal_mutation_command(command); + let ended = Arc::new(AtomicBool::new(false)); + let completed_agent_messages = Arc::new(Mutex::new(Vec::new())); + // Native commands can either be an immediate thread operation or + // return/start a provider turn. `/goal` is a mutation RPC whose + // response is followed by an autonomous provider turn, as shown by + // Codex's `thread_goal_updated -> task_started` event sequence. Keep + // an empty turn id temporarily: `turn/started` can arrive before the + // command RPC response and fills it in. Register before emitting the + // optimistic lifecycle events so an immediate stop cannot miss it. let cancelled_before_start = { - let mut pending = self.inner.pending_cancellations.lock().await; - match pending.get(flowix_thread_id) { - Some(expected_run_id) if expected_run_id == &run_id => { - pending.remove(flowix_thread_id); - true - } - Some(_) => { - // A stale stop for an older command must not affect this - // newly-created command. - pending.remove(flowix_thread_id); - false - } - None => false, + let mut turns = self.inner.active_turns.lock().await; + let cancelled = take_pending_cancellation( + &mut *self.inner.pending_cancellations.lock().await, + flowix_thread_id, + &run_id, + ); + if !cancelled { + turns.insert( + flowix_thread_id.to_string(), + ActiveTurn { + flowix_thread_id: flowix_thread_id.to_string(), + run_id: run_id.clone(), + client_user_message_id: None, + codex_thread_id: codex_thread_id.clone(), + codex_turn_id: String::new(), + binding: TurnBinding::StartedNotification, + pending_notifications: Vec::new(), + dropped_notifications: 0, + turn_ready: Arc::new(tokio::sync::Notify::new()), + cancel_requested: false, + stopping: false, + command_id: Some(command_id.clone()), + command: Some(command.to_string()), + app_handle: app_handle.clone(), + started_at, + last_event_at: chrono::Utc::now().timestamp_millis(), + stream_end_emitted: ended.clone(), + completed_agent_messages: completed_agent_messages.clone(), + pending_steers: Vec::new(), + }, + ); } + cancelled }; if cancelled_before_start { - let ended = Arc::new(AtomicBool::new(false)); self.emit_codex_command( app_handle, flowix_thread_id, @@ -2584,40 +2719,6 @@ impl CodexAppServerManager { .await; return Ok(json!({ "cancelled": true })); } - let waits_for_provider_turn = - is_codex_compact_command(command) || is_codex_goal_mutation_command(command); - let ended = Arc::new(AtomicBool::new(false)); - let completed_agent_messages = Arc::new(Mutex::new(Vec::new())); - // Native commands can either be an immediate thread operation or - // return/start a provider turn. `/goal` is a mutation RPC whose - // response is followed by an autonomous provider turn, as shown by - // Codex's `thread_goal_updated -> task_started` event sequence. Keep - // an empty turn id temporarily: `turn/started` can arrive before the - // command RPC response and fills it in. Register before emitting the - // optimistic lifecycle events so an immediate stop cannot miss it. - self.inner.active_turns.lock().await.insert( - flowix_thread_id.to_string(), - ActiveTurn { - flowix_thread_id: flowix_thread_id.to_string(), - run_id: run_id.clone(), - client_user_message_id: None, - codex_thread_id: codex_thread_id.clone(), - codex_turn_id: String::new(), - binding: TurnBinding::StartedNotification, - pending_notifications: Vec::new(), - turn_ready: Arc::new(tokio::sync::Notify::new()), - cancel_requested: false, - stopping: false, - command_id: Some(command_id.clone()), - command: Some(command.to_string()), - app_handle: app_handle.clone(), - started_at, - last_event_at: chrono::Utc::now().timestamp_millis(), - stream_end_emitted: ended.clone(), - completed_agent_messages: completed_agent_messages.clone(), - pending_steers: Vec::new(), - }, - ); self.emit_codex_command( app_handle, flowix_thread_id, @@ -3499,11 +3600,16 @@ async fn dispatch_notification_inner(inner: &Arc, message: &Value) { if let Some((chunk, metadata)) = completed_message_chunk(&flowix_thread_id, item, turn_id.as_deref()) { - let should_emit = item.get("type").and_then(Value::as_str) - != Some("agentMessage") - || should_emit_completed_agent_message(&completed_agent_messages, item) - .await; - if should_emit { + let mut metadata = metadata; + if should_emit_completed_agent_message( + &completed_agent_messages, + item, + turn_id.as_deref(), + None, + &mut metadata, + ) + .await + { emit_notification_chunk_with_metadata( inner, &flowix_thread_id, @@ -3583,35 +3689,42 @@ async fn dispatch_notification_inner(inner: &Arc, message: &Value) { // App Server normally finalizes messages through item/completed. // The completed turn also carries the final agent message, so // project it as an idempotent fallback before ending the stream. - if let Some(item) = params - .pointer("/turn/items") - .and_then(Value::as_array) - .and_then(|items| { - items.iter().rev().find(|item| { + if let Some(items) = params.pointer("/turn/items").and_then(Value::as_array) { + if let Some((item_index, item)) = + items.iter().enumerate().rev().find(|(_, item)| { item.get("type").and_then(Value::as_str) == Some("agentMessage") }) - }) - { - if let Some((chunk, metadata)) = - completed_message_chunk(&flowix_thread_id, item, turn_id.as_deref()) { - if should_emit_completed_agent_message(&completed_agent_messages, item) - .await + if let Some((chunk, metadata)) = + completed_message_chunk(&flowix_thread_id, item, turn_id.as_deref()) { - emit_notification_chunk_with_metadata( - inner, - &flowix_thread_id, - &run_id, - &app, - chunk, - metadata, + let occurrence = + codex_fallback_agent_message_occurrence(items, item_index, status); + let mut metadata = metadata; + if should_emit_completed_agent_message( + &completed_agent_messages, + item, + turn_id.as_deref(), + Some(occurrence), + &mut metadata, ) - .await; + .await + { + emit_notification_chunk_with_metadata( + inner, + &flowix_thread_id, + &run_id, + &app, + chunk, + metadata, + ) + .await; + } } } } if let Some(reason) = &reason { - emit_notification_chunk( + emit_notification_chunk_with_metadata( inner, &flowix_thread_id, &run_id, @@ -3624,6 +3737,7 @@ async fn dispatch_notification_inner(inner: &Arc, message: &Value) { "app-server", )), }, + codex_turn_error_metadata(turn_id.as_deref()), ) .await; } @@ -3786,6 +3900,7 @@ fn item_metadata(item: &Value) -> AgentChunkMetadata { metadata.codex_turn_id = item .get("turnId") .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) .map(str::to_string); metadata.client_user_message_id = item .get("clientId") @@ -3802,11 +3917,11 @@ fn item_metadata_from_id(item_id: Option) -> AgentChunkMetadata { } } -fn completed_agent_message(item: &Value) -> Option { +fn completed_agent_message(item: &Value) -> Option { if item.get("type").and_then(Value::as_str) != Some("agentMessage") { return None; } - let message = CompletedAgentMessage { + let message = CompletedAgentMessageCandidate { id: item .get("id") .and_then(Value::as_str) @@ -3822,33 +3937,97 @@ fn completed_agent_message(item: &Value) -> Option { } async fn should_emit_completed_agent_message( - messages: &Arc>>, + messages: &Arc>>, item: &Value, + turn_id: Option<&str>, + terminal_occurrence: Option, + metadata: &mut AgentChunkMetadata, ) -> bool { let Some(candidate) = completed_agent_message(item) else { return true; }; let mut seen = messages.lock().await; - let duplicate = seen - .iter() - .any(|previous| match (&candidate.id, &previous.id) { - // Stable provider ids are authoritative. Two different ids remain - // distinct even when the rendered text happens to match. - (Some(candidate_id), Some(previous_id)) => candidate_id == previous_id, - // If one side lacks an id, text is the only compatibility identity - // available for the terminal fallback. This branch is deliberately - // limited to that malformed/legacy boundary. - _ => candidate.text == previous.text, - }); - if duplicate { + let fallback_occurrence = candidate.id.is_none().then(|| { + terminal_occurrence.unwrap_or_else(|| { + seen.iter() + .filter(|previous| previous.id.is_none() && previous.text == candidate.text) + .count() + }) + }); + let duplicate_id = candidate.id.as_deref().is_some_and(|candidate_id| { + seen.iter() + .any(|previous| previous.id.as_deref() == Some(candidate_id)) + }); + let terminal_duplicate = terminal_occurrence.is_some_and(|occurrence| { + seen.iter().any(|previous| { + previous.id.is_none() + && previous.text == candidate.text + && previous.fallback_occurrence == Some(occurrence) + }) + }); + if duplicate_id || terminal_duplicate { return false; } - seen.push(candidate); + + if metadata.message_id.is_none() { + metadata.message_id = candidate.id.clone().or_else(|| { + let occurrence = fallback_occurrence.unwrap_or_default(); + turn_id.filter(|id| !id.trim().is_empty()).map(|turn_id| { + codex_fallback_item_message_id(turn_id, "assistant", &candidate.text, occurrence) + }) + }); + } + seen.push(SeenCompletedAgentMessage { + id: candidate.id, + text: candidate.text, + fallback_occurrence, + }); true } +fn codex_fallback_agent_message_occurrence( + items: &[Value], + item_index: usize, + turn_status: &str, +) -> usize { + let Some(item) = items.get(item_index) else { + return 0; + }; + let Some(text) = item.get("text").and_then(Value::as_str) else { + return 0; + }; + let interrupted_turn = matches!(turn_status, "failed" | "interrupted" | "cancelled"); + items[..item_index] + .iter() + .filter(|previous| { + if previous.get("type").and_then(Value::as_str) != Some("agentMessage") + || previous + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.trim().is_empty()) + || previous.get("text").and_then(Value::as_str) != Some(text) + { + return false; + } + let interrupted_item = + previous + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| { + matches!( + status, + "inProgress" | "failed" | "interrupted" | "cancelled" + ) + }); + !(interrupted_turn && interrupted_item) + }) + .count() +} + fn with_turn_id(mut metadata: AgentChunkMetadata, turn_id: Option<&str>) -> AgentChunkMetadata { - metadata.codex_turn_id = turn_id.map(str::to_string); + metadata.codex_turn_id = turn_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); metadata } @@ -3902,10 +4081,17 @@ fn completed_message_chunk( .get("id") .and_then(Value::as_str) .filter(|id| !id.trim().is_empty()) - .unwrap_or("codex-context-compaction") - .to_string(); + .map(str::to_string) + .or_else(|| { + turn_id + .filter(|id| !id.trim().is_empty()) + .map(|turn_id| codex_fallback_item_message_id(turn_id, "system", "", 0)) + }) + .unwrap_or_else(|| "codex-context-compaction".to_string()); let mut metadata = item_metadata(item); - metadata.codex_turn_id = turn_id.map(str::to_string); + metadata.codex_turn_id = turn_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); metadata.message_phase = Some("completed"); metadata.content_mode = Some("snapshot"); return Some(( @@ -3957,13 +4143,15 @@ fn completed_message_chunk( return None; } let chunk = if kind == "userMessage" { + let user_id = item + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .unwrap_or(CODEX_MISSING_USER_ITEM_ID) + .to_string(); AgentChunk::UserMessage { thread_id: thread_id.to_string(), - id: item - .get("id") - .and_then(Value::as_str) - .unwrap_or("codex-user") - .to_string(), + id: user_id, text, timestamp: chrono::Utc::now().timestamp_millis(), attachments: app_server_image_attachments(item.get("content")), @@ -3980,7 +4168,9 @@ fn completed_message_chunk( } }; let mut metadata = item_metadata(item); - metadata.codex_turn_id = turn_id.map(str::to_string); + metadata.codex_turn_id = turn_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); metadata.message_type = codex_message_type(item); // Completed item notifications do not repeat turnId in the item. The // caller fills it from the notification context before emitting. @@ -4129,6 +4319,29 @@ fn app_server_approval_policy(permission: Option<&str>) -> &'static str { mod tests { use super::*; + #[test] + fn startup_cancellation_is_scoped_and_consumed_once() { + let mut pending = HashMap::from([ + ("thread-a".to_string(), "run-a".to_string()), + ("thread-b".to_string(), "run-b".to_string()), + ]); + assert!(take_pending_cancellation(&mut pending, "thread-a", "run-a")); + assert!(!take_pending_cancellation( + &mut pending, + "thread-a", + "run-a" + )); + assert!(!take_pending_cancellation( + &mut pending, + "thread-b", + "new-run" + )); + assert!( + pending.is_empty(), + "a stale Stop must not cancel a later run" + ); + } + #[test] fn consumes_only_the_correlated_pending_steer_and_never_recreates_a_receipt() { let mut receipts = vec![ @@ -4466,6 +4679,20 @@ mod tests { assert_eq!(message.codex_turn_id.as_deref(), Some("turn-1")); } + #[test] + fn context_compaction_without_native_identity_uses_the_live_fallback() { + let messages = app_server_turn_messages(&[json!({ + "status": "completed", + "items": [{ "type": "contextCompaction" }] + })]); + + let marker = messages + .iter() + .find(|message| message.role == "system") + .expect("context compaction marker"); + assert_eq!(marker.id, "codex-context-compaction"); + } + #[test] fn codex_history_only_confirms_assistant_rows_with_native_terminal_evidence() { let active = app_server_turn_messages(&[json!({ @@ -4527,7 +4754,11 @@ mod tests { messages[0].message_type.as_deref(), Some(CODEX_COMMAND_MESSAGE_TYPE) ); - assert_eq!(messages[0].id, "codex-command-history-compaction-1"); + assert_eq!( + messages[0].id, + codex_command_message_id(Some("turn-compact"), "") + ); + assert_eq!(messages[0].id, "flowix:codex:command:turn-compact"); assert_eq!(messages[0].codex_turn_id.as_deref(), Some("turn-compact")); assert_eq!(messages[1].role, "system"); assert_eq!( @@ -4536,6 +4767,159 @@ mod tests { ); } + #[test] + fn history_commands_without_turn_ids_use_the_unbound_namespace() { + let compact = app_server_compact_command_message( + &json!({ "id": "compact-item", "type": "contextCompaction" }), + "2026-01-01T00:00:00Z", + 0, + 0, + None, + ); + let goal = app_server_goal_command_message("Ship it", "2026-01-01T00:00:00Z", 2, None); + + assert_eq!(compact.id, "flowix:codex:command:unbound:compact-item"); + assert_eq!(goal.id, "flowix:codex:command:unbound:turn-2"); + } + + #[test] + fn codex_missing_item_ids_use_turn_content_and_occurrence() { + let messages = app_server_turn_messages(&[json!({ + "id": "turn-fallback", + "status": "completed", + "items": [ + { "type": "agentMessage", "text": "same reply" }, + { "type": "agentMessage", "text": "same reply" }, + { "type": "reasoning", "summary": "same thought" }, + { "type": "reasoning", "summary": "same thought" } + ] + })]); + + assert_eq!(messages.len(), 4); + assert_eq!( + messages[0].id, + codex_fallback_item_message_id("turn-fallback", "assistant", "same reply", 0) + ); + assert_eq!( + messages[0].id, + "flowix:codex:item:turn-fallback:assistant:c9c3d78b64eaec57:0" + ); + assert_eq!( + codex_fallback_item_message_id("t", "assistant", "你好🙂", 0), + "flowix:codex:item:t:assistant:71b13a3934f0304d:0" + ); + assert_eq!( + messages[1].id, + codex_fallback_item_message_id("turn-fallback", "assistant", "same reply", 1) + ); + assert_ne!(messages[0].id, messages[1].id); + assert_eq!( + messages[2].id, + codex_fallback_item_message_id("turn-fallback", "reasoning", "same thought", 0) + ); + assert_eq!( + messages[3].id, + codex_fallback_item_message_id("turn-fallback", "reasoning", "same thought", 1) + ); + } + + #[test] + fn repeated_codex_user_items_without_ids_match_the_live_fallback_policy() { + let messages = app_server_turn_messages(&[json!({ + "id": "turn-users", + "status": "completed", + "items": [ + { "type": "userMessage", "content": "repeat" }, + { "type": "userMessage", "content": "repeat" } + ] + })]); + + assert_eq!(messages.len(), 2); + assert_eq!( + messages[0].id, + codex_fallback_item_message_id("turn-users", "user", "repeat", 0) + ); + assert_eq!( + messages[1].id, + codex_fallback_item_message_id("turn-users", "user", "repeat", 1) + ); + } + + #[test] + fn orphan_custom_tool_history_uses_provider_output_id_or_call_fallback() { + let messages = app_server_turn_messages(&[json!({ + "id": "turn-tools", + "status": "completed", + "items": [ + { "id": "output-item", "type": "custom_tool_call_output", "call_id": "call-a", "output": "done" }, + { "type": "custom_tool_call_output", "call_id": "call-b", "output": "done" } + ] + })]); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].id, "output-item"); + assert_eq!( + messages[1].id, + codex_custom_tool_output_message_id(Some("turn-tools"), "call-b") + ); + assert_eq!(messages[1].id, "flowix:codex:tool-output:turn-tools:call-b"); + } + + #[test] + fn custom_tool_call_without_item_id_uses_the_live_call_fallback() { + let messages = app_server_turn_messages(&[json!({ + "id": "turn-tools", + "status": "completed", + "items": [{ + "type": "custom_tool_call", + "call_id": "call-a", + "name": "future_connector", + "input": { "query": "x" } + }] + })]); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].id, + codex_tool_call_message_id(Some("turn-tools"), "call-a") + ); + assert_eq!(messages[0].id, "flowix:codex:tool-call:turn-tools:call-a"); + assert_eq!(messages[0].tool_call_id.as_deref(), Some("call-a")); + } + + #[test] + fn codex_tool_call_without_turn_or_item_id_uses_the_unbound_live_fallback() { + let messages = app_server_turn_messages(&[json!({ + "status": "completed", + "items": [{ + "type": "custom_tool_call", + "call_id": "call-a", + "name": "future_connector", + "input": { "query": "x" } + }] + })]); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, codex_tool_call_message_id(None, "call-a")); + assert_eq!(messages[0].id, "flowix:codex:tool-call:unbound:call-a"); + } + + #[test] + fn cancelled_turn_history_emits_the_same_error_identity_as_live() { + let messages = app_server_turn_messages(&[json!({ + "id": "turn-cancelled", + "status": "cancelled", + "items": [] + })]); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].id, + codex_turn_error_message_id("turn-cancelled") + ); + assert_eq!(messages[0].content, "Codex turn failed"); + } + #[test] fn restores_initial_goal_command_before_goal_tools_and_hides_internal_context() { let internal = r#" @@ -4775,41 +5159,122 @@ Budget: } #[tokio::test] - async fn suppresses_a_terminal_agent_snapshot_already_forwarded_for_the_turn() { + async fn preserves_distinct_identical_idless_agent_items_and_suppresses_terminal_replay() { let messages = Arc::new(Mutex::new(Vec::new())); - let completed = json!({ - "id": "message-1", - "type": "agentMessage", - "text": "Final answer" - }); + let first = json!({ "type": "agentMessage", "text": "Final answer" }); + let second = json!({ "type": "agentMessage", "text": "Final answer" }); + let mut first_metadata = AgentChunkMetadata::default(); + let mut second_metadata = AgentChunkMetadata::default(); + let first_should_emit = should_emit_completed_agent_message( + &messages, + &first, + Some("turn-1"), + None, + &mut first_metadata, + ) + .await; + let second_should_emit = should_emit_completed_agent_message( + &messages, + &second, + Some("turn-1"), + None, + &mut second_metadata, + ) + .await; + + assert!(first_should_emit); + assert_eq!( + first_metadata.message_id.as_deref(), + Some(codex_fallback_item_message_id("turn-1", "assistant", "Final answer", 0).as_str()) + ); + assert!(second_should_emit); + assert_eq!( + second_metadata.message_id.as_deref(), + Some(codex_fallback_item_message_id("turn-1", "assistant", "Final answer", 1).as_str()) + ); + + let history = app_server_turn_messages(&[json!({ + "id": "turn-1", + "status": "completed", + "items": [first.clone(), second.clone()] + })]); + assert_eq!( + history + .iter() + .map(|message| message.id.as_str()) + .collect::>(), + [ + first_metadata.message_id.as_deref().unwrap(), + second_metadata.message_id.as_deref().unwrap(), + ] + ); - assert!(should_emit_completed_agent_message(&messages, &completed).await); - assert!(!should_emit_completed_agent_message(&messages, &completed).await); + let partial_stream = Arc::new(Mutex::new(Vec::new())); + let mut partial_metadata = AgentChunkMetadata::default(); assert!( should_emit_completed_agent_message( - &messages, - &json!({ "id": "message-2", "type": "agentMessage", "text": "Final answer" }), + &partial_stream, + &first, + Some("turn-1"), + None, + &mut partial_metadata, ) .await ); - } - - #[tokio::test] - async fn matches_legacy_terminal_snapshots_when_one_side_lacks_an_id() { - let messages = Arc::new(Mutex::new(Vec::new())); - let completed = json!({ "type": "agentMessage", "text": "Final answer" }); + let mut terminal_second_metadata = AgentChunkMetadata::default(); + assert!( + should_emit_completed_agent_message( + &partial_stream, + &second, + Some("turn-1"), + Some(1), + &mut terminal_second_metadata, + ) + .await + ); + assert_eq!( + terminal_second_metadata.message_id.as_deref(), + Some(codex_fallback_item_message_id("turn-1", "assistant", "Final answer", 1).as_str()) + ); - assert!(should_emit_completed_agent_message(&messages, &completed).await); - assert!(!should_emit_completed_agent_message(&messages, &completed).await); + let mut terminal_metadata = AgentChunkMetadata::default(); assert!( !should_emit_completed_agent_message( &messages, - &json!({ "id": "message-1", "type": "agentMessage", "text": "Final answer" }), + &second, + Some("turn-1"), + Some(1), + &mut terminal_metadata, ) .await ); } + #[tokio::test] + async fn uses_terminal_item_order_for_an_unseen_idless_snapshot() { + let items = vec![ + json!({ "type": "agentMessage", "text": "Repeated", "status": "completed" }), + json!({ "type": "agentMessage", "text": "Repeated", "status": "completed" }), + ]; + let occurrence = codex_fallback_agent_message_occurrence(&items, 1, "completed"); + assert_eq!(occurrence, 1); + + let mut metadata = AgentChunkMetadata::default(); + let should_emit = should_emit_completed_agent_message( + &Arc::new(Mutex::new(Vec::new())), + &items[1], + Some("turn-1"), + Some(occurrence), + &mut metadata, + ) + .await; + assert!(should_emit); + assert_eq!( + metadata.message_id.as_deref(), + Some(codex_fallback_item_message_id("turn-1", "assistant", "Repeated", 1).as_str()) + ); + } + #[tokio::test] async fn does_not_mark_an_empty_agent_snapshot_as_completed() { let messages = Arc::new(Mutex::new(Vec::new())); @@ -4824,9 +5289,37 @@ Budget: "text": "Final answer" }); - assert!(should_emit_completed_agent_message(&messages, &empty).await); - assert!(should_emit_completed_agent_message(&messages, &complete).await); - assert!(!should_emit_completed_agent_message(&messages, &complete).await); + let mut metadata = AgentChunkMetadata::default(); + assert!( + should_emit_completed_agent_message( + &messages, + &empty, + Some("turn-1"), + None, + &mut metadata, + ) + .await + ); + assert!( + should_emit_completed_agent_message( + &messages, + &complete, + Some("turn-1"), + None, + &mut metadata, + ) + .await + ); + assert!( + !should_emit_completed_agent_message( + &messages, + &complete, + Some("turn-1"), + None, + &mut metadata, + ) + .await + ); } #[test] @@ -4955,6 +5448,25 @@ Budget: assert_eq!(metadata.message_phase.as_deref(), Some("completed")); } + #[test] + fn context_compaction_without_provider_ids_uses_shared_fallback_identity() { + for turn_id in [None, Some("")] { + let (chunk, metadata) = completed_message_chunk( + "thread-1", + &json!({ "type": "contextCompaction" }), + turn_id, + ) + .expect("completed context compaction"); + + assert!(matches!( + chunk, + AgentChunk::ContextCompaction { id, .. } + if id == "codex-context-compaction" + )); + assert!(metadata.message_id.is_none()); + } + } + #[test] fn projects_completed_reasoning_summary_as_authoritative_snapshot() { let (chunk, metadata) = completed_message_chunk( @@ -5081,10 +5593,19 @@ Budget: assert_eq!(messages.len(), 2); assert_eq!(messages[1].role, "assistant"); assert_eq!(messages[1].content, "rate limit exceeded"); - assert_eq!(messages[1].id, "codex-turn-error-turn-1"); + assert_eq!(messages[1].id, codex_turn_error_message_id("turn-1")); assert!(messages[1].error_details.is_some()); } + #[test] + fn live_turn_error_metadata_uses_the_history_identity() { + let metadata = codex_turn_error_metadata(Some("turn-1")); + let expected_id = codex_turn_error_message_id("turn-1"); + + assert_eq!(metadata.codex_turn_id.as_deref(), Some("turn-1")); + assert_eq!(metadata.message_id.as_deref(), Some(expected_id.as_str())); + } + #[test] fn keeps_tool_items_and_interruption_with_their_own_turn() { let turns = vec![ @@ -5115,7 +5636,14 @@ Budget: .iter() .map(|message| message.id.as_str()) .collect::>(), - ["u1", "a1", "tool-1", "codex-turn-error-turn-1", "u2", "a2"], + [ + "u1", + "a1", + "tool-1", + "msg:codex:turn-error:turn-1", + "u2", + "a2" + ], ); assert_eq!(messages[2].role, "tool"); assert_eq!(messages[3].content, "Codex turn interrupted"); @@ -5136,13 +5664,41 @@ Budget: let messages = app_server_turn_messages(&turns); assert_eq!( - messages.iter().map(|message| message.id.as_str()).collect::>(), - ["u1", "answer", "codex-turn-error-turn-interrupted"], + messages + .iter() + .map(|message| message.id.as_str()) + .collect::>(), + ["u1", "answer", "msg:codex:turn-error:turn-interrupted"], ); assert_eq!(messages[1].content, "Completed before stop"); assert_eq!(messages[1].is_completed, Some(true)); } + #[test] + fn history_fallback_ids_ignore_omitted_interrupted_items() { + let items = vec![ + json!({ "type": "agentMessage", "status": "inProgress", "text": "Repeated" }), + json!({ "type": "agentMessage", "status": "completed", "text": "Repeated" }), + ]; + let turn_id = "turn-interrupted"; + let occurrence = codex_fallback_agent_message_occurrence(&items, 1, "interrupted"); + let history = app_server_turn_messages(&[json!({ + "id": turn_id, + "status": "interrupted", + "items": items + })]); + let assistant = history + .iter() + .find(|message| message.role == "assistant") + .expect("completed assistant message"); + + assert_eq!(occurrence, 0); + assert_eq!( + assistant.id, + codex_fallback_item_message_id(turn_id, "assistant", "Repeated", occurrence) + ); + } + #[test] fn pages_projected_history_on_complete_turn_boundaries() { let turns = (0..3) diff --git a/app/flowix-desktop/src/agent_external/codex/app_server/history_projection.rs b/app/flowix-desktop/src/agent_external/codex/app_server/history_projection.rs index e3b594023..40343f0d5 100644 --- a/app/flowix-desktop/src/agent_external/codex/app_server/history_projection.rs +++ b/app/flowix-desktop/src/agent_external/codex/app_server/history_projection.rs @@ -49,7 +49,11 @@ pub(super) fn app_server_turn_messages_with_offset( let mut messages = Vec::new(); for (turn_offset, turn) in turns.iter().enumerate() { let turn_index = turn_index_offset + turn_offset; - let turn_id = turn.get("id").and_then(Value::as_str).map(str::to_string); + let turn_id = turn + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); let turn_duration_ms = app_server_turn_duration_ms(turn); let turn_message_start = messages.len(); let timestamp = app_server_timestamp_string( @@ -67,6 +71,7 @@ pub(super) fn app_server_turn_messages_with_offset( } let is_manual_compact_turn = is_manual_compact_turn(turn); let mut compact_command_emitted = false; + let mut fallback_item_occurrences: HashMap<(String, String), usize> = HashMap::new(); let turn_items = turn .get("items") .and_then(Value::as_array) @@ -94,8 +99,6 @@ pub(super) fn app_server_turn_messages_with_offset( messages.push(app_server_orphan_custom_tool_output_message( item, ×tamp, - turn_index, - item_index, turn_id.as_deref(), )); } @@ -138,14 +141,50 @@ pub(super) fn app_server_turn_messages_with_offset( item_index, turn_id.as_deref(), ) { - if matches!(message.role.as_str(), "assistant" | "reasoning") { - let item_status = item.get("status").and_then(Value::as_str); - let turn_status = turn.get("status").and_then(Value::as_str); - let interrupted_turn = matches!(turn_status, Some("failed" | "interrupted" | "cancelled")); - let interrupted_item = matches!(item_status, Some("inProgress" | "failed" | "interrupted" | "cancelled")); - if interrupted_turn && interrupted_item { - continue; + let item_status = item.get("status").and_then(Value::as_str); + let turn_status = turn.get("status").and_then(Value::as_str); + let interrupted_turn = + matches!(turn_status, Some("failed" | "interrupted" | "cancelled")); + let interrupted_item = matches!( + item_status, + Some("inProgress" | "failed" | "interrupted" | "cancelled") + ); + if matches!(message.role.as_str(), "assistant" | "reasoning") + && interrupted_turn + && interrupted_item + { + continue; + } + if item + .get("id") + .and_then(Value::as_str) + .map_or(true, |id| id.trim().is_empty()) + { + if message.role == "tool" { + if let Some(call_id) = message.tool_call_id.as_deref() { + message.id = + super::codex_tool_call_message_id(turn_id.as_deref(), call_id); + } + } else if let Some(turn_id) = + turn_id.as_deref().filter(|id| !id.trim().is_empty()) + { + let key = (message.role.clone(), message.content.clone()); + let occurrence = fallback_item_occurrences.entry(key).or_default(); + message.id = super::codex_fallback_item_message_id( + turn_id, + &message.role, + &message.content, + *occurrence, + ); + *occurrence += 1; + } else if item.get("type").and_then(Value::as_str) == Some("contextCompaction") + { + // The live stream uses this stable marker when the provider omits both + // the compaction item ID and its turn ID. + message.id = "codex-context-compaction".to_string(); } + } + if matches!(message.role.as_str(), "assistant" | "reasoning") { let item_terminal = item_status.is_some_and(is_terminal_native_status); let turn_terminal = turn_status.is_some_and(is_terminal_native_status); message.is_completed = Some(item_terminal || turn_terminal); @@ -205,17 +244,10 @@ pub(super) fn app_server_compact_command_message( .get("id") .and_then(Value::as_str) .filter(|id| !id.trim().is_empty()) - .or(turn_id) .map(str::to_string) .unwrap_or_else(|| format!("turn-{turn_index}-item-{item_index}")); - let mut message = - app_server_base_message(format!("codex-command-history-{source_id}"), timestamp); - message.role = "user".to_string(); - message.message_type = Some(CODEX_COMMAND_MESSAGE_TYPE.to_string()); - message.content = "/compact".to_string(); - message.is_completed = Some(true); - message.codex_turn_id = turn_id.map(str::to_string); - message + let id = super::codex_command_message_id(turn_id, &source_id); + app_server_command_message(id, "/compact", timestamp, turn_id) } pub(super) fn app_server_goal_command_message( @@ -224,16 +256,25 @@ pub(super) fn app_server_goal_command_message( turn_index: usize, turn_id: Option<&str>, ) -> ChatMessage { - let source_id = turn_id - .map(str::to_string) - .unwrap_or_else(|| format!("turn-{turn_index}")); - let mut message = - app_server_base_message(format!("codex-command-history-goal-{source_id}"), timestamp); + let source_id = format!("turn-{turn_index}"); + let id = super::codex_command_message_id(turn_id, &source_id); + app_server_command_message(id, &format!("/goal {objective}"), timestamp, turn_id) +} + +fn app_server_command_message( + id: String, + content: &str, + timestamp: &str, + turn_id: Option<&str>, +) -> ChatMessage { + let mut message = app_server_base_message(id, timestamp); message.role = "user".to_string(); message.message_type = Some(CODEX_COMMAND_MESSAGE_TYPE.to_string()); - message.content = format!("/goal {objective}"); + message.content = content.to_string(); message.is_completed = Some(true); - message.codex_turn_id = turn_id.map(str::to_string); + message.codex_turn_id = turn_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); message } @@ -257,16 +298,20 @@ pub(super) fn app_server_turn_error_message( .or_else(|| value.get("message").and_then(Value::as_str)) }); let text = match (status, error_text) { - ("failed", Some(message)) | ("interrupted", Some(message)) => message.to_string(), + ("failed", Some(message)) + | ("interrupted", Some(message)) + | ("cancelled", Some(message)) => message.to_string(), ("failed", None) => "Codex turn failed".to_string(), ("interrupted", None) => "Codex turn interrupted".to_string(), + ("cancelled", None) => "Codex turn failed".to_string(), _ => return None, }; let id = turn .get("id") .and_then(Value::as_str) - .map(|id| format!("codex-turn-error-{id}")) - .unwrap_or_else(|| format!("codex-turn-error-{turn_index}")); + .filter(|id| !id.trim().is_empty()) + .map(super::codex_turn_error_message_id) + .unwrap_or_else(|| super::codex_turn_error_message_id(&format!("index-{turn_index}"))); let mut message = app_server_base_message(id, timestamp); message.role = "assistant".to_string(); message.content = text.clone(); @@ -281,21 +326,19 @@ pub(super) fn app_server_turn_error_message( fn app_server_orphan_custom_tool_output_message( item: &Value, timestamp: &str, - turn_index: usize, - item_index: usize, turn_id: Option<&str>, ) -> ChatMessage { let call_id = custom_tool_output_call_id(item).unwrap_or("unknown-call"); let output = codex_custom_tool_output(item) .map(codex_tool_payload_text) .unwrap_or_default(); - let mut message = app_server_base_message( - format!( - "codex-custom-tool-output-{}-{turn_index}-{item_index}", - call_id - ), - timestamp, - ); + let output_id = item + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| super::codex_custom_tool_output_message_id(turn_id, call_id)); + let mut message = app_server_base_message(output_id, timestamp); message.role = "tool".to_string(); message.message_type = Some(CODEX_CUSTOM_TOOL_MESSAGE_TYPE.to_string()); message.tool_call_id = Some(call_id.to_string()); @@ -303,7 +346,9 @@ fn app_server_orphan_custom_tool_output_message( message.tool_data = Some(output.clone()); message.content = output; message.is_completed = Some(true); - message.codex_turn_id = turn_id.map(str::to_string); + message.codex_turn_id = turn_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); message } @@ -322,7 +367,9 @@ pub(super) fn app_server_item_message( .map(str::to_string) .unwrap_or_else(|| format!("codex-{turn_index}-{item_index}")); let mut message = app_server_base_message(id, timestamp); - message.codex_turn_id = turn_id.map(str::to_string); + message.codex_turn_id = turn_id + .filter(|id| !id.trim().is_empty()) + .map(str::to_string); message.message_type = codex_message_type(item).map(str::to_string); match kind { "userMessage" => { diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/host_registry.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/host_registry.rs index 66f56f231..b19afe3d7 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/host_registry.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/host_registry.rs @@ -26,42 +26,8 @@ pub(crate) struct ProcessDshClientFactory; #[async_trait::async_trait] impl DshClientFactory for ProcessDshClientFactory { async fn spawn(&self, spec: &HostLaunchSpec) -> Result, String> { - let (command, args) = app_server_command(&spec.dsh_home)?; - // Match the legacy host's strict child environment boundary. Do - // not leak arbitrary Desktop variables (especially credentials) - // into the App Server process. - let allowed = [ - "PATH", - "Path", - "PATHEXT", - "SystemRoot", - "WINDIR", - "COMSPEC", - "TMPDIR", - "TMP", - "TEMP", - "LANG", - "LC_ALL", - "HOME", - "USERPROFILE", - "NODE_USE_ENV_PROXY", - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "NO_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - "no_proxy", - ]; - let mut env = allowed - .iter() - .filter_map(|key| { - std::env::var(key) - .ok() - .map(|value| ((*key).to_string(), value)) - }) - .collect::>(); + let launch = app_server_command(&spec.dsh_home)?; + let mut env = launch.environment; env.insert("FLOWIX_DSH_APPSERVER_STDIO".into(), "1".into()); env.insert( "DSH_HOME".into(), @@ -87,14 +53,23 @@ impl DshClientFactory for ProcessDshClientFactory { "FLOWIX_DSH_PLUGIN_SETTINGS_PATH".into(), spec.plugin_settings_path.to_string_lossy().into_owned(), ); - let client = AppServerClient::spawn(&command, &args, &env).await?; + let client = + AppServerClient::spawn(&launch.executable.to_string_lossy(), &launch.args, &env) + .await?; let initialize = protocol::app_initialize_request(client.next_request_id(), env!("CARGO_PKG_VERSION")); - let initialized = client.request(initialize).await?; + let initialized = match client.request(initialize).await { + Ok(initialized) => initialized, + Err(error) => { + client.shutdown().await; + return Err(error); + } + }; let version = initialized .get("protocolVersion") .and_then(serde_json::Value::as_u64); if version != Some(super::protocol::APP_SERVER_PROTOCOL_VERSION) { + client.shutdown().await; return Err(format!( "dsh-appserver protocol version mismatch (server={version:?}, client={})", super::protocol::APP_SERVER_PROTOCOL_VERSION @@ -105,7 +80,7 @@ impl DshClientFactory for ProcessDshClientFactory { } } -fn app_server_command(dsh_home: &std::path::Path) -> Result<(String, Vec), String> { +fn app_server_command(dsh_home: &std::path::Path) -> Result { if let Ok(command) = std::env::var("FLOWIX_DSH_APPSERVER_COMMAND") { let args = std::env::var("FLOWIX_DSH_APPSERVER_ARGS") .ok() @@ -115,87 +90,20 @@ fn app_server_command(dsh_home: &std::path::Path) -> Result<(String, Vec }) .transpose()? .unwrap_or_default(); - return Ok((command, args)); - } - - let launch = crate::dsh::managed_launch_spec() - .ok_or("DSH App Server is enabled but no managed DSH bundle is available".to_string())?; - // The child uses the per-session DSH_HOME below. Syncing only the durable - // install directory leaves that child on an old profile and lets the - // legacy SDK JSON-RPC server own model/config/read. - sync_flowix_profile(&launch.root, dsh_home)?; - let cli = launch - .cli_entrypoint - .ok_or("managed DSH bundle has no CLI entrypoint for App Server".to_string())?; - // The dsh CLI boots the named profile; dsh-appserver's profile config owns - // the stdio transport. `app-server` is not an upstream CLI subcommand and - // would be forwarded into the profile application as an unknown runtime - // argument. - let args = vec![ - cli.to_string_lossy().into_owned(), - "--profile".into(), - "flowix".into(), - ]; - // `executable` is the private Node binary for managed JS bundles. Native - // launchers can still provide an explicit command through the environment. - Ok((launch.executable.to_string_lossy().into_owned(), args)) -} - -fn sync_flowix_profile( - bundle_root: &std::path::Path, - dsh_home: &std::path::Path, -) -> Result<(), String> { - let source = bundle_root.join("profile").join("flowix"); - let target = dsh_home.join("profiles").join("flowix"); - // Remove obsolete profile plugins instead of leaving them discoverable in - // a profile that is now exclusively owned by dsh-appserver. - for obsolete in [ - target.join("node_modules/@flowix/dsh-flowix-bridge"), - target.join("node_modules/@deepseek-ai/dsh-sdk-jsonrpc-server"), - ] { - if obsolete.exists() { - std::fs::remove_dir_all(&obsolete).map_err(|error| { - format!("remove obsolete DSH plugin {}: {error}", obsolete.display()) - })?; - } - } - for relative in [ - std::path::Path::new("package.json"), - std::path::Path::new("cordis.patch.yml"), - std::path::Path::new("node_modules/dsh-appserver"), - std::path::Path::new("node_modules/dsh-flowix-memory"), - ] { - let from = source.join(relative); - let to = target.join(relative); - if !from.exists() { - return Err(format!("managed DSH profile is missing {}", from.display())); - } - copy_profile_tree(&from, &to)?; + return Ok(crate::dsh::cli::DshCliLaunch { + executable: PathBuf::from(command), + args, + environment: crate::dsh::cli::child_environment(), + }); } - Ok(()) -} -fn copy_profile_tree(source: &std::path::Path, target: &std::path::Path) -> Result<(), String> { - if source.is_dir() { - std::fs::create_dir_all(target).map_err(|error| { - format!("create DSH profile directory {}: {error}", target.display()) - })?; - for entry in std::fs::read_dir(source) - .map_err(|error| format!("read DSH profile directory {}: {error}", source.display()))? - { - let entry = entry.map_err(|error| format!("read DSH profile entry: {error}"))?; - copy_profile_tree(&entry.path(), &target.join(entry.file_name()))?; - } - } else { - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(|error| { - format!("create DSH profile parent {}: {error}", parent.display()) - })?; - } - std::fs::copy(source, target) - .map_err(|error| format!("sync DSH profile {}: {error}", target.display()))?; + if !crate::dsh::flowix_profile_installed(dsh_home) { + return Err( + "DSH App Server profile is not installed; install it from Flowix Preferences" + .to_string(), + ); } - Ok(()) + crate::dsh::resolve_cli_launch(&["--profile", "flowix"]) } pub(crate) struct HostRegistry { @@ -544,4 +452,44 @@ mod tests { assert!(factory.client.lock().unwrap().as_ref().unwrap().is_closed()); assert_eq!(registry.shutdown_all().await, 0); } + + /// Run with FLOWIX_DSH_SMOKE_CLI and FLOWIX_DSH_SMOKE_HOME pointing at an + /// official npm-installed DSH and a disposable home containing the Flowix + /// profile. This covers the same resolver, environment and handshake used + /// for locally installed DSH users. + #[tokio::test] + #[ignore = "requires an official DSH CLI and disposable installed profile"] + async fn official_dsh_cli_starts_the_flowix_appserver_profile() { + let _env_lock = crate::agent_external::acquire_test_env_lock(); + let cli = std::env::var("FLOWIX_DSH_SMOKE_CLI").expect("set FLOWIX_DSH_SMOKE_CLI"); + let home = PathBuf::from( + std::env::var("FLOWIX_DSH_SMOKE_HOME").expect("set FLOWIX_DSH_SMOKE_HOME"), + ); + let previous_cli = std::env::var_os("FLOWIX_DSH_CLI_PATH"); + std::env::set_var("FLOWIX_DSH_CLI_PATH", &cli); + let spec = HostLaunchSpec { + session_root: home.join("sessions"), + settings_path: home.join("settings.yaml"), + credentials_path: home.join(".credentials.yaml"), + plugin_settings_path: home.join("plugin-settings.json"), + dsh_home: home, + }; + + let result = async { + let client = ProcessDshClientFactory.spawn(&spec).await?; + let request = protocol::model_settings_describe_request(client.next_request_id()); + let settings = client.request(request).await; + client.shutdown().await; + settings + } + .await; + + match previous_cli { + Some(value) => std::env::set_var("FLOWIX_DSH_CLI_PATH", value), + None => std::env::remove_var("FLOWIX_DSH_CLI_PATH"), + } + let settings = + result.expect("official DSH profile must complete handshake and read model settings"); + assert!(settings.get("providers").is_some()); + } } diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs index 3a42798f3..ab0ee5543 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs @@ -145,9 +145,13 @@ impl DeepSeekHarnessManager { message: AgentUserMessage, app_handle: &tauri::AppHandle, ) -> Result { - if !crate::dsh::status().installed { + let dsh_status = crate::dsh::status(); + if !dsh_status.installed { return Err("DeepSeek Harness runtime is not installed".to_string()); } + if !dsh_status.app_server_profile_installed { + return Err("DSH App Server profile is not installed; install it from Flowix Preferences".to_string()); + } let thread_id = thread_id.to_string(); let run_id = crate::agent_external::resolve_run_id(&thread_id, message.run_id.as_deref()); @@ -1124,45 +1128,6 @@ impl DeepSeekHarnessManager { Ok(()) } - /// Move any key written by an older Flowix build into DSH, then remove - /// the duplicate only after DSH confirms ownership. - pub async fn migrate_legacy_credentials( - &self, - configs: &[crate::config::AiConfigFile], - ) -> Result<(), String> { - for config in configs { - let route = if config.model.provider_id.trim().is_empty() { - config.model.provider.trim() - } else { - config.model.provider_id.trim() - }; - let Some(secret) = self - .user_config - .legacy_dsh_secret(route) - .map_err(|error| error.to_string())? - .filter(|value| !value.trim().is_empty()) - else { - continue; - }; - let reference = Self::credential_reference(&config.model); - if !self.credential_configured(&reference).await? { - let host = self.model_host().await?; - let result = timed_host_request( - &host.client(), - protocol::credential_set_request(host.next_request_id(), &reference, &secret), - ) - .await; - result?; - } - if self.credential_configured(&reference).await? { - self.user_config - .delete_legacy_dsh_secret(route) - .map_err(|error| error.to_string())?; - } - } - Ok(()) - } - pub async fn persist_credential(&self, config: &AiModelConfig) -> Result<(), String> { let reference = Self::credential_reference(config); let route = if config.provider_id.trim().is_empty() { diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs index 28623c10f..aa1125b56 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs @@ -16,4 +16,4 @@ mod transport; pub use config::resolve_runtime_config; pub use manager::{DeepSeekHarnessManager, DeepSeekHarnessSessionUsage}; -pub const AGENT_TYPE: &str = "deepseek-harness"; +pub const AGENT_TYPE: &str = "dsh"; diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs index e2ff484d9..7d95ab2fd 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs @@ -200,7 +200,7 @@ mod tests { runs.register("t", "r1", Some("s1"), Arc::new(AtomicBool::new(false))) .await.unwrap(); runs.mark_stopping("t", "r1").await; - let snapshot = runs.running_threads("deepseek-harness").await; + let snapshot = runs.running_threads("dsh").await; assert_eq!(snapshot["t"].phase.as_deref(), Some("stopping")); assert!(runs.register("t", "r2", None, Arc::new(AtomicBool::new(false))).await.is_err()); assert!(runs.remove_if_matches("t", "r1").await); diff --git a/app/flowix-desktop/src/agent_external/lifecycle.rs b/app/flowix-desktop/src/agent_external/lifecycle.rs index 5297826e4..014a803ae 100644 --- a/app/flowix-desktop/src/agent_external/lifecycle.rs +++ b/app/flowix-desktop/src/agent_external/lifecycle.rs @@ -233,7 +233,7 @@ mod tests { image_paths: Vec::new(), run_id: None, system_reminder_directory: None, - agent_type: Some("claude".to_string()), + agent_type: Some("claudecode".to_string()), runtime_config: None, permission_mode: None, codex_model: None, diff --git a/app/flowix-desktop/src/agent_external/pi/config.rs b/app/flowix-desktop/src/agent_external/pi/config.rs index 747f65192..00388cf05 100644 --- a/app/flowix-desktop/src/agent_external/pi/config.rs +++ b/app/flowix-desktop/src/agent_external/pi/config.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::Path; @@ -14,6 +14,12 @@ pub struct PiModelEntry { #[serde(default)] pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub subtitle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credit_multiplier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_icon_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub api: Option, #[serde(default)] pub reasoning: bool, @@ -30,8 +36,14 @@ pub struct PiModelEntry { pub struct PiProviderConfig { pub id: String, pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_url: Option, pub api: String, pub base_url: String, + /// Flowix-owned provider source, surfaced for settings UI classification. + /// Stored in Pi settings.json so Pi's native models.json stays provider-only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, #[serde(default)] pub api_key: Option, #[serde(default)] @@ -65,8 +77,10 @@ pub struct PiModelCatalog { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PiFeatures { - pub code_mode: bool, pub tool_search: bool, + /// Read-only input for the one-time frontend preference migration. + #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")] + pub legacy_code_mode: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -79,9 +93,9 @@ pub struct PiMcpServer { pub fn features() -> Result { let dir = pi_config_dir()?; let settings = read_json(&dir.join("settings.json"))?; - let features: PiFeatures = - serde_json::from_value(settings.get("flowixFeatures").cloned().unwrap_or_default()) - .unwrap_or_default(); + let value = settings.get("flowixFeatures").cloned().unwrap_or_default(); + let mut features: PiFeatures = serde_json::from_value(value.clone()).unwrap_or_default(); + features.legacy_code_mode = value.get("codeMode").and_then(Value::as_bool); Ok(features) } @@ -90,11 +104,26 @@ pub fn save_features(features: PiFeatures) -> Result<(), String> { fs::create_dir_all(&dir).map_err(|error| error.to_string())?; let path = dir.join("settings.json"); let mut settings = read_json(&path)?; - settings["flowixFeatures"] = - serde_json::to_value(features).map_err(|error| error.to_string())?; + if !settings["flowixFeatures"].is_object() { + settings["flowixFeatures"] = json!({}); + } + settings["flowixFeatures"]["toolSearch"] = json!(features.tool_search); write_json(&path, &settings) } +pub fn clear_legacy_code_mode() -> Result<(), String> { + let path = pi_config_dir()?.join("settings.json"); + let mut settings = read_json(&path)?; + let removed = settings + .get_mut("flowixFeatures") + .and_then(Value::as_object_mut) + .and_then(|features| features.remove("codeMode")); + if removed.is_some() { + write_json(&path, &settings)?; + } + Ok(()) +} + fn mcp_config_path() -> Result { Ok(pi_config_dir()?.join("mcp.json")) } @@ -156,7 +185,10 @@ fn validate_mcp_server(name: &str, config: &Value) -> Result<(), String> { return Err("Pi MCP command and url values must be non-empty strings".into()); } if command == url { - return Err("Configure exactly one Pi MCP transport: command (stdio) or url (streamable HTTP)".into()); + return Err( + "Configure exactly one Pi MCP transport: command (stdio) or url (streamable HTTP)" + .into(), + ); } if let Some(kind) = object.get("type").and_then(Value::as_str) { if !matches!(kind, "stdio" | "http" | "streamable-http") { @@ -179,9 +211,11 @@ fn validate_mcp_server(name: &str, config: &Value) -> Result<(), String> { } } for key in ["env", "headers"] { - if object.get(key).and_then(Value::as_object).is_some_and(|values| { - values.values().any(|value| !value.is_string()) - }) { + if object + .get(key) + .and_then(Value::as_object) + .is_some_and(|values| values.values().any(|value| !value.is_string())) + { return Err(format!("Pi MCP {key} values must be strings")); } } @@ -203,7 +237,10 @@ fn validate_mcp_server(name: &str, config: &Value) -> Result<(), String> { } } if let Some(exposure) = object.get("exposure").and_then(Value::as_str) { - if !matches!(exposure, "codemode" | "codemode-deferred" | "deferred" | "direct" | "hidden") { + if !matches!( + exposure, + "codemode" | "codemode-deferred" | "deferred" | "direct" | "hidden" + ) { return Err("Pi MCP exposure must be codemode, deferred, direct or hidden".into()); } } @@ -215,7 +252,10 @@ pub fn save_mcp_server(name: &str, config: Value) -> Result<(), String> { let (path, mut file) = read_mcp_config()?; let servers = object_at(&mut file, "mcpServers")?; let normalized = name.replace('-', "_"); - if servers.keys().any(|existing| existing != name && existing.replace('-', "_") == normalized) { + if servers + .keys() + .any(|existing| existing != name && existing.replace('-', "_") == normalized) + { return Err("Pi treats server names that differ only by - and _ as the same name".into()); } servers.insert(name.to_string(), config); @@ -224,7 +264,8 @@ pub fn save_mcp_server(name: &str, config: Value) -> Result<(), String> { pub fn delete_mcp_server(name: &str) -> Result<(), String> { let (path, mut file) = read_mcp_config()?; - let is_empty = if let Some(servers) = file.get_mut("mcpServers").and_then(Value::as_object_mut) { + let is_empty = if let Some(servers) = file.get_mut("mcpServers").and_then(Value::as_object_mut) + { servers.remove(name); servers.is_empty() } else { @@ -343,20 +384,29 @@ pub fn list() -> Result, String> { let settings = read_json(&dir.join("settings.json"))?; let default_provider = settings.get("defaultProvider").and_then(Value::as_str); let default_model = settings.get("defaultModel").and_then(Value::as_str); + let cloud_provider_id = settings + .get("flowixCloudProviderId") + .and_then(Value::as_str); let providers = models .get("providers") .and_then(Value::as_object) .cloned() .unwrap_or_default(); + let flowix_catalog = settings.get("flowixPiCatalog").and_then(Value::as_object); Ok(providers .into_iter() .filter_map(|(id, provider)| { let models = provider.get("models")?.as_array()?; + let provider_metadata = flowix_catalog.and_then(|catalog| catalog.get(&id)); + let model_metadata = provider_metadata + .and_then(|metadata| metadata.get("models")) + .and_then(Value::as_object); let entries: Vec = models .iter() .filter_map(|entry| { let id = entry.get("id")?.as_str()?.to_string(); + let metadata = model_metadata.and_then(|catalog| catalog.get(&id)); Some(PiModelEntry { id, name: entry @@ -364,6 +414,18 @@ pub fn list() -> Result, String> { .and_then(Value::as_str) .unwrap_or_default() .to_string(), + subtitle: metadata + .and_then(|item| item.get("subtitle")) + .and_then(Value::as_str) + .map(str::to_string), + credit_multiplier: metadata + .and_then(|item| item.get("creditMultiplier")) + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && (0.01..=10.0).contains(value)), + provider_icon_url: metadata + .and_then(|item| item.get("providerIconUrl")) + .and_then(Value::as_str) + .map(str::to_string), api: entry.get("api").and_then(Value::as_str).map(str::to_string), reasoning: entry .get("reasoning") @@ -390,6 +452,10 @@ pub fn list() -> Result, String> { .and_then(Value::as_str) .unwrap_or(&id) .to_string(), + icon_url: provider_metadata + .and_then(|metadata| metadata.get("providerIconUrl")) + .and_then(Value::as_str) + .map(str::to_string), api: provider .get("api") .and_then(Value::as_str) @@ -400,6 +466,8 @@ pub fn list() -> Result, String> { .and_then(Value::as_str) .unwrap_or_default() .to_string(), + source: (cloud_provider_id == Some(id.as_str())) + .then(|| "flowix-cloud".to_string()), api_key: None, credential_configured: auth .get(&id) @@ -495,6 +563,7 @@ pub fn save(mut config: PiProviderConfig, app: &AppHandle) -> Result<(), String> let settings_path = dir.join("settings.json"); let mut settings = read_json(&settings_path)?; + let mut settings_changed = false; if let Some(default_model) = config .default_model_id .as_deref() @@ -502,12 +571,86 @@ pub fn save(mut config: PiProviderConfig, app: &AppHandle) -> Result<(), String> { settings["defaultProvider"] = json!(id); settings["defaultModel"] = json!(default_model); - write_json(&settings_path, &settings)?; + settings_changed = true; } else if settings.get("defaultProvider").and_then(Value::as_str) == Some(id.as_str()) { if let Some(settings) = settings.as_object_mut() { settings.remove("defaultProvider"); settings.remove("defaultModel"); } + settings_changed = true; + } + if config.source.as_deref() == Some("flowix-cloud") { + settings["flowixCloudProviderId"] = json!(id); + settings_changed = true; + } + let mut model_metadata = Map::new(); + for model in &config.models { + let mut metadata = Map::new(); + if let Some(subtitle) = model + .subtitle + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + metadata.insert("subtitle".into(), json!(subtitle.trim())); + } + if let Some(multiplier) = model + .credit_multiplier + .filter(|value| value.is_finite() && (0.01..=10.0).contains(value)) + { + metadata.insert("creditMultiplier".into(), json!(multiplier)); + } + if let Some(icon_url) = model + .provider_icon_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + metadata.insert("providerIconUrl".into(), json!(icon_url.trim())); + } + if !metadata.is_empty() { + model_metadata.insert(model.id.clone(), Value::Object(metadata)); + } + } + let mut provider_metadata = Map::new(); + if let Some(icon_url) = config + .icon_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + provider_metadata.insert("providerIconUrl".into(), json!(icon_url.trim())); + } + if !model_metadata.is_empty() { + provider_metadata.insert("models".into(), Value::Object(model_metadata)); + } + if provider_metadata.is_empty() { + let removed = settings + .get_mut("flowixPiCatalog") + .and_then(Value::as_object_mut) + .is_some_and(|catalog| catalog.remove(&id).is_some()); + if removed { + let catalog_empty = settings + .get("flowixPiCatalog") + .and_then(Value::as_object) + .is_some_and(Map::is_empty); + if catalog_empty { + settings + .as_object_mut() + .map(|settings| settings.remove("flowixPiCatalog")); + } + settings_changed = true; + } + } else { + let provider_metadata = Value::Object(provider_metadata); + let unchanged = settings + .get("flowixPiCatalog") + .and_then(Value::as_object) + .and_then(|catalog| catalog.get(&id)) + == Some(&provider_metadata); + if !unchanged { + object_at(&mut settings, "flowixPiCatalog")?.insert(id.clone(), provider_metadata); + settings_changed = true; + } + } + if settings_changed { write_json(&settings_path, &settings)?; } @@ -539,11 +682,41 @@ pub fn delete(provider_id: &str) -> Result<(), String> { write_json(&models_path, &models)?; let settings_path = dir.join("settings.json"); let mut settings = read_json(&settings_path)?; + let mut settings_changed = false; if settings.get("defaultProvider").and_then(Value::as_str) == Some(id) { if let Some(settings) = settings.as_object_mut() { settings.remove("defaultProvider"); settings.remove("defaultModel"); } + settings_changed = true; + } + if settings + .get("flowixCloudProviderId") + .and_then(Value::as_str) + == Some(id) + { + if let Some(settings) = settings.as_object_mut() { + settings.remove("flowixCloudProviderId"); + } + settings_changed = true; + } + let removed_catalog_metadata = settings + .get_mut("flowixPiCatalog") + .and_then(Value::as_object_mut) + .is_some_and(|catalog| catalog.remove(id).is_some()); + if removed_catalog_metadata { + let catalog_empty = settings + .get("flowixPiCatalog") + .and_then(Value::as_object) + .is_some_and(Map::is_empty); + if catalog_empty { + settings + .as_object_mut() + .map(|settings| settings.remove("flowixPiCatalog")); + } + settings_changed = true; + } + if settings_changed { write_json(&settings_path, &settings)?; } let auth_path = dir.join("auth.json"); @@ -554,6 +727,201 @@ pub fn delete(provider_id: &str) -> Result<(), String> { write_json(&auth_path, &auth) } +pub fn legacy_cloud_provider_id(api_base: &str) -> Result, String> { + let dir = pi_config_dir()?; + let models = read_json(&dir.join("models.json"))?; + let auth = read_json(&dir.join("auth.json"))?; + let provider_id = "flowix-cloud"; + let expected_base_url = format!("{}/v1/ai", api_base.trim_end_matches('/')); + let provider = models + .get("providers") + .and_then(Value::as_object) + .and_then(|providers| providers.get(provider_id)); + let looks_like_cloud_provider = provider.is_some_and(|provider| { + provider.get("name").and_then(Value::as_str) == Some("Flowix Cloud") + && provider.get("api").and_then(Value::as_str) == Some("openai-completions") + && provider.get("baseUrl").and_then(Value::as_str) == Some(&expected_base_url) + }); + let has_cloud_credential = auth + .get(provider_id) + .and_then(|entry| entry.get("key")) + .and_then(Value::as_str) + .is_some_and(|key| key.starts_with("fai_")); + Ok((looks_like_cloud_provider && has_cloud_credential).then(|| provider_id.to_string())) +} + +fn collect_cloud_provider_ids( + providers: impl IntoIterator, + marked_provider_id: Option<&str>, + legacy_provider_id: Option<&str>, +) -> Vec { + let mut ids: Vec = providers + .into_iter() + .filter_map(|(id, is_cloud)| is_cloud.then_some(id)) + .collect(); + for id in [marked_provider_id, legacy_provider_id] + .into_iter() + .flatten() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + if !ids.iter().any(|existing| existing == id) { + ids.push(id.to_string()); + } + } + ids +} + +/// Remove every Flowix Cloud Pi provider, including providers identified by +/// the settings marker and the legacy provider shape. +pub fn delete_cloud_providers(api_base: &str) -> Result { + let providers = list()?.into_iter().map(|provider| { + let is_cloud = provider.source.as_deref() == Some("flowix-cloud"); + (provider.id, is_cloud) + }); + let settings = read_json(&pi_config_dir()?.join("settings.json"))?; + let marked_provider_id = settings + .get("flowixCloudProviderId") + .and_then(Value::as_str); + let legacy_provider_id = legacy_cloud_provider_id(api_base)?; + let provider_ids = + collect_cloud_provider_ids(providers, marked_provider_id, legacy_provider_id.as_deref()); + if provider_ids.is_empty() { + return Ok(false); + } + for provider_id in provider_ids { + delete(&provider_id)?; + } + Ok(true) +} + +pub fn is_flowix_cloud_provider(provider_id: Option<&str>, api_base: &str) -> Result { + let dir = pi_config_dir()?; + let settings = read_json(&dir.join("settings.json"))?; + let selected_provider = provider_id + .filter(|value| !value.trim().is_empty()) + .or_else(|| settings.get("defaultProvider").and_then(Value::as_str)); + let Some(selected_provider) = selected_provider else { + return Ok(false); + }; + let providers = list()?; + if providers.iter().any(|provider| { + provider.id == selected_provider && provider.source.as_deref() == Some("flowix-cloud") + }) { + return Ok(true); + } + Ok(legacy_cloud_provider_id(api_base)?.as_deref() == Some(selected_provider)) +} + +pub fn save_cloud_credential( + app: &AppHandle, + api_base: &str, + credential: flowix_sync::CloudAiCredential, +) -> Result<(), String> { + let api_base = api_base.trim_end_matches('/'); + let providers = list()?; + let legacy_cloud_id = legacy_cloud_provider_id(api_base)?; + let settings = read_json(&pi_config_dir()?.join("settings.json"))?; + let catalog = if credential.models.is_empty() { + vec![credential.model.clone()] + } else { + credential.models.clone() + }; + let provider_icon_url = credential + .provider + .as_ref() + .and_then(|provider| provider.icon_url.clone()); + let model_ids: Vec = catalog.iter().map(|model| model.id.clone()).collect(); + let current_cloud = providers + .iter() + .find(|provider| provider.source.as_deref() == Some("flowix-cloud")) + .or_else(|| { + legacy_cloud_id + .as_deref() + .and_then(|id| providers.iter().find(|provider| provider.id == id)) + }); + let saved_default = current_cloud + .and_then(|provider| provider.default_model_id.clone()) + .filter(|id| model_ids.iter().any(|model_id| model_id == id)); + let default_model_id = saved_default + .or_else(|| { + model_ids + .iter() + .find(|id| **id == credential.model.id) + .cloned() + }) + .or_else(|| model_ids.first().cloned()); + let provider_id = current_cloud + .map(|provider| provider.id.clone()) + .unwrap_or_else(|| { + let existing_ids: std::collections::HashSet<&str> = providers + .iter() + .map(|provider| provider.id.as_str()) + .collect(); + let base_id = "flowix-cloud-member"; + if !existing_ids.contains(base_id) { + return base_id.to_string(); + } + let mut suffix = 2; + loop { + let candidate = format!("{base_id}-{suffix}"); + if !existing_ids.contains(candidate.as_str()) { + return candidate; + } + suffix += 1; + } + }); + let default_model_id = if current_cloud.is_some() + && settings.get("defaultProvider").and_then(Value::as_str) != Some(provider_id.as_str()) + { + None + } else { + default_model_id + }; + save( + PiProviderConfig { + id: provider_id, + display_name: "Flowix Cloud".into(), + icon_url: provider_icon_url, + api: "openai-completions".into(), + base_url: format!("{api_base}/v1/ai"), + source: Some("flowix-cloud".into()), + api_key: Some(credential.api_key), + credential_configured: true, + default_model_id, + models: catalog + .into_iter() + .map(|model| { + let id = model.id; + let name = model + .title + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| id.clone()); + PiModelEntry { + id, + name, + subtitle: model + .subtitle + .filter(|subtitle| !subtitle.trim().is_empty()), + credit_multiplier: model + .credit_multiplier + .filter(|value| value.is_finite() && (0.01..=10.0).contains(value)), + provider_icon_url: model + .provider_icon_url + .filter(|url| !url.trim().is_empty()), + api: None, + reasoning: false, + vision: false, + context_window: None, + max_tokens: None, + } + }) + .collect(), + }, + app, + ) +} + pub async fn test_connection(config: PiProviderConfig) -> Result { let dir = pi_config_dir()?; let auth = read_json(&dir.join("auth.json"))?; @@ -677,6 +1045,9 @@ pub async fn discover_models(config: PiProviderConfig) -> Result Result, model: Option, + cloud_auth_generation: Option, thinking: Option, startup_resources: PiStartupResources, } @@ -308,6 +310,17 @@ pub struct PiSessionSnapshot { revision: PiHistoryRevision, } +fn empty_pi_session_snapshot() -> PiSessionSnapshot { + PiSessionSnapshot { + messages: Vec::new(), + revision: PiHistoryRevision { + session_id: String::new(), + append_cursor: None, + leaf_id: None, + }, + } +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct PiHistoryPage { @@ -390,6 +403,28 @@ async fn read_pi_rpc_record( } } +/// Keep diagnostics available while the process is alive, with bounded memory. +async fn capture_pi_stderr( + mut reader: R, + captured: Arc>, +) -> std::io::Result<()> { + let mut tail = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let count = reader.read(&mut chunk).await?; + if count == 0 { + return Ok(()); + } + tail.extend_from_slice(&chunk[..count]); + if tail.len() > PI_STDERR_TAIL_BYTES { + tail.drain(..tail.len() - PI_STDERR_TAIL_BYTES); + } + // Decode the retained bytes together so a UTF-8 character split across + // reads is restored once its remaining bytes arrive. + *captured.lock().await = String::from_utf8_lossy(&tail).into_owned(); + } +} + #[derive(Default)] struct PiEntriesCache { entries: Arc>, @@ -440,7 +475,6 @@ struct PiSession { config: PiSessionConfig, stdin: Arc>, records: Mutex>>, - deferred_records: Mutex>, response_waiters: Arc>>>, entries_cache: Mutex, child: Mutex, @@ -457,6 +491,18 @@ impl PiSession { } async fn request(&self, id: &str, value: &Value) -> Result { + let response = self.request_response(id, value).await?; + if response.get("success").and_then(Value::as_bool) != Some(true) { + return Err(response + .get("error") + .and_then(Value::as_str) + .unwrap_or("Pi rejected the RPC command") + .to_string()); + } + Ok(response) + } + + async fn request_response(&self, id: &str, value: &Value) -> Result { let (sender, receiver) = oneshot::channel(); self.response_waiters .lock() @@ -474,13 +520,6 @@ impl PiSession { return Err("Pi RPC command timed out".into()); } }; - if response.get("success").and_then(Value::as_bool) != Some(true) { - return Err(response - .get("error") - .and_then(Value::as_str) - .unwrap_or("Pi rejected the RPC command") - .to_string()); - } Ok(response) } @@ -501,9 +540,6 @@ impl PiSession { } async fn next_record(&self) -> Result { - if let Some(record) = self.deferred_records.lock().await.pop_front() { - return Ok(record); - } self.records .lock() .await @@ -512,10 +548,9 @@ impl PiSession { .ok_or_else(|| "Pi RPC event reader closed".to_string())? } - /// Called only by the owner of `operation`. Read responses from the same - /// receiver and preserve interleaved agent events for the main event loop. + /// Serialize cursor updates while allowing the read RPC to run alongside + /// a prompt and its streamed events. async fn session_entries(&self) -> Result<(Arc>, Option), String> { - static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); let mut cache = self.entries_cache.lock().await; // The cursor follows append order, not the active leaf: branch switches // can move the leaf backwards without changing the append-only log. @@ -526,23 +561,12 @@ impl PiSession { .and_then(Value::as_str) .map(str::to_owned); loop { - let request_id = format!( - "flowix-entries-{}", - REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) - ); + let request_id = format!("flowix-entries-{}", next_pi_rpc_id()); let mut request = serde_json::json!({"id": request_id, "type": "get_entries"}); if let Some(cursor) = &since { request["since"] = Value::String(cursor.clone()); } - self.write(&request).await?; - let mut records = self.records.lock().await; - let mut deferred = self.deferred_records.lock().await; - let response = tokio::time::timeout( - PI_RPC_REQUEST_TIMEOUT, - receive_rpc_response(&mut records, &mut deferred, &request_id), - ) - .await - .map_err(|_| "Pi session entries RPC timed out".to_string())??; + let response = self.request_response(&request_id, &request).await?; if response.get("success").and_then(Value::as_bool) != Some(true) { if since.take().is_some() { // A stale cursor or an older runtime can be recovered with @@ -568,6 +592,17 @@ impl PiSession { } } + async fn session_messages(&self) -> Result, String> { + let request_id = format!("flowix-messages-{}", next_pi_rpc_id()); + let request = serde_json::json!({"id":request_id, "type":"get_messages"}); + let response = self.request(&request_id, &request).await?; + response + .pointer("/data/messages") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| "Pi get_messages response did not include messages".to_string()) + } + async fn is_alive(&self) -> bool { !self.invalidated.load(Ordering::Acquire) && matches!(self.child.lock().await.try_wait(), Ok(None)) @@ -607,6 +642,7 @@ struct ActivePiRun { run_id: String, started_at: i64, session: Mutex>>, + session_state: tokio::sync::watch::Sender>>, session_id: Mutex>, queued_messages: Mutex>, queue_lock: Mutex<()>, @@ -633,6 +669,21 @@ struct PiProjectedUserMessage { } impl ActivePiRun { + async fn wait_for_session(&self) -> Option> { + let mut session_state = self.session_state.subscribe(); + loop { + if let Some(session) = session_state.borrow().clone() { + return Some(session); + } + if self.finalizing.load(Ordering::Acquire) { + return None; + } + if session_state.changed().await.is_err() { + return None; + } + } + } + async fn take_queued_user_message(&self, native_text: &str) -> Option { let _queue_guard = self.queue_lock.lock().await; let mut queued = self.queued_messages.lock().await; @@ -676,6 +727,51 @@ pub struct PiRpcManager { active_runs: Mutex>>, shutting_down: AtomicBool, history_snapshots: Mutex)>>, + cloud_credential_generation: Mutex>, + startup_cloud_refresh_results: Mutex>>, +} + +async fn run_startup_cloud_refresh_once( + results: &Mutex>>, + generation: u64, + refresh: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + // Keep the lock while refreshing so concurrent WebViews await the same + // result, including a failure, instead of treating an in-flight attempt + // as a successful refresh. + let mut results = results.lock().await; + if let Some(result) = results.get(&generation) { + return result.clone(); + } + + let result = refresh().await; + results.insert(generation, result.clone()); + result +} + +async fn run_startup_cloud_credential_refresh_once( + results: &Mutex>>, + credential_generation: &Mutex>, + generation: u64, + refresh: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + run_startup_cloud_refresh_once(results, generation, || async { + // Startup refreshes the live model catalog even if this process has + // already created a credential for the current Cloud login. + let mut credential_generation = credential_generation.lock().await; + refresh().await?; + *credential_generation = Some(generation); + Ok(true) + }) + .await } #[async_trait::async_trait] @@ -715,20 +811,6 @@ impl ExternalLifecycleEmitter for PiRpcManager { impl PiRpcManager { pub fn new(thread_manager: Arc) -> Self { - #[cfg(windows)] - { - static INITIALIZE_SESSIONS: std::sync::Once = std::sync::Once::new(); - INITIALIZE_SESSIONS.call_once(|| { - if let Some(local) = std::env::var_os("LOCALAPPDATA") { - if let Err(error) = - crate::runtime_state::migrate_pi_sessions(std::path::Path::new(&local)) - { - // Legacy conversations remain accessible in place. - tracing::warn!("Pi session migration deferred: {error}"); - } - } - }); - } Self { thread_manager, sessions: Mutex::new(HashMap::new()), @@ -737,7 +819,185 @@ impl PiRpcManager { active_runs: Mutex::new(HashMap::new()), shutting_down: AtomicBool::new(false), history_snapshots: Mutex::new(VecDeque::new()), + cloud_credential_generation: Mutex::new(None), + startup_cloud_refresh_results: Mutex::new(HashMap::new()), + } + } + + pub async fn connect_cloud_model_provider( + &self, + app: &tauri::AppHandle, + api_base: &str, + ) -> Result<(), String> { + let app_state = app.state::(); + let cloud_sync = app_state.cloud_sync.clone(); + let generation = cloud_sync.ai_credential_session_generation(); + drop(app_state); + + let mut cached_generation = self.cloud_credential_generation.lock().await; + self.create_and_save_cloud_credential(app, api_base, &cloud_sync, generation) + .await?; + *cached_generation = Some(generation); + Ok(()) + } + + /// Remove the local Flowix Cloud provider and its credential after logout. + pub async fn disconnect_cloud_model_provider( + &self, + app: &tauri::AppHandle, + api_base: &str, + logout_generation: u64, + ) -> Result<(), String> { + // Serialize with credential creation and refresh before reading the + // provider files, so a concurrent save cannot slip past this cleanup. + let mut cached_generation = self.cloud_credential_generation.lock().await; + let app_state = app.state::(); + let cloud_sync = app_state.cloud_sync.clone(); + drop(app_state); + if cloud_sync.ai_credential_session_generation() != logout_generation + || cloud_sync + .state() + .map_err(|error| error.to_string())? + .authenticated + { + return Ok(()); + } + *cached_generation = None; + if super::config::delete_cloud_providers(api_base)? { + let _ = app.emit( + crate::commands::settings::USER_CONFIG_CHANGED_EVENT, + "pi_config", + ); } + Ok(()) + } + + /// Refresh the saved Flowix Cloud model metadata once after workspace startup. + /// The generation guard deduplicates calls from multiple content WebViews. + pub async fn refresh_cloud_model_provider_at_startup( + &self, + app: &tauri::AppHandle, + api_base: &str, + ) -> Result { + let has_cloud_provider = super::config::list()? + .iter() + .any(|provider| provider.source.as_deref() == Some("flowix-cloud")); + if !has_cloud_provider { + return Ok(false); + } + + let app_state = app.state::(); + let cloud_sync = app_state.cloud_sync.clone(); + if !cloud_sync + .state() + .map_err(|error| error.to_string())? + .authenticated + { + return Ok(false); + } + let generation = cloud_sync.ai_credential_session_generation(); + drop(app_state); + + run_startup_cloud_credential_refresh_once( + &self.startup_cloud_refresh_results, + &self.cloud_credential_generation, + generation, + || async { + self.create_and_save_cloud_credential(app, api_base, &cloud_sync, generation) + .await + }, + ) + .await + } + + /// Refresh the saved Flowix Cloud model catalog on an explicit user request. + pub async fn refresh_cloud_model_provider( + &self, + app: &tauri::AppHandle, + api_base: &str, + ) -> Result { + let has_cloud_provider = super::config::list()? + .iter() + .any(|provider| provider.source.as_deref() == Some("flowix-cloud")); + if !has_cloud_provider { + return Ok(false); + } + + let app_state = app.state::(); + let cloud_sync = app_state.cloud_sync.clone(); + if !cloud_sync + .state() + .map_err(|error| error.to_string())? + .authenticated + { + return Ok(false); + } + let generation = cloud_sync.ai_credential_session_generation(); + drop(app_state); + + let mut cached_generation = self.cloud_credential_generation.lock().await; + self.create_and_save_cloud_credential(app, api_base, &cloud_sync, generation) + .await?; + *cached_generation = Some(generation); + Ok(true) + } + + async fn ensure_cloud_credential( + &self, + app: &tauri::AppHandle, + provider_id: Option<&str>, + api_base: &str, + ) -> Result, String> { + if !super::config::is_flowix_cloud_provider(provider_id, api_base)? { + return Ok(None); + } + let app_state = app.state::(); + let cloud_sync = app_state.cloud_sync.clone(); + let generation = cloud_sync.ai_credential_session_generation(); + drop(app_state); + let mut cached_generation = self.cloud_credential_generation.lock().await; + if *cached_generation == Some(generation) { + return Ok(Some(generation)); + } + + self.create_and_save_cloud_credential(app, api_base, &cloud_sync, generation) + .await?; + *cached_generation = Some(generation); + Ok(Some(generation)) + } + + async fn create_and_save_cloud_credential( + &self, + app: &tauri::AppHandle, + api_base: &str, + cloud_sync: &flowix_sync::SyncManager, + generation: u64, + ) -> Result<(), String> { + let credential = cloud_sync + .create_ai_credential() + .await + .map_err(|error| error.to_string())?; + if cloud_sync.ai_credential_session_generation() != generation { + return Err( + "Flowix Cloud account changed while preparing the Pi model; retry the request" + .into(), + ); + } + if let Err(error) = super::config::save_cloud_credential(app, api_base, credential) { + if cloud_sync.ai_credential_session_generation() == generation { + if let Err(revoke_error) = cloud_sync.revoke_ai_credential().await { + tracing::warn!( + "failed to revoke an unconfigured Flowix Cloud Pi credential: {revoke_error}" + ); + } + } + return Err(error); + } + let _ = app.emit( + crate::commands::settings::USER_CONFIG_CHANGED_EVENT, + "pi_config", + ); + Ok(()) } pub async fn queue_message( @@ -989,13 +1249,11 @@ impl PiRpcManager { session.shutdown(thread_id).await; } let mut deleted = false; - for candidate in pi_session_candidates(thread_id)? { - match std::fs::remove_dir_all(candidate) { - Ok(()) => deleted = true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!("could not delete Pi conversation session: {error}")); - } + match std::fs::remove_dir_all(pi_session_dir(thread_id)?) { + Ok(()) => deleted = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("could not delete Pi conversation session: {error}")); } } Ok(deleted) @@ -1156,10 +1414,7 @@ impl PiRpcManager { let stderr = Arc::new(Mutex::new(String::new())); let stderr_capture = stderr.clone(); tokio::spawn(async move { - let mut reader = BufReader::new(stderr_pipe); - let mut captured = String::new(); - let _ = reader.read_to_string(&mut captured).await; - *stderr_capture.lock().await = captured; + let _ = capture_pi_stderr(stderr_pipe, stderr_capture).await; }); let session = Arc::new(PiSession { @@ -1167,7 +1422,6 @@ impl PiRpcManager { config: config.clone(), stdin, records: Mutex::new(record_rx), - deferred_records: Mutex::new(VecDeque::new()), response_waiters, entries_cache: Mutex::new(PiEntriesCache::default()), child: Mutex::new(child), @@ -1502,17 +1756,12 @@ impl PiRpcManager { .get_external_session(thread_id, AGENT_TYPE) .await .map_err(|error| error.to_string())?; - let has_session = - mapped_session.is_some() || self.sessions.lock().await.contains_key(thread_id); + let active_run = self.active_runs.lock().await.get(thread_id).cloned(); + let has_session = mapped_session.is_some() + || self.sessions.lock().await.contains_key(thread_id) + || active_run.is_some(); if !has_session { - return Ok(PiSessionSnapshot { - messages: Vec::new(), - revision: PiHistoryRevision { - session_id: String::new(), - append_cursor: None, - leaf_id: None, - }, - }); + return Ok(empty_pi_session_snapshot()); } let cwd = self .thread_manager @@ -1528,6 +1777,7 @@ impl PiRpcManager { tool_search: false, provider: None, model: None, + cloud_auth_generation: None, thinking: None, startup_resources: skill_resources::capture( &cwd, @@ -1535,72 +1785,71 @@ impl PiRpcManager { &resolve_pi_session_dir(thread_id, true)?, )?, }; - let session = self.ensure_session(app, thread_id, config, true).await?; - let _operation = tokio::time::timeout( - PI_HISTORY_LOCK_TIMEOUT, - self.lock_session_operation(thread_id, &session), - ) - .await - .map_err(|_| { - "Pi history read timed out waiting for the active turn to finish".to_string() - })??; - session.touch(); - let request_id = format!("flowix-history-{}", chrono::Utc::now().timestamp_millis()); - if let Err(error) = session - .write(&serde_json::json!({"id":request_id,"type":"get_messages"})) - .await - { - drop(_operation); - self.remove_session_if_same(thread_id, &session).await; - return Err(error); - } - let response = tokio::time::timeout(PI_RPC_REQUEST_TIMEOUT, async { - loop { - let value = session.next_record().await?; - if value.get("id").and_then(Value::as_str) == Some(&request_id) - && value.get("type").and_then(Value::as_str) == Some("response") - { - return Ok::(value); + let session = if let Some(active_run) = active_run.as_ref() { + let active_session = + tokio::time::timeout(PI_RPC_REQUEST_TIMEOUT, active_run.wait_for_session()) + .await + .map_err(|_| { + "Pi session initialization timed out while reading history".to_string() + })?; + if let Some(session) = active_session { + session + } else if let Some(session) = self.sessions.lock().await.get(thread_id).cloned() { + session + } else { + let mapped_session = self + .thread_manager + .get_external_session(thread_id, AGENT_TYPE) + .await + .map_err(|error| error.to_string())?; + if mapped_session.is_some() { + self.ensure_session(app, thread_id, config, true).await? + } else { + return Ok(empty_pi_session_snapshot()); } } - }) - .await; - let value = match response { - Ok(Ok(value)) => value, - Ok(Err(error)) => { - drop(_operation); - self.remove_session_if_same(thread_id, &session).await; - return Err(error); - } - Err(_) => { - drop(_operation); - self.remove_session_if_same(thread_id, &session).await; - return Err("Pi history read timed out waiting for an RPC response".into()); - } + } else { + self.ensure_session(app, thread_id, config, true).await? }; - if value.get("success").and_then(Value::as_bool) != Some(true) { - return Err(value - .get("error") - .and_then(Value::as_str) - .unwrap_or("Pi could not read this session") - .to_string()); - } session.touch(); - let mut messages = value - .pointer("/data/messages") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let (entries, leaf_id) = match session.session_entries().await { - Ok(snapshot) => snapshot, - Err(error) => { - drop(_operation); - self.shutdown_and_remove_session_if_same(thread_id, &session) - .await; - return Err(error); + // Pi keeps the operation lock for the whole prompt. History reads use + // correlated RPC waiters and a small revision fence instead, matching + // the behavior of Codex's concurrent live-turn/history reads. + let active_at_start = self.active_runs.lock().await.contains_key(thread_id); + let mut last_read = None; + let mut stable_revision_seen = false; + for attempt in 0..3 { + let (before_entries, before_leaf) = session.session_entries().await?; + let messages = session.session_messages().await?; + let (entries, leaf_id) = session.session_entries().await?; + let before_cursor = before_entries + .last() + .and_then(|entry| entry.get("id")) + .and_then(Value::as_str); + let after_cursor = entries + .last() + .and_then(|entry| entry.get("id")) + .and_then(Value::as_str); + let revision_matches = before_cursor == after_cursor && before_leaf == leaf_id; + last_read = Some((messages, entries, leaf_id)); + if revision_matches { + stable_revision_seen = true; + break; } - }; - attach_pi_session_message_ids(&mut messages, &entries, leaf_id.as_deref())?; + if attempt < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + let (mut messages, entries, leaf_id) = + last_read.ok_or_else(|| "Pi history read did not return a snapshot".to_string())?; + let active_during_read = + active_at_start || self.active_runs.lock().await.contains_key(thread_id); + attach_pi_session_message_ids_with_pending_tail( + &mut messages, + &entries, + leaf_id.as_deref(), + active_during_read || !stable_revision_seen, + )?; // Keep original Pi block positions even when thinking is hidden. for (sequence, message) in messages.iter_mut().enumerate() { if let Some(object) = message.as_object_mut() { @@ -1627,6 +1876,7 @@ impl PiRpcManager { } } let native_session_id = session.session_id.lock().await.clone(); + session.touch(); Ok(PiSessionSnapshot { messages, revision: PiHistoryRevision { @@ -1725,6 +1975,7 @@ impl PiRpcManager { run_id: run_id.clone(), started_at, session: Mutex::new(None), + session_state: tokio::sync::watch::channel(None).0, session_id: Mutex::new(None), queued_messages: Mutex::new(HashMap::new()), queue_lock: Mutex::new(()), @@ -1768,6 +2019,9 @@ impl PiRpcManager { if active.finalizing.swap(true, Ordering::AcqRel) { return; } + active + .session_state + .send_replace(active.session.lock().await.clone()); let stop_reason = active.stop_reason.lock().await.clone(); let error = match stop_reason.as_deref() { Some(USER_STOPPED_REASON) => None, @@ -1822,6 +2076,14 @@ impl PiRpcManager { let pi_features = super::config::features()?; let runtime_model = runtime_model.filter(|value| !value.trim().is_empty() && value != "inherit"); + let cloud_api_base = app + .state::() + .cloud_environment + .api_base() + .to_string(); + let cloud_auth_generation = self + .ensure_cloud_credential(app, provider.as_deref(), &cloud_api_base) + .await?; let thinking = message .reasoning_effort_for_runtime(AGENT_TYPE) .filter(|value| { @@ -1834,12 +2096,11 @@ impl PiRpcManager { let config = PiSessionConfig { cwd: cwd.clone(), tools: pi_tools_for_permission_mode(permission_mode).into(), - code_mode: message - .code_mode_for_runtime(AGENT_TYPE) - .unwrap_or(pi_features.code_mode), + code_mode: message.code_mode_for_runtime(AGENT_TYPE).unwrap_or(false), tool_search: pi_features.tool_search, provider: provider.clone().filter(|value| !value.trim().is_empty()), model: runtime_model.clone(), + cloud_auth_generation, thinking, startup_resources: skill_resources::capture( &cwd, @@ -1849,6 +2110,7 @@ impl PiRpcManager { }; let session = self.ensure_session(app, thread_id, config, false).await?; *active.session.lock().await = Some(session.clone()); + active.session_state.send_replace(Some(session.clone())); let session_id = session.session_id.lock().await.clone(); *active.session_id.lock().await = Some(session_id.clone()); active @@ -2947,8 +3209,9 @@ async fn run_pi_fork_extension( if let Some(error) = value.get("error").and_then(Value::as_str) { return Err(error.to_string()); } - let fork: PiForkMetadata = serde_json::from_value(value) - .map_err(|error| format!("Pi fork returned incomplete metadata: {error}"))?; + let fork: PiForkMetadata = serde_json::from_value(value).map_err(|error| { + format!("Pi fork returned incomplete metadata: {error}") + })?; if fork.session_id.trim().is_empty() || fork.session_id == expected_source_id { return Err("Pi fork did not create a new session ID".into()); } @@ -3006,14 +3269,6 @@ fn pi_session_dir(thread_id: &str) -> Result { Ok(root.join(stable_name)) } -fn legacy_pi_session_dir(thread_id: &str) -> Result { - let root = pi_session_root()?; - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - thread_id.hash(&mut hasher); - Ok(root.join(format!("{:016x}", hasher.finish()))) -} - fn pi_session_root() -> Result { #[cfg(windows)] { @@ -3026,57 +3281,8 @@ fn pi_session_root() -> Result { .ok_or_else(|| "user data directory is unavailable".to_string()) } -fn pi_session_candidates(thread_id: &str) -> Result, String> { - #[allow(unused_mut)] - let mut candidates = vec![ - pi_session_dir(thread_id)?, - legacy_pi_session_dir(thread_id)?, - ]; - #[cfg(windows)] - { - let local = std::env::var_os("LOCALAPPDATA").ok_or("LOCALAPPDATA is unavailable")?; - let old_root = PathBuf::from(local).join("Flowix").join("pi-sessions"); - candidates.push( - old_root.join( - candidates[0] - .file_name() - .ok_or("Pi session path has no name")?, - ), - ); - candidates.push( - old_root.join( - candidates[1] - .file_name() - .ok_or("Pi session path has no name")?, - ), - ); - } - Ok(candidates) -} - fn resolve_pi_session_dir(thread_id: &str, create: bool) -> Result { - #[cfg(windows)] - let path = - crate::runtime_state::resolve_pi_session_candidates(&pi_session_candidates(thread_id)?)?; - #[cfg(not(windows))] - let path = { - let stable = pi_session_dir(thread_id)?; - let legacy = legacy_pi_session_dir(thread_id)?; - if !stable.exists() && legacy.exists() { - std::fs::create_dir_all(stable.parent().ok_or("Pi session path has no parent")?) - .map_err(|error| format!("cannot create Pi session directory: {error}"))?; - match std::fs::rename(&legacy, &stable) { - Ok(()) => {} - Err(_) if stable.exists() => {} - Err(error) => { - return Err(format!( - "could not migrate Pi conversation session: {error}" - )) - } - } - } - stable - }; + let path = pi_session_dir(thread_id)?; if create { std::fs::create_dir_all(&path) .map_err(|error| format!("cannot create Pi session directory: {error}"))?; @@ -3084,7 +3290,11 @@ fn resolve_pi_session_dir(thread_id: &str, create: bool) -> Result bool { + left == right +} + +#[cfg(test)] async fn receive_rpc_response( records: &mut mpsc::UnboundedReceiver>, deferred: &mut VecDeque, @@ -3101,10 +3311,6 @@ async fn receive_rpc_response( } } -fn pi_message_matches(left: &Value, right: &Value) -> bool { - left == right -} - #[cfg(test)] fn pi_rpc_message_id( entries: &[Value], @@ -3155,15 +3361,35 @@ fn pi_active_branch_entries<'a>( Ok(branch) } +#[cfg(test)] fn attach_pi_session_message_ids( - messages: &mut [Value], + messages: &mut Vec, + entries: &[Value], + leaf_id: Option<&str>, +) -> Result<(), String> { + attach_pi_session_message_ids_impl(messages, entries, leaf_id, false) +} + +fn attach_pi_session_message_ids_with_pending_tail( + messages: &mut Vec, + entries: &[Value], + leaf_id: Option<&str>, + allow_pending_tail: bool, +) -> Result<(), String> { + attach_pi_session_message_ids_impl(messages, entries, leaf_id, allow_pending_tail) +} + +fn attach_pi_session_message_ids_impl( + messages: &mut Vec, entries: &[Value], leaf_id: Option<&str>, + allow_pending_tail: bool, ) -> Result<(), String> { let branch = pi_active_branch_entries(entries, leaf_id)?; let mut entry_index = 0; + let mut first_unmatched_renderable = None; let mut unmatched_renderable_messages = 0; - for message in messages { + for (message_index, message) in messages.iter_mut().enumerate() { let matched_index = branch[entry_index..].iter().position(|entry| { entry.get("type").and_then(Value::as_str) == Some("message") && entry @@ -3182,10 +3408,16 @@ fn attach_pi_session_message_ids( | "compactionSummary" ) ) { + first_unmatched_renderable.get_or_insert(message_index); unmatched_renderable_messages += 1; } continue; }; + if first_unmatched_renderable.is_some() { + return Err( + "Pi history contains an unmatched message before a later committed message".into(), + ); + } entry_index += relative_index; let entry = branch[entry_index]; entry_index += 1; @@ -3202,6 +3434,12 @@ fn attach_pi_session_message_ids( } } if unmatched_renderable_messages > 0 { + if allow_pending_tail { + if let Some(start) = first_unmatched_renderable { + messages.truncate(start); + return Ok(()); + } + } return Err(format!( "Pi returned {unmatched_renderable_messages} history messages that could not be reconciled with native ids on the active session branch" )); @@ -3209,70 +3447,164 @@ fn attach_pi_session_message_ids( Ok(()) } -fn resolve_pi_binary(app: &tauri::AppHandle) -> Result { - if let Some(value) = std::env::var_os("PI_CLI_PATH") { - let candidate = PathBuf::from(value); - if candidate.is_file() { - return Ok(candidate); - } - } - #[cfg(target_os = "macos")] - let platform = "darwin"; - #[cfg(target_os = "windows")] - let platform = "windows"; - #[cfg(target_os = "linux")] - let platform = "linux"; +pub(crate) fn resolve_pi_binary(app: &tauri::AppHandle) -> Result { + let platform = match std::env::consts::OS { + "macos" => "darwin", + "windows" => "windows", + "linux" => "linux", + other => return Err(format!("Pi runtime is not bundled for {other}")), + }; let arch = match std::env::consts::ARCH { "aarch64" => "arm64", "x86_64" => "x64", - other => other, + other => return Err(format!("Pi runtime is not bundled for {platform}-{other}")), }; + let target = format!("{platform}-{arch}"); + let executable = if cfg!(windows) { "pi.exe" } else { "pi" }; + let mut roots = Vec::new(); + if let Ok(resources) = app.path().resource_dir() { - let name = if cfg!(windows) { "pi.exe" } else { "pi" }; - let bundled_root = resources.join("pi").join(format!("{platform}-{arch}")); - let bundled_candidates = [ - bundled_root.join("dist").join(name), - bundled_root.join(name), - ]; - if let Some(bundled) = bundled_candidates - .into_iter() - .find(|candidate| candidate.is_file()) - { - return Ok(bundled); + roots.push(resources.join("pi").join(&target)); + } + #[cfg(debug_assertions)] + roots.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.build/pi-runtime/resources") + .join(&target), + ); + + for root in roots { + for candidate in [root.join("dist").join(executable), root.join(executable)] { + if crate::agent_external::cli_resolver::is_executable_file(&candidate) { + return Ok(candidate); + } } } - if let Some(path) = crate::agent_external::cli_resolver::which_in_path( - if cfg!(windows) { "pi.exe" } else { "pi" }, - std::env::var_os("PATH").as_deref(), - ) { - return Ok(path); - } - if let Some(path) = installed_flowix_pi_binary() { - return Ok(path); - } - Ok( - crate::agent_external::cli_resolver::resolve_external_cli_uncached( - &crate::agent_external::cli_resolver::ExternalCliSpec { - binary_name: "pi", - #[cfg(windows)] - windows_binary_name: "pi.exe", - env_vars: &["PI_CLI_PATH"], - extra_unix_candidates: crate::agent_external::cli_resolver::no_extra_candidates, - #[cfg(windows)] - extra_windows_candidates: crate::agent_external::cli_resolver::no_extra_candidates, - }, - ), - ) + + Err(format!( + "Bundled Pi runtime is missing or not executable for {target}" + )) } #[cfg(test)] mod tests { use super::{ attach_pi_session_message_ids, pi_active_branch_entries, pi_rpc_message_id, - receive_rpc_response, + receive_rpc_response, run_startup_cloud_credential_refresh_once, + run_startup_cloud_refresh_once, }; use serde_json::json; + #[tokio::test] + async fn startup_cloud_refresh_shares_the_actual_failure_per_generation() { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::sync::Mutex; + + let results = Arc::new(Mutex::new(HashMap::new())); + let refresh_count = Arc::new(AtomicUsize::new(0)); + let run = |results: Arc>>>, + refresh_count: Arc| async move { + run_startup_cloud_refresh_once(&results, 7, || async move { + refresh_count.fetch_add(1, Ordering::SeqCst); + tokio::task::yield_now().await; + Err("credential refresh failed".to_string()) + }) + .await + }; + + let (first, concurrent) = tokio::join!( + run(results.clone(), refresh_count.clone()), + run(results.clone(), refresh_count.clone()), + ); + assert_eq!(first, Err("credential refresh failed".to_string())); + assert_eq!(concurrent, Err("credential refresh failed".to_string())); + assert_eq!(refresh_count.load(Ordering::SeqCst), 1); + + let cached = run_startup_cloud_refresh_once(&results, 7, || async { Ok(true) }).await; + assert_eq!(cached, Err("credential refresh failed".to_string())); + assert_eq!(refresh_count.load(Ordering::SeqCst), 1); + + let next_generation = + run_startup_cloud_refresh_once(&results, 8, || async { Ok(true) }).await; + assert_eq!(next_generation, Ok(true)); + } + + #[tokio::test] + async fn startup_cloud_refresh_fetches_latest_catalog_once_even_if_credential_is_cached() { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::sync::Mutex; + + let results = Mutex::new(HashMap::new()); + let credential_generation = Mutex::new(Some(7)); + let refresh_count = Arc::new(AtomicUsize::new(0)); + let count = refresh_count.clone(); + let refreshed = run_startup_cloud_credential_refresh_once( + &results, + &credential_generation, + 7, + || async move { + count.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert_eq!(refreshed, Ok(true)); + assert_eq!(refresh_count.load(Ordering::SeqCst), 1); + + let count = refresh_count.clone(); + let coalesced = run_startup_cloud_credential_refresh_once( + &results, + &credential_generation, + 7, + || async move { + count.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert_eq!(coalesced, Ok(true)); + assert_eq!(refresh_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn stderr_is_visible_before_eof_and_keeps_only_a_bounded_tail() { + use std::sync::Arc; + use tokio::io::AsyncWriteExt; + use tokio::sync::Mutex; + let (mut writer, reader) = tokio::io::duplex(4096); + let captured = Arc::new(Mutex::new(String::new())); + let task = tokio::spawn(super::capture_pi_stderr(reader, captured.clone())); + writer.write_all(b"startup diagnostic").await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if captured.lock().await.contains("startup diagnostic") { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!( + !task.is_finished(), + "diagnostics must be visible before EOF" + ); + writer + .write_all(&vec![b'x'; super::PI_STDERR_TAIL_BYTES * 2]) + .await + .unwrap(); + writer.write_all("最新错误".as_bytes()).await.unwrap(); + drop(writer); + task.await.unwrap().unwrap(); + let text = captured.lock().await; + assert!(text.ends_with("最新错误")); + assert!(text.len() <= super::PI_STDERR_TAIL_BYTES); + assert!(!text.contains("startup diagnostic")); + } #[tokio::test] async fn pi_queue_rebuild_keeps_duplicate_message_attachments_with_the_right_item() { use super::{known_pi_queue_payloads, pi_queue_target_index, ActivePiRun, PiQueuedMessage}; @@ -3299,6 +3631,7 @@ mod tests { run_id: "test".into(), started_at: 0, session: Mutex::new(None), + session_state: tokio::sync::watch::channel(None).0, session_id: Mutex::new(None), queued_messages: Mutex::new(HashMap::from([ ("first".into(), first.clone()), @@ -3523,66 +3856,3 @@ mod tests { assert!(error.contains("could not be reconciled with native ids")); } } - -/// Find Pi shipped inside a regular Flowix installation. Dev launches use -/// this when Tauri does not preserve the `PI_CLI_PATH` set by tauri-dev.mjs. -pub fn installed_flowix_pi_binary() -> Option { - #[cfg(target_os = "macos")] - let platform = "darwin"; - #[cfg(target_os = "windows")] - let platform = "windows"; - #[cfg(target_os = "linux")] - let platform = "linux"; - let arch = match std::env::consts::ARCH { - "aarch64" => "arm64", - "x86_64" => "x64", - other => other, - }; - let executable = if cfg!(windows) { "pi.exe" } else { "pi" }; - let mut app_roots = Vec::new(); - if let Some(root) = std::env::var_os("FLOWIX_PROD_APP_PATH") { - app_roots.push(PathBuf::from(root)); - } - #[cfg(target_os = "macos")] - { - app_roots.push(PathBuf::from("/Applications/Flowix.app")); - if let Some(home) = dirs::home_dir() { - app_roots.push(home.join("Applications").join("Flowix.app")); - } - } - #[cfg(target_os = "windows")] - { - if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { - app_roots.push( - PathBuf::from(local_app_data) - .join("Programs") - .join("Flowix"), - ); - } - if let Some(program_files) = std::env::var_os("ProgramFiles") { - app_roots.push(PathBuf::from(program_files).join("Flowix")); - } - } - #[cfg(target_os = "linux")] - { - app_roots.push(PathBuf::from("/opt/Flowix")); - app_roots.push(PathBuf::from("/usr/lib/flowix")); - app_roots.push(PathBuf::from("/usr/lib/Flowix")); - } - - for app_root in app_roots { - #[cfg(target_os = "macos")] - let resource_roots = vec![app_root.join("Contents").join("Resources")]; - #[cfg(not(target_os = "macos"))] - let resource_roots = vec![app_root.join("resources"), app_root]; - for resource_root in resource_roots { - let root = resource_root.join("pi").join(format!("{platform}-{arch}")); - for candidate in [root.join("dist").join(executable), root.join(executable)] { - if crate::agent_external::cli_resolver::is_executable_file(&candidate) { - return Some(candidate); - } - } - } - } - None -} diff --git a/app/flowix-desktop/src/agent_external/runtime_registry.rs b/app/flowix-desktop/src/agent_external/runtime_registry.rs index b3f36fd39..bf0943227 100644 --- a/app/flowix-desktop/src/agent_external/runtime_registry.rs +++ b/app/flowix-desktop/src/agent_external/runtime_registry.rs @@ -45,7 +45,7 @@ impl ExternalRuntimeKind { Self::DeepSeekHarness, ]; - pub const fn key(self) -> &'static str { + pub const fn id(self) -> &'static str { match self { Self::Codex => CODEX_AGENT_TYPE, Self::Claude => CLAUDE_AGENT_TYPE, @@ -59,11 +59,11 @@ impl ExternalRuntimeKind { pub fn parse(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "codex" => Ok(Self::Codex), - "claude" => Ok(Self::Claude), + "claudecode" | "claude" => Ok(Self::Claude), "hermes" => Ok(Self::Hermes), "opencode" => Ok(Self::OpenCode), "pi" => Ok(Self::Pi), - "deepseek-harness" | "deepseek_harness" | "dsh" => Ok(Self::DeepSeekHarness), + "dsh" | "deepseek-harness" | "deepseek_harness" => Ok(Self::DeepSeekHarness), other => Err(format!("unsupported agent type: {other}")), } } @@ -73,8 +73,8 @@ impl ExternalRuntimeKind { pub trait ExternalCliRuntime: Send + Sync { fn kind(&self) -> ExternalRuntimeKind; - fn key(&self) -> &'static str { - self.kind().key() + fn id(&self) -> &'static str { + self.kind().id() } async fn chat_stream( @@ -669,7 +669,7 @@ impl ExternalRuntimeRegistry { if registry.insert(kind, runtime).is_some() { return Err(format!( "external runtime is registered more than once for {}", - kind.key() + kind.id() )); } } @@ -1052,7 +1052,7 @@ fn merge_run_snapshot(target: &mut HashMap, snapshot: RuntimeRu if snapshot.info.started_at > existing.started_at { tracing::warn!( thread_id = %snapshot.thread_id, - kept_runtime = snapshot.runtime.key(), + kept_runtime = snapshot.runtime.id(), dropped_runtime = existing.agent_type.as_deref().unwrap_or("unknown"), "multiple runtimes reported the same running thread; keeping the newest run" ); @@ -1061,7 +1061,7 @@ fn merge_run_snapshot(target: &mut HashMap, snapshot: RuntimeRu tracing::warn!( thread_id = %snapshot.thread_id, kept_runtime = existing.agent_type.as_deref().unwrap_or("unknown"), - dropped_runtime = snapshot.runtime.key(), + dropped_runtime = snapshot.runtime.id(), "multiple runtimes reported the same running thread; keeping the newest run" ); } @@ -1157,17 +1157,17 @@ mod tests { let keys = registry .iter() - .map(ExternalCliRuntime::key) + .map(|runtime| runtime.id()) .collect::>(); assert_eq!( keys, [ - "codex", - "claude", - "hermes", - "opencode", - "pi", - "deepseek-harness" + CODEX_AGENT_TYPE, + CLAUDE_AGENT_TYPE, + HERMES_AGENT_TYPE, + OPENCODE_AGENT_TYPE, + PI_AGENT_TYPE, + DSH_AGENT_TYPE, ] ); for kind in ExternalRuntimeKind::ALL { diff --git a/app/flowix-desktop/src/agent_external/shared/lifecycle.rs b/app/flowix-desktop/src/agent_external/shared/lifecycle.rs index 0d8517a55..fefa9124e 100644 --- a/app/flowix-desktop/src/agent_external/shared/lifecycle.rs +++ b/app/flowix-desktop/src/agent_external/shared/lifecycle.rs @@ -249,7 +249,7 @@ pub async fn persist_external_chunk_for_thread_with_metadata( // local journal is retained only for Claude. Codex and DSH messages must // come from their provider history APIs; their live chunks are runtime // state only and must never become a second local transcript. - if agent_type != "claude" { + if agent_type != "claudecode" { return; } let payload_json = match chunk_payload_json(chunk, agent_type, run_id, metadata) { diff --git a/app/flowix-desktop/src/agent_external/shared/tests.rs b/app/flowix-desktop/src/agent_external/shared/tests.rs index 340d68a2b..bd48def2b 100644 --- a/app/flowix-desktop/src/agent_external/shared/tests.rs +++ b/app/flowix-desktop/src/agent_external/shared/tests.rs @@ -76,6 +76,31 @@ fn chunk_payload_scopes_codex_fallback_identity_per_run() { assert_ne!(run_one.get("message_id"), run_two.get("message_id")); } +#[test] +fn chunk_payload_preserves_stable_codex_turn_error_identity() { + let turn_error_id = "msg:codex:turn-error:turn-1"; + let chunk = AgentChunk::Error { + thread_id: "thread-1".to_string(), + message: "user_stopped".to_string(), + error_details: None, + }; + let payload = chunk_payload_value( + &chunk, + "codex", + "run-1", + &AgentChunkMetadata { + codex_turn_id: Some("turn-1".to_string()), + message_id: Some(turn_error_id.to_string()), + ..AgentChunkMetadata::default() + }, + ) + .expect("serialize stable turn error"); + + assert_eq!(payload["codex_turn_id"], "turn-1"); + assert_eq!(payload["message_id"], turn_error_id); + assert_eq!(payload["source_message_id"], turn_error_id); +} + #[test] fn pi_deltas_stay_draft_until_the_native_session_entry_id_arrives() { let text = AgentChunk::Text { diff --git a/app/flowix-desktop/src/agent_external_config.rs b/app/flowix-desktop/src/agent_external_config.rs index e6ef3b92b..aa47b924e 100644 --- a/app/flowix-desktop/src/agent_external_config.rs +++ b/app/flowix-desktop/src/agent_external_config.rs @@ -1,5 +1,5 @@ //! External CLI path config ── 持久化在 `~/.flowix/agent-external-config.json`�?//! -//! 作为�?external agent (codex / claude / gemini / hermes / openclaw) 执�?�?�� +//! External CLI paths for supported Agents are stored here. //! �?�?��参照": //! - �?��时探测一�?(`run_startup_detect`), 把命�?�� path 写入文件并灌�?//! `agent_external::cli_resolver::REGISTRY`; 此后运�?�?`resolve_external_cli` //! 命中即用, 不再每条消息跑探测链�?//! - `source = "user"` 的条�?��动探测跳�? 尊重用户在偏好�?�?��手改的路径�?//! - path 失效 (文件�?��) 不自�?fallback ── �?`executable_available` �?false @@ -9,21 +9,56 @@ use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; - +use std::sync::{ + atomic::{AtomicBool, Ordering}, + RwLock, RwLockReadGuard, RwLockWriteGuard, +}; use serde::{Deserialize, Serialize}; +use tokio::sync::Notify; use crate::agent_external::cli_resolver::{ is_executable_file, set_external_cli_registry, update_external_cli_path, }; use crate::agent_external::{claude, codex, hermes, opencode}; -/// 5 �?external agent �?binary_name (= AgentTypeKey = registry key)�?/// 顺序影响偏好设置列表呈现, 不影响逻辑�? -pub const EXTERNAL_AGENT_KEYS: &[&str] = &["codex", "claude", "hermes", "opencode"]; +/// Stable IDs for the external CLI Agents configured by this module. +pub const EXTERNAL_AGENT_KEYS: &[&str] = &["codex", "claudecode", "hermes", "opencode"]; + +fn canonical_external_agent_key(agent_key: &str) -> Option<&'static str> { + match agent_key { + "codex" => Some("codex"), + "claude" | "claudecode" => Some("claudecode"), + "hermes" => Some("hermes"), + "opencode" => Some("opencode"), + _ => None, + } +} + +fn external_binary_name(agent_key: &str) -> &str { + if agent_key == "claudecode" { "claude" } else { agent_key } +} + +fn is_supported_external_agent_key(agent_key: &str) -> bool { + EXTERNAL_AGENT_KEYS.contains(&agent_key) +} pub struct AgentExternalConfig { path: PathBuf, data: RwLock, + startup_detect_complete: AtomicBool, + startup_detect_notify: Notify, +} + +struct StartupDetectCompletion<'a> { + complete: &'a AtomicBool, + notify: &'a Notify, +} + +impl Drop for StartupDetectCompletion<'_> { + fn drop(&mut self) { + self.complete.store(true, Ordering::Release); + self.notify.notify_waiters(); + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -42,6 +77,10 @@ pub struct AgentExternalEntry { /// `auto` = �?��探测写入 (�??下�?�?��覆盖); `user` = 用户手改 (�?��探测跳过)�? #[serde(default = "default_source")] pub source: AgentExternalSource, + /// Whether this path was explicitly configured or discovery has completed. + /// A missing path before that point means unknown, not uninstalled. + #[serde(default)] + pub detected: bool, } fn default_source() -> AgentExternalSource { @@ -66,11 +105,44 @@ impl AgentExternalConfig { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let data = Self::read_from_disk(&path).unwrap_or_default(); - Ok(Self { + let mut data = flowix_core::memo_file::read_or_create_json(&path, AgentExternalConfigFile::default)?; + // Canonicalize the one renamed external Agent ID on first load. If a + // canonical entry already exists, it wins; otherwise carry the user's + // existing path/source/detection state across unchanged. + let migrated_legacy_key = if let Some(legacy) = data.agents.remove("claude") { + match data.agents.remove("claudecode") { + Some(current) => { + let chosen = match (current.source, legacy.source) { + (AgentExternalSource::User, _) => current, + (_, AgentExternalSource::User) => legacy, + _ if current.path.is_some() || current.detected => current, + _ => legacy, + }; + data.agents.insert("claudecode".to_string(), chosen); + } + None => { + data.agents.insert("claudecode".to_string(), legacy); + } + } + true + } else { + false + }; + let original_entry_count = data.agents.len(); + data.agents + .retain(|key, _| is_supported_external_agent_key(key)); + let removed_unsupported_entries = migrated_legacy_key || data.agents.len() != original_entry_count; + let config = Self { path, data: RwLock::new(data), - }) + startup_detect_complete: AtomicBool::new(false), + startup_detect_notify: Notify::new(), + }; + if removed_unsupported_entries { + let sanitized = config.read_data().clone(); + config.flush(&sanitized)?; + } + Ok(config) } pub fn transient(path: PathBuf) -> Self { @@ -81,6 +153,8 @@ impl AgentExternalConfig { Self { path, data: RwLock::new(AgentExternalConfigFile::default()), + startup_detect_complete: AtomicBool::new(false), + startup_detect_notify: Notify::new(), } } @@ -98,22 +172,6 @@ impl AgentExternalConfig { }) } - fn read_from_disk(path: &Path) -> Option { - if !path.exists() { - return None; - } - let content = fs::read_to_string(path).ok()?; - match serde_json::from_str::(&content) { - Ok(data) => Some(data), - Err(e) => { - tracing::warn!( - "agent-external-config.json parse error: {e}, falling back to empty" - ); - None - } - } - } - fn flush(&self, data: &AgentExternalConfigFile) -> std::io::Result<()> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; @@ -138,9 +196,10 @@ impl AgentExternalConfig { /// 读取�?agent 的当前条�?(path + source)�? pub fn get_entry(&self, agent_key: &str) -> AgentExternalEntry { - self.read_data() - .agents - .get(agent_key) + let Some(agent_key) = canonical_external_agent_key(agent_key) else { + return AgentExternalEntry::default(); + }; + self.read_data().agents.get(agent_key) .cloned() .unwrap_or_default() } @@ -156,16 +215,23 @@ impl AgentExternalConfig { agent_key: &str, path: PathBuf, ) -> std::io::Result { + let Some(agent_key) = canonical_external_agent_key(agent_key) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unsupported external Agent: {agent_key}"), + )); + }; let entry = AgentExternalEntry { path: Some(path.clone()), source: AgentExternalSource::User, + detected: true, }; { let mut data = self.write_data(); data.agents.insert(agent_key.to_string(), entry.clone()); self.flush(&data)?; } - update_external_cli_path(agent_key, Some(path)); + update_external_cli_path(external_binary_name(agent_key), Some(path)); Ok(entry) } @@ -174,6 +240,10 @@ impl AgentExternalConfig { /// 此刻注册表尚�?���?(`REGISTRY = None`), �?`resolve_*_binary` 会走原探测链 /// (env > PATH > 鍊欓€?> shell), 涓庢敼閫犲墠琛屼负涓€鑷淬€? pub fn run_startup_detect(&self) { + let _completion = StartupDetectCompletion { + complete: &self.startup_detect_complete, + notify: &self.startup_detect_notify, + }; let started = std::time::Instant::now(); let pending: Vec<&str> = { let data = self.read_data(); @@ -201,6 +271,7 @@ impl AgentExternalConfig { let entry = AgentExternalEntry { path, source: AgentExternalSource::Auto, + detected: true, }; data.agents.insert(key.to_string(), entry); changed = true; @@ -216,13 +287,28 @@ impl AgentExternalConfig { ); } + /// Wait until startup discovery has published all detected CLI paths. + /// Register the notification before checking the flag so completion cannot + /// be missed between the check and the wait. + pub async fn wait_for_startup_detect(&self) { + loop { + let notified = self.startup_detect_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.startup_detect_complete.load(Ordering::Acquire) { + return; + } + notified.await; + } + } + /// 把当�?JSON �?path 内存镜像灌进 `REGISTRY`�? pub fn load_into_registry(&self) { let data = self.read_data(); let mut map: HashMap = HashMap::new(); for &key in EXTERNAL_AGENT_KEYS { if let Some(p) = data.agents.get(key).and_then(|e| e.path.clone()) { - map.insert(key.to_string(), p); + map.insert(external_binary_name(key).to_string(), p); } } set_external_cli_registry(map); @@ -231,8 +317,14 @@ impl AgentExternalConfig { /// 重新探测单个 agent: 清注册表该项 -> 跑探�?-> �?`source = auto` -> /// 更新注册表。返回探测到�?path (`None` = 没探测到)�? pub fn redetect(&self, agent_key: &str) -> std::io::Result> { + let Some(agent_key) = canonical_external_agent_key(agent_key) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unsupported external Agent: {agent_key}"), + )); + }; // Remove the cached path before running the fallback detection chain. - update_external_cli_path(agent_key, None); + update_external_cli_path(external_binary_name(agent_key), None); let detected = detect_external_binary(agent_key); { let mut data = self.write_data(); @@ -241,13 +333,14 @@ impl AgentExternalConfig { AgentExternalEntry { path: detected.clone(), source: AgentExternalSource::Auto, + detected: true, }, ); self.flush(&data)?; } // 探测到才回填注册�? 没探测到保持移除状�?(�?agent �?unavailable)�? if let Some(p) = detected.clone() { - update_external_cli_path(agent_key, Some(p)); + update_external_cli_path(external_binary_name(agent_key), Some(p)); } Ok(detected) } @@ -258,7 +351,7 @@ impl AgentExternalConfig { fn detect_external_binary(agent_key: &str) -> Option { let path = match agent_key { "codex" => codex::resolve_codex_binary(), - "claude" => claude::cli::resolve_claude_binary(), + "claudecode" => claude::cli::resolve_claude_binary(), "hermes" => hermes::cli::resolve_hermes_binary(), "opencode" => opencode::resolve_opencode_binary(), other => { diff --git a/app/flowix-desktop/src/agent_history.rs b/app/flowix-desktop/src/agent_history.rs index bc9c9f2aa..9c71f2cb5 100644 --- a/app/flowix-desktop/src/agent_history.rs +++ b/app/flowix-desktop/src/agent_history.rs @@ -77,7 +77,7 @@ impl AgentHistoryService { if registry.insert(runtime, adapter).is_some() { return Err(format!( "history adapter is registered more than once for {}", - runtime.key() + runtime.id() )); } } @@ -136,13 +136,13 @@ impl AgentHistoryService { let before = page.oldest_sequence.ok_or_else(|| { format!( "{} history reported more pages without a cursor", - runtime.key() + runtime.id() ) })?; if previous_cursor == Some(before) { return Err(format!( "{} history cursor did not advance at {before}", - runtime.key() + runtime.id() )); } previous_cursor = Some(before); @@ -164,7 +164,7 @@ impl AgentHistoryService { fn adapter(&self, runtime: ExternalRuntimeKind) -> Result<&Arc, String> { self.adapters .get(&runtime) - .ok_or_else(|| format!("history adapter is not registered for {}", runtime.key())) + .ok_or_else(|| format!("history adapter is not registered for {}", runtime.id())) } } diff --git a/app/flowix-desktop/src/agent_history/adapters.rs b/app/flowix-desktop/src/agent_history/adapters.rs index 666ff6faa..968452569 100644 --- a/app/flowix-desktop/src/agent_history/adapters.rs +++ b/app/flowix-desktop/src/agent_history/adapters.rs @@ -60,7 +60,7 @@ impl HistoryAdapter for CodexHistoryAdapter { ) -> Result { let session_id = self .threads - .get_external_session(request.thread_id, self.runtime().key()) + .get_external_session(request.thread_id, self.runtime().id()) .await .map_err(|error| error.to_string())? .unwrap_or_else(|| request.thread_id.to_string()); @@ -144,7 +144,7 @@ impl HistoryAdapter for ClaudeHistoryAdapter { } async fn list_threads(&self) -> Result, String> { self.threads - .list_external_threads(self.runtime().key()) + .list_external_threads(self.runtime().id()) .await .map_err(|error| error.to_string()) } @@ -187,7 +187,7 @@ impl HistoryAdapter for HermesHistoryAdapter { } async fn list_threads(&self) -> Result, String> { self.threads - .list_external_threads(self.runtime().key()) + .list_external_threads(self.runtime().id()) .await .map_err(|error| error.to_string()) } @@ -198,7 +198,7 @@ impl HistoryAdapter for HermesHistoryAdapter { if let Some(page) = self .threads .get_external_event_messages_page( - self.runtime().key(), + self.runtime().id(), request.thread_id, request.before_sequence, request.limit, diff --git a/app/flowix-desktop/src/agent_lifecycle.rs b/app/flowix-desktop/src/agent_lifecycle.rs index 3d31bbb8e..d6e08588a 100644 --- a/app/flowix-desktop/src/agent_lifecycle.rs +++ b/app/flowix-desktop/src/agent_lifecycle.rs @@ -87,7 +87,7 @@ impl AgentLifecycleService { if registry.insert(runtime, adapter).is_some() { return Err(format!( "lifecycle adapter is registered more than once for {}", - runtime.key() + runtime.id() )); } } @@ -204,7 +204,7 @@ impl ThreadLifecycleAdapter for CodexLifecycleAdapter { // 3. otherwise the conversation never reached the provider let codex_thread_id = match self .threads - .get_external_session(thread_id, self.runtime().key()) + .get_external_session(thread_id, self.runtime().id()) .await .map_err(|error| error.to_string())? { @@ -273,9 +273,9 @@ mod tests { .apply("claude", "thread-1", LifecycleAction::Delete) .await .unwrap()); - // Unparseable types (local agents) keep local-only semantics too. + // Unsupported types keep local-only semantics too. assert!(!service - .apply("gemini", "thread-1", LifecycleAction::Delete) + .apply("unsupported-agent", "thread-1", LifecycleAction::Delete) .await .unwrap()); // A never-started Codex conversation has no provider thread, so no diff --git a/app/flowix-desktop/src/agent_session/migrations.rs b/app/flowix-desktop/src/agent_session/migrations.rs index f13319575..8b3900c54 100644 --- a/app/flowix-desktop/src/agent_session/migrations.rs +++ b/app/flowix-desktop/src/agent_session/migrations.rs @@ -10,7 +10,7 @@ use rusqlite::{params, Connection}; use super::error::ThreadError; -pub(super) const THREAD_DB_SCHEMA_VERSION: i64 = 13; +pub(super) const THREAD_DB_SCHEMA_VERSION: i64 = 16; pub(super) const WAL_AUTOCHECKPOINT_PAGES: i64 = 4000; pub(super) const WAL_JOURNAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024; @@ -34,6 +34,12 @@ impl super::store::ThreadManager { let current_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; if current_version == THREAD_DB_SCHEMA_VERSION { + // A version marker can survive manual table deletion. Validate only + // the essential schema; this does not scan conversation contents. + conn.execute_batch("SELECT thread_id FROM threads LIMIT 0; + SELECT id, thread_id, content FROM thread_messages LIMIT 0; + SELECT instance_id FROM agent_conversation_instances LIMIT 0; + SELECT id FROM threads_index LIMIT 0;")?; return Ok(()); } if current_version > THREAD_DB_SCHEMA_VERSION { @@ -99,11 +105,7 @@ impl super::store::ThreadManager { source_kind TEXT NOT NULL DEFAULT 'thread-card', source_document_path TEXT, source_relative_path TEXT, - source_memo_id TEXT, source_notebook_id TEXT, - role_memo_id TEXT, - role_address TEXT, - role_name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY(thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE @@ -155,13 +157,9 @@ impl super::store::ThreadManager { // cut-over happens in a later migration once all callers are moved. Self::ensure_simplified_thread_schema(conn)?; Self::ensure_pi_thread_metadata_columns(conn)?; - Self::ensure_path_and_legacy_role_columns(conn)?; - // Role files are no longer part of conversations. Clear values from - // historical columns before marking the schema current. - conn.execute_batch( - "UPDATE agent_conversation_instances SET role_memo_id = NULL, role_address = NULL, role_name = NULL; - UPDATE agent_instances SET role_memo_id = NULL, role_address = NULL, role_name = NULL;", - )?; + Self::ensure_path_columns(conn)?; + Self::migrate_agent_identity_ids(conn)?; + Self::drop_obsolete_note_reference_columns(conn)?; conn.execute_batch( " CREATE INDEX IF NOT EXISTS idx_agent_external_events_thread @@ -178,6 +176,80 @@ impl super::store::ThreadManager { Ok(()) } + /// Note references are addressed by notebook and relative path. Discard + /// retired identity and role-file columns while keeping session data. + fn drop_obsolete_note_reference_columns(conn: &Connection) -> Result<(), ThreadError> { + for (table, column) in [ + ("agent_instances", "memo_id"), + ("agent_instances", "role_memo_id"), + ("agent_instances", "role_address"), + ("agent_instances", "role_name"), + ("agent_conversation_instances", "source_memo_id"), + ("agent_conversation_instances", "role_memo_id"), + ("agent_conversation_instances", "role_address"), + ("agent_conversation_instances", "role_name"), + ] { + let exists: bool = conn.query_row( + &format!("SELECT EXISTS(SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1)"), + [column], + |row| row.get(0), + )?; + if exists { + conn.execute_batch(&format!("ALTER TABLE {table} DROP COLUMN {column}"))?; + } + } + Ok(()) + } + + /// Move persisted provider identities to the stable product IDs. This is + /// deliberately data-only: messages, provider session IDs, and runtime + /// configuration snapshots are left untouched. + fn migrate_agent_identity_ids(conn: &mut Connection) -> Result<(), ThreadError> { + let tx = conn.transaction()?; + tx.execute_batch( + " + UPDATE threads SET agent_id = 'claudecode' WHERE agent_id = 'claude'; + UPDATE threads SET agent_id = 'dsh' WHERE agent_id = 'deepseek-harness'; + + UPDATE agent_conversation_instances SET agent_type = 'claudecode' WHERE agent_type = 'claude'; + UPDATE agent_conversation_instances SET agent_type = 'dsh' WHERE agent_type = 'deepseek-harness'; + + UPDATE agent_instances SET agent = 'claudecode' WHERE agent = 'claude'; + UPDATE agent_instances SET agent = 'dsh' WHERE agent = 'deepseek-harness'; + + -- A canonical row wins if both old and new runtime bindings exist. + DELETE FROM thread_external_sessions + WHERE runtime IN ('claude', 'deepseek-harness') + AND EXISTS ( + SELECT 1 FROM thread_external_sessions current + WHERE (current.thread_id = thread_external_sessions.thread_id + OR current.external_session_id = thread_external_sessions.external_session_id) + AND current.runtime = CASE thread_external_sessions.runtime + WHEN 'claude' THEN 'claudecode' ELSE 'dsh' END + ); + UPDATE thread_external_sessions SET runtime = 'claudecode' WHERE runtime = 'claude'; + UPDATE thread_external_sessions SET runtime = 'dsh' WHERE runtime = 'deepseek-harness'; + + -- Event journals can contain idempotency keys. Remove only exact + -- old/new key collisions before changing the runtime discriminator. + DELETE FROM agent_external_events + WHERE runtime IN ('claude', 'deepseek-harness') + AND event_key IS NOT NULL AND trim(event_key) <> '' + AND EXISTS ( + SELECT 1 FROM agent_external_events current + WHERE current.thread_id = agent_external_events.thread_id + AND current.event_key = agent_external_events.event_key + AND current.runtime = CASE agent_external_events.runtime + WHEN 'claude' THEN 'claudecode' ELSE 'dsh' END + ); + UPDATE agent_external_events SET runtime = 'claudecode' WHERE runtime = 'claude'; + UPDATE agent_external_events SET runtime = 'dsh' WHERE runtime = 'deepseek-harness'; + ", + )?; + tx.commit()?; + Ok(()) + } + /// Additive first phase of the thread-store migration. /// /// The new tables deliberately contain only product identity and external @@ -194,11 +266,7 @@ impl super::store::ThreadManager { source TEXT NOT NULL DEFAULT 'thread-card', document_path TEXT, relative_path TEXT, - memo_id TEXT, notebook_id TEXT, - role_memo_id TEXT, - role_address TEXT, - role_name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -276,13 +344,12 @@ impl super::store::ThreadManager { tx.execute_batch( " INSERT OR IGNORE INTO agent_instances ( - id, agent, config_json, source, document_path, memo_id, - notebook_id, role_memo_id, role_name, created_at, updated_at + id, agent, config_json, source, document_path, + notebook_id, created_at, updated_at ) SELECT instance_id, agent_type, runtime_config, source_kind, - source_document_path, source_memo_id, source_notebook_id, - role_memo_id, role_name, created_at, updated_at + source_document_path, source_notebook_id, created_at, updated_at FROM agent_conversation_instances; -- Preserve legacy product threads that predate an instance row by @@ -487,13 +554,13 @@ impl super::store::ThreadManager { Ok(()) } - fn ensure_path_and_legacy_role_columns(conn: &Connection) -> Result<(), ThreadError> { + fn ensure_path_columns(conn: &Connection) -> Result<(), ThreadError> { for (table, required) in [ ( "agent_conversation_instances", - &["role_address", "source_relative_path"][..], + &["source_relative_path"][..], ), - ("agent_instances", &["role_address", "relative_path"][..]), + ("agent_instances", &["relative_path"][..]), ] { let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; let columns = stmt @@ -565,8 +632,8 @@ impl super::store::ThreadManager { -- The unique index was added after some releases had already -- written more than one instance for the same product thread. -- Pick one canonical row before creating the constraint. Prefer - -- the newest row; on a timestamp tie, retain the row with the - -- richer source linkage, then use instance_id for determinism. + -- the newest row; on a timestamp tie, retain the row with a + -- document path, then use instance_id for determinism. DELETE FROM agent_conversation_instances WHERE thread_id IS NOT NULL AND EXISTS ( @@ -578,15 +645,9 @@ impl super::store::ThreadManager { OR ( newer.updated_at = agent_conversation_instances.updated_at AND ( - (newer.source_memo_id IS NOT NULL AND agent_conversation_instances.source_memo_id IS NULL) + (newer.source_document_path IS NOT NULL AND agent_conversation_instances.source_document_path IS NULL) OR ( - (newer.source_memo_id IS NOT NULL) = (agent_conversation_instances.source_memo_id IS NOT NULL) - AND newer.source_document_path IS NOT NULL - AND agent_conversation_instances.source_document_path IS NULL - ) - OR ( - (newer.source_memo_id IS NOT NULL) = (agent_conversation_instances.source_memo_id IS NOT NULL) - AND (newer.source_document_path IS NOT NULL) = (agent_conversation_instances.source_document_path IS NOT NULL) + (newer.source_document_path IS NOT NULL) = (agent_conversation_instances.source_document_path IS NOT NULL) AND newer.instance_id > agent_conversation_instances.instance_id ) ) @@ -689,10 +750,7 @@ impl super::store::ThreadManager { frozen_cwd TEXT, source_kind TEXT NOT NULL DEFAULT 'thread-card', source_document_path TEXT, - source_memo_id TEXT, source_notebook_id TEXT, - role_memo_id TEXT, - role_name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY(thread_id) REFERENCES threads(thread_id) ON DELETE CASCADE @@ -700,13 +758,13 @@ impl super::store::ThreadManager { INSERT INTO agent_conversation_instances ( instance_id, agent_type, thread_id, runtime_config, frozen_cwd, - source_kind, source_document_path, source_memo_id, source_notebook_id, - role_memo_id, role_name, created_at, updated_at + source_kind, source_document_path, source_notebook_id, + created_at, updated_at ) SELECT instance_id, agent_type, thread_id, runtime_config, frozen_cwd, - source_kind, source_document_path, source_memo_id, source_notebook_id, - role_memo_id, role_name, created_at, updated_at + source_kind, source_document_path, source_notebook_id, + created_at, updated_at FROM agent_conversation_instances_legacy; DROP TABLE agent_conversation_instances_legacy; @@ -999,7 +1057,6 @@ impl super::store::ThreadManager { LIMIT 1 ), i.thread_id) AS canonical_thread_id, i.updated_at, - i.source_memo_id, i.source_document_path FROM agent_conversation_instances i WHERE i.thread_id IS NOT NULL; @@ -1015,21 +1072,14 @@ impl super::store::ThreadManager { OR ( newer.updated_at = stale.updated_at AND ( - (newer.source_memo_id IS NOT NULL AND stale.source_memo_id IS NULL) - OR ( - (newer.source_memo_id IS NOT NULL) = (stale.source_memo_id IS NOT NULL) - AND newer.source_document_path IS NOT NULL - AND stale.source_document_path IS NULL - ) + (newer.source_document_path IS NOT NULL AND stale.source_document_path IS NULL) OR ( - (newer.source_memo_id IS NOT NULL) = (stale.source_memo_id IS NOT NULL) - AND (newer.source_document_path IS NOT NULL) = (stale.source_document_path IS NOT NULL) + (newer.source_document_path IS NOT NULL) = (stale.source_document_path IS NOT NULL) AND newer.instance_id > stale.instance_id ) - ) ) ) - ); + )); UPDATE agent_conversation_instances SET thread_id = ( diff --git a/app/flowix-desktop/src/agent_session/store.rs b/app/flowix-desktop/src/agent_session/store.rs index aa0e46f78..9a78d4fb2 100644 --- a/app/flowix-desktop/src/agent_session/store.rs +++ b/app/flowix-desktop/src/agent_session/store.rs @@ -22,11 +22,12 @@ use super::error::ThreadError; pub struct ThreadManager { conn: Mutex, + db_path: Option, } fn external_default_title(runtime: &str) -> &'static str { match runtime { - "claude" => "Claude Code session", + "claudecode" => "Claude Code session", "hermes" => "Hermes session", "opencode" => "OpenCode session", _ => "Codex session", @@ -42,18 +43,47 @@ impl ThreadManager { } pub fn new(db_path: PathBuf) -> Result { - let mut conn = Connection::open(db_path)?; - Self::run_migrations(&mut conn)?; + let manager = Self::new_uninitialized(db_path)?; + manager.initialize_storage()?; + Ok(manager) + } + + /// Defer disk access and schema initialization to the startup worker. + pub fn new_uninitialized(db_path: PathBuf) -> Result { + // Disk initialization belongs to the startup worker, including repair. + let conn = Connection::open_in_memory()?; Ok(Self { conn: Mutex::new(conn), + db_path: Some(db_path), }) } + pub fn initialize_storage(&self) -> Result<(), ThreadError> { + let mut connection = self.lock_conn(); + let Some(path) = self.db_path.as_ref() else { + return Self::run_migrations(&mut connection); + }; + // Always reopen the disk path on retry. A failed attempt must never + // turn the temporary connection into a successful in-memory store. + let old = std::mem::replace(&mut *connection, Connection::open_in_memory()?); + drop(old); + let initialized = flowix_core::memo_file::with_database_rebuild(path, || { + let mut conn = Connection::open(path).map_err(std::io::Error::other)?; + // A newer schema may contain user messages this binary cannot read. + // Unsupported versions are not corruption and must not be rebuilt. + Self::run_migrations(&mut conn).map_err(std::io::Error::other)?; + Ok(conn) + }).map_err(|error| ThreadError::Join(format!("initialize thread database: {error}")))?; + *connection = initialized; + Ok(()) + } + pub fn new_in_memory() -> Result { let mut conn = Connection::open_in_memory()?; Self::run_migrations(&mut conn)?; Ok(Self { conn: Mutex::new(conn), + db_path: None, }) } @@ -163,3 +193,40 @@ impl ThreadManager { Ok(()) } } + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + + #[test] + fn invalid_thread_database_is_backed_up_and_recreated() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("thread.db"); + std::fs::write(&path, b"invalid thread database").unwrap(); + let manager = ThreadManager::new(path).unwrap(); + manager.initialize_storage().unwrap(); + let count: i64 = manager + .lock_conn() + .query_row("SELECT COUNT(*) FROM threads", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + assert!(temp.path().join("rebuild-backups").exists()); + } + + #[test] + fn completed_schema_marker_does_not_hide_missing_tables() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("thread.db"); + let manager = ThreadManager::new(path).unwrap(); + manager + .lock_conn() + .execute_batch("PRAGMA foreign_keys=OFF; DROP TABLE thread_messages;") + .unwrap(); + manager.initialize_storage().unwrap(); + manager + .lock_conn() + .execute_batch("SELECT id FROM thread_messages LIMIT 0;") + .unwrap(); + assert!(temp.path().join("rebuild-backups").exists()); + } +} diff --git a/app/flowix-desktop/src/agent_session/store/conversations.rs b/app/flowix-desktop/src/agent_session/store/conversations.rs index 0987b0639..4fef75057 100644 --- a/app/flowix-desktop/src/agent_session/store/conversations.rs +++ b/app/flowix-desktop/src/agent_session/store/conversations.rs @@ -3,8 +3,8 @@ use super::ThreadManager; use crate::agent_session::error::ThreadError; use crate::agent_session::types::{ - AgentConversationCursor, AgentConversationInstance, - AgentConversationSource, AgentConversationTypeCount, UpsertAgentConversationInstance, + AgentConversationCursor, AgentConversationInstance, AgentConversationSource, + AgentConversationTypeCount, UpsertAgentConversationInstance, }; use rusqlite::{params, OptionalExtension}; use std::path::PathBuf; @@ -41,21 +41,44 @@ impl ThreadManager { let new_relative = new_relative.replace('\\', "/"); let old_absolute = old_absolute.replace('\\', "/"); let new_absolute = new_absolute.replace('\\', "/"); - if old_notebook_id == new_notebook_id && old_relative == new_relative && old_absolute == new_absolute { return Ok(()); } + if old_notebook_id == new_notebook_id + && old_relative == new_relative + && old_absolute == new_absolute + { + return Ok(()); + } let mut conn = self.lock_conn(); let tx = conn.transaction()?; for (table, notebook_col, relative_col, document_col) in [ - ("agent_instances", "notebook_id", "relative_path", "document_path"), - ("agent_conversation_instances", "source_notebook_id", "source_relative_path", "source_document_path"), + ( + "agent_instances", + "notebook_id", + "relative_path", + "document_path", + ), + ( + "agent_conversation_instances", + "source_notebook_id", + "source_relative_path", + "source_document_path", + ), ] { tx.execute( - &format!("UPDATE {table} SET + &format!( + "UPDATE {table} SET {notebook_col} = ?5, {relative_col} = ?1 || substr({relative_col}, length(?2) + 1), {document_col} = ?3 || substr({relative_col}, length(?2) + 1) WHERE {notebook_col} = ?4 AND - ({relative_col} = ?2 OR substr({relative_col}, 1, length(?2) + 1) = ?2 || '/')"), - params![new_relative, old_relative, new_absolute, old_notebook_id, new_notebook_id], + ({relative_col} = ?2 OR substr({relative_col}, 1, length(?2) + 1) = ?2 || '/')" + ), + params![ + new_relative, + old_relative, + new_absolute, + old_notebook_id, + new_notebook_id + ], )?; tx.execute( &format!("UPDATE {table} SET @@ -103,15 +126,15 @@ impl ThreadManager { let mut stmt = conn.prepare( "SELECT i.id, i.agent, ti.title, ti.id, i.config_json, - legacy.frozen_cwd, i.source, i.document_path, i.memo_id, - NULL, NULL, i.created_at, i.updated_at, i.notebook_id, + legacy.frozen_cwd, i.source, i.document_path, + i.created_at, i.updated_at, i.notebook_id, COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -187,15 +210,15 @@ impl ThreadManager { let mut stmt = conn.prepare( "SELECT i.id, i.agent, ti.title, ti.id, i.config_json, - legacy.frozen_cwd, i.source, i.document_path, i.memo_id, - NULL, NULL, i.created_at, i.updated_at, i.notebook_id, + legacy.frozen_cwd, i.source, i.document_path, + i.created_at, i.updated_at, i.notebook_id, COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -270,15 +293,15 @@ impl ThreadManager { conn.query_row( "SELECT i.id, i.agent, ti.title, ti.id, i.config_json, - legacy.frozen_cwd, i.source, i.document_path, i.memo_id, - NULL, NULL, i.created_at, i.updated_at, i.notebook_id, + legacy.frozen_cwd, i.source, i.document_path, + i.created_at, i.updated_at, i.notebook_id, COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -312,15 +335,15 @@ impl ThreadManager { conn.query_row( "SELECT i.id, i.agent, ti.title, ti.id, i.config_json, - legacy.frozen_cwd, i.source, i.document_path, i.memo_id, - NULL, NULL, i.created_at, i.updated_at, i.notebook_id, + legacy.frozen_cwd, i.source, i.document_path, + i.created_at, i.updated_at, i.notebook_id, COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -406,16 +429,15 @@ impl ThreadManager { tx.execute( "INSERT INTO agent_conversation_instances ( instance_id, agent_type, thread_id, - runtime_config, source_kind, source_document_path, source_memo_id, source_notebook_id, source_relative_path, + runtime_config, source_kind, source_document_path, source_notebook_id, source_relative_path, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(instance_id) DO UPDATE SET agent_type = excluded.agent_type, thread_id = excluded.thread_id, runtime_config = excluded.runtime_config, source_kind = excluded.source_kind, source_document_path = excluded.source_document_path, - source_memo_id = excluded.source_memo_id, source_notebook_id = excluded.source_notebook_id, source_relative_path = excluded.source_relative_path, updated_at = excluded.updated_at @@ -427,7 +449,6 @@ impl ThreadManager { runtime_config, source_kind, input.source.document_path, - Option::::None, input.source.notebook_id, input.source.relative_path, created_at, @@ -441,15 +462,14 @@ impl ThreadManager { // repository methods. tx.execute( "INSERT INTO agent_instances ( - id, agent, config_json, source, document_path, memo_id, + id, agent, config_json, source, document_path, notebook_id, relative_path, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET agent = excluded.agent, config_json = excluded.config_json, source = excluded.source, document_path = excluded.document_path, - memo_id = excluded.memo_id, notebook_id = excluded.notebook_id, relative_path = excluded.relative_path, updated_at = excluded.updated_at @@ -460,7 +480,6 @@ impl ThreadManager { runtime_config, source_kind, input.source.document_path, - Option::::None, input.source.notebook_id, input.source.relative_path, created_at, @@ -523,15 +542,15 @@ impl ThreadManager { .query_row( "SELECT i.id, i.agent, ti.title, ti.id, i.config_json, - legacy.frozen_cwd, i.source, i.document_path, i.memo_id, - NULL, NULL, i.created_at, i.updated_at, i.notebook_id, + legacy.frozen_cwd, i.source, i.document_path, + i.created_at, i.updated_at, i.notebook_id, COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -899,20 +918,20 @@ impl ThreadManager { let source = AgentConversationSource { kind: row.get(6)?, document_path: row.get(7)?, - relative_path: row.get(15)?, - notebook_id: row.get(13)?, + relative_path: row.get(12)?, + notebook_id: row.get(10)?, }; Ok(AgentConversationInstance { instance_id: row.get(0)?, agent_type: row.get(1)?, thread_title: row.get(2)?, thread_id: row.get(3)?, - session_id: row.get(14)?, + session_id: row.get(11)?, runtime_config: row.get(4)?, frozen_cwd: row.get(5)?, source, - created_at: row.get(11)?, - updated_at: row.get(12)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, }) } } diff --git a/app/flowix-desktop/src/agent_session/store/external.rs b/app/flowix-desktop/src/agent_session/store/external.rs index afdef4fc0..deeb44075 100644 --- a/app/flowix-desktop/src/agent_session/store/external.rs +++ b/app/flowix-desktop/src/agent_session/store/external.rs @@ -11,8 +11,8 @@ use crate::agent_types::AgentId; use rusqlite::{params, Connection, OptionalExtension}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; -use std::sync::Arc; use std::path::PathBuf; +use std::sync::Arc; const MAX_EXTERNAL_EVENTS_PER_THREAD: i64 = 10_000; const EXTERNAL_HISTORY_TRUNCATED_JSON: &str = r#"{"kind":"history_truncated","version":1}"#; @@ -488,11 +488,11 @@ impl ThreadManager { ) -> Result, ThreadError> { let thread_id = thread_id.to_string(); self.run_blocking(move |tm| { - if !tm.external_event_history_exists_inner("claude", &thread_id)? { + if !tm.external_event_history_exists_inner("claudecode", &thread_id)? { return Ok(None); } tm.get_external_event_messages_page_inner( - "claude", + "claudecode", &thread_id, before_event_id, turn_limit, @@ -649,7 +649,7 @@ impl ThreadManager { let messages = materialize_external_messages(events); Ok(ThreadMessagesPage { - messages: if runtime == "deepseek-harness" { + messages: if runtime == "dsh" { normalize_legacy_deepseek_thinking(messages) } else { messages @@ -747,7 +747,7 @@ pub(crate) fn materialize_external_messages(events: Vec) -> }; let mut message_id = external_run_scoped_id(&event.runtime, &payload, role, &message_id); - if event.runtime == "deepseek-harness" { + if event.runtime == "dsh" { if let Some(run_key) = external_event_run_key(&event.runtime, &payload) { let segment_key = format!("{run_key}:{message_id}"); let current_id = dsh_current_assistant_ids @@ -804,7 +804,7 @@ pub(crate) fn materialize_external_messages(events: Vec) -> messages.push(message); } Some("tool_call") => { - if event.runtime == "deepseek-harness" { + if event.runtime == "dsh" { if let Some(run_key) = external_event_run_key(&event.runtime, &payload) { dsh_pending_assistant_segment.insert(run_key); } @@ -854,7 +854,7 @@ pub(crate) fn materialize_external_messages(events: Vec) -> } } Some("tool_result") => { - if event.runtime == "deepseek-harness" { + if event.runtime == "dsh" { if let Some(run_key) = external_event_run_key(&event.runtime, &payload) { dsh_pending_assistant_segment.insert(run_key); } diff --git a/app/flowix-desktop/src/agent_session/store/messages.rs b/app/flowix-desktop/src/agent_session/store/messages.rs index 0a7636c08..8b7f9fff8 100644 --- a/app/flowix-desktop/src/agent_session/store/messages.rs +++ b/app/flowix-desktop/src/agent_session/store/messages.rs @@ -204,8 +204,8 @@ impl ThreadManager { /// Startup cleanup: reset all `is_loading = 1` rows, regardless of role. /// /// This handles crashes after a tool_use row was persisted but before its - /// tool_result arrived. The synchronous version is intentional: startup calls - /// this before the Tauri runtime is available, and the work is a single SQLite UPDATE. + /// tool_result arrived. Startup calls this before publishing native ready, + /// so it cannot clear loading rows created by a new interactive run. /// Returns the affected row count for startup logging. pub fn clear_all_loading(&self) -> Result { let conn = self.lock_conn(); diff --git a/app/flowix-desktop/src/agent_session/store/provider.rs b/app/flowix-desktop/src/agent_session/store/provider.rs index 3f9c41152..418d7a011 100644 --- a/app/flowix-desktop/src/agent_session/store/provider.rs +++ b/app/flowix-desktop/src/agent_session/store/provider.rs @@ -25,10 +25,10 @@ impl ProviderThreadStore { pub(super) fn for_runtime(runtime: &str) -> Option { match runtime { "codex" => Some(Self::Codex), - "deepseek-harness" => Some(Self::Dsh), + "dsh" => Some(Self::Dsh), "opencode" => Some(Self::OpenCode), "hermes" => Some(Self::Hermes), - "claude" => Some(Self::Claude), + "claudecode" => Some(Self::Claude), "pi" => Some(Self::Pi), _ => None, } diff --git a/app/flowix-desktop/src/agent_session/tests.rs b/app/flowix-desktop/src/agent_session/tests.rs index 88c2d2a43..b11d9e17a 100644 --- a/app/flowix-desktop/src/agent_session/tests.rs +++ b/app/flowix-desktop/src/agent_session/tests.rs @@ -308,20 +308,20 @@ mod tests { let manager = ThreadManager::for_tests(); let first = manager .ensure_thread( - "gemini-local-1", - AgentId("gemini".to_string()), + "codex-local-1", + AgentId("codex".to_string()), "first title".to_string(), ) .await .unwrap(); - assert_eq!(first.thread_id, "gemini-local-1"); - assert_eq!(first.agent_id.0, "gemini"); + assert_eq!(first.thread_id, "codex-local-1"); + assert_eq!(first.agent_id.0, "codex"); assert_eq!(first.title, "first title"); let second = manager .ensure_thread( - "gemini-local-1", - AgentId("gemini".to_string()), + "codex-local-1", + AgentId("codex".to_string()), "second title".to_string(), ) .await @@ -550,14 +550,14 @@ mod tests { Some("Notes/Renamed.md") ); let conn = manager.lock_conn(); - let source_id: Option = conn - .query_row( - "SELECT source_memo_id FROM agent_conversation_instances WHERE instance_id = ?1", - ["path-role"], - |row| row.get(0), - ) + let columns = conn + .prepare("PRAGMA table_info(agent_conversation_instances)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() .unwrap(); - assert!(source_id.is_none()); + assert!(!columns.iter().any(|column| column == "source_memo_id")); } #[tokio::test] @@ -1368,31 +1368,38 @@ mod tests { } #[test] - fn migration_removes_persisted_role_file_selection() { + fn migration_drops_retired_role_note_columns() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("thread.db"); drop(ThreadManager::new(db_path.clone()).expect("create database")); let conn = rusqlite::Connection::open(&db_path).expect("open database"); conn.execute_batch( - "INSERT INTO agent_conversation_instances - (instance_id, agent_type, source_kind, role_memo_id, role_address, role_name, created_at, updated_at) - VALUES ('legacy-role', 'codex', 'dedicated', 'old-id', 'nb::Role.md', 'Role', 1, 1); + "ALTER TABLE agent_conversation_instances ADD COLUMN role_address TEXT; + ALTER TABLE agent_conversation_instances ADD COLUMN role_name TEXT; + ALTER TABLE agent_instances ADD COLUMN role_address TEXT; + ALTER TABLE agent_instances ADD COLUMN role_name TEXT; + INSERT INTO agent_conversation_instances + (instance_id, agent_type, source_kind, role_address, role_name, created_at, updated_at) + VALUES ('legacy-role', 'codex', 'dedicated', 'nb::Role.md', 'Role', 1, 1); INSERT INTO agent_instances - (id, agent, source, role_memo_id, role_address, role_name, created_at, updated_at) - VALUES ('legacy-role', 'codex', 'dedicated', 'old-id', 'nb::Role.md', 'Role', 1, 1); - PRAGMA user_version = 10;", + (id, agent, source, role_address, role_name, created_at, updated_at) + VALUES ('legacy-role', 'codex', 'dedicated', 'nb::Role.md', 'Role', 1, 1); + PRAGMA user_version = 15;", ).expect("seed previous role data"); drop(conn); drop(ThreadManager::new(db_path.clone()).expect("migrate role data")); let conn = rusqlite::Connection::open(&db_path).expect("open migrated database"); for table in ["agent_conversation_instances", "agent_instances"] { - let remaining: i64 = conn.query_row( - &format!("SELECT count(*) FROM {table} WHERE role_memo_id IS NOT NULL OR role_address IS NOT NULL OR role_name IS NOT NULL"), - [], - |row| row.get(0), - ).expect("read cleared roles"); - assert_eq!(remaining, 0); + let columns = conn + .prepare(&format!("PRAGMA table_info({table})")) + .expect("inspect migrated columns") + .query_map([], |row| row.get::<_, String>(1)) + .expect("query migrated columns") + .collect::, _>>() + .expect("collect migrated columns"); + assert!(!columns.iter().any(|column| column == "role_address")); + assert!(!columns.iter().any(|column| column == "role_name")); } } @@ -1438,7 +1445,7 @@ mod tests { } #[test] - fn newer_thread_schema_is_rejected() { + fn newer_thread_schema_is_rejected_without_rebuilding_user_data() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("thread.db"); let conn = rusqlite::Connection::open(&db_path).expect("open database"); @@ -1450,17 +1457,11 @@ mod tests { .expect("set future schema version"); drop(conn); - let error = match ThreadManager::new(db_path) { - Ok(_) => panic!("future schema must be rejected"), - Err(error) => error, - }; - assert!(matches!( - error, - crate::agent_session::error::ThreadError::UnsupportedSchemaVersion { - found, - supported - } if found == supported + 1 - )); + assert!(ThreadManager::new(db_path.clone()).is_err()); + assert!(!dir.path().join("rebuild-backups").exists()); + let preserved = rusqlite::Connection::open(db_path).unwrap(); + let version: i64 = preserved.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap(); + assert_eq!(version, crate::agent_session::migrations::THREAD_DB_SCHEMA_VERSION + 1); } #[tokio::test] diff --git a/app/flowix-desktop/src/agent_wire.rs b/app/flowix-desktop/src/agent_wire.rs index 4fa5c89db..f8f27fef7 100644 --- a/app/flowix-desktop/src/agent_wire.rs +++ b/app/flowix-desktop/src/agent_wire.rs @@ -64,6 +64,8 @@ pub struct RuntimePathConfig { #[derive(Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeConfig { + /// Notebook scope used to validate persisted workspace paths before each run. + pub notebook_id: Option, pub codex: Option, pub claude: Option, pub hermes: Option, @@ -165,11 +167,11 @@ impl AgentUserMessage { let config = self.runtime_config.as_ref()?; match runtime { "codex" => config.codex.as_ref(), - "claude" => config.claude.as_ref(), + "claudecode" => config.claude.as_ref(), "hermes" => config.hermes.as_ref(), "opencode" => config.opencode.as_ref(), "pi" => config.pi.as_ref(), - "deepseek-harness" => config.deepseek_harness.as_ref(), + "dsh" => config.deepseek_harness.as_ref(), _ => None, } } diff --git a/app/flowix-desktop/src/app/bootstrap.rs b/app/flowix-desktop/src/app/bootstrap.rs index a7c4f310e..951687be1 100644 --- a/app/flowix-desktop/src/app/bootstrap.rs +++ b/app/flowix-desktop/src/app/bootstrap.rs @@ -14,15 +14,13 @@ use crate::app::watchdog::spawn_external_agent_watchdog; use crate::cli_link; use crate::commands; use crate::config::user as user_config; -use crate::config::AgentAccessStore; -use crate::config::SecurityBookmarkStore; use crate::events as dispatcher; use crate::memo_events::{self, MemoChangeSource, MemoDerivedChanged, MemoEvent}; -use crate::plugin; use crate::runtime_log; use crate::system_data::SystemData; use crate::watcher::MemoWatcher; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; use tauri::{Emitter, Listener, Manager}; @@ -35,20 +33,13 @@ fn record_startup_stage(startup: &crate::app::startup::StartupCoordinator, stage ); } -fn record_slow_notebook_stage(notebook_id: &str, stage: &str, elapsed: Duration) { - if elapsed < Duration::from_millis(250) { - return; - } - runtime_log::record_event( - "info", - "startup.notebook_stage", - serde_json::json!({ - "notebookId": notebook_id, - "stage": stage, - "elapsedMs": elapsed.as_millis(), - }) - .to_string(), - ); +// Only the durable per-version completion record controls the first stage. +// Missing migration journals, old settings, and rebuilt databases must never +// move an initialized installation back into version initialization. +fn version_initialization_pending( + device_registry: &crate::device_registration::DeviceRegistry, +) -> bool { + !device_registry.is_version_initialization_complete() } pub fn run() { @@ -80,13 +71,16 @@ pub fn run() { let user_config_dir = get_user_config_dir(&home_dir); std::fs::create_dir_all(&user_config_dir).ok(); - if let Err(error) = plugin::ensure_builtin_plugins() { - tracing::warn!("[startup] failed to initialize plugins: {error}"); - } - let thread_db_path = user_config_dir.join("thread.db"); + // Only choose the canonical location here. Path migration uses VACUUM INTO + // and runs in the startup worker, where its progress can reach the WebView. + let database_dir = user_config_dir.join("db"); + let thread_db_path = database_dir.join("thread.db"); let user_config = Arc::new(user_config::UserConfigStore::new(home_dir.clone())); + let security_bookmarks = Arc::new(crate::config::SecurityBookmarkStore::new( + user_config_dir.clone(), + )); - // 笔�?�?��册表真源�?~/.flowix/index.db (SQLite); `MemoFile::open_index_db` + // 笔记本注册表真源为 ~/.flowix/db/local.db (SQLite); MemoFile 按需建表 // 首�?�??时建表�?这里不需要任何�?盘迁�?── �?`notebook.json` �?��已废�? let memo_file = flowix_core::memo_file::MemoFile::new(user_config_dir.clone()); @@ -105,7 +99,7 @@ pub fn run() { }; // External CLI 璺緞閰嶇疆 (~/.flowix/agent-external-config.json) 鈹€鈹€ - // 作为 codex/claude/gemini/hermes/openclaw 执�?�?��的唯一参照�? + // Supported external Agents use agent-external-config as their path source. let agent_external_config_path = user_config_dir.join("agent-external-config.json"); let agent_external_config = match AgentExternalConfig::new(agent_external_config_path.clone()) { Ok(store) => store, @@ -120,7 +114,7 @@ pub fn run() { let memo_file_arc = Arc::new(RwLock::new(memo_file)); let notebook_transition = Arc::new(std::sync::Mutex::new(())); - let thread_manager = match ThreadManager::new(thread_db_path.clone()) { + let thread_manager = match ThreadManager::new_uninitialized(thread_db_path.clone()) { Ok(manager) => manager, Err(err) => { tracing::error!( @@ -133,16 +127,11 @@ pub fn run() { } }; let thread_manager_arc = Arc::new(thread_manager); - // Orphaned tool loading rows are cleared after the first workspace paint. + // Orphaned tool loading rows are cleared before native ready is published. let user_config_arc = user_config.clone(); // Agent �??�?���?store ── 必须�?notebook registry �?`memo_file_arc` // 都就�?��后构�?(�?store 会�? notebook registry �?? + 对账)�? - let security_bookmarks_arc = Arc::new(SecurityBookmarkStore::new(user_config_dir.clone())); - let agent_access_arc = Arc::new(AgentAccessStore::new( - user_config_dir.clone(), - &*crate::lock_utils::read_lock(&memo_file_arc, "memo_file"), - )); // 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ // 监听 user-config-changed �?���?whitelist �? 也需�?user_config_arc, @@ -152,9 +141,8 @@ pub fn run() { // 这里把构�?AppState 需要的子结�?clone 出来 (�?�� `move` 捕获), // 同时把另一�?clone 喂给 sub-component 构造函数�? let user_config_for_state = user_config_arc.clone(); + let security_bookmarks_for_state = security_bookmarks.clone(); let memo_file_for_state = memo_file_arc.clone(); - let agent_access_for_state = agent_access_arc.clone(); - let security_bookmarks_for_state = security_bookmarks_arc.clone(); let thread_manager_for_state = thread_manager_arc.clone(); let startup_for_state = startup_coordinator.clone(); let notebook_transition_for_state = notebook_transition.clone(); @@ -213,6 +201,15 @@ pub fn run() { .plugin(crate::frame_scrollbar::init()) .manage(crate::app_update::AppUpdateState::default()) .manage(memo_watcher.clone()) + .on_window_event(commands::window_shutdown::on_window_event) + .on_page_load(|webview, payload| { + #[cfg(windows)] + if payload.event() == tauri::webview::PageLoadEvent::Started { + commands::window_shutdown::clear_native_region(&webview.window()); + } + #[cfg(not(windows))] + let _ = (webview, payload); + }) .on_webview_event(|webview, event| { if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { let state = webview.state::(); @@ -228,24 +225,8 @@ pub fn run() { flowix_installer_helper::listen_for_shutdown(move || shutdown_app.exit(0)); } record_startup_stage(&startup_coordinator, "native-setup-start"); - if let Err(error) = crate::template_store::initialize(&user_config_dir_for_device) { - tracing::warn!("[startup] failed to initialize template directories: {error}"); - } - - // Read the notebook registry synchronously so AppState can be - // created. Structural migrations themselves are scheduled after - // AppState and IPC are available; the startup coordinator is the - // gate for the WebView and notebook-dependent commands. - let initial_notebooks = { - let memo_file = crate::lock_utils::read_lock(&memo_file_arc, "memo_file"); - let notebooks = memo_file.read_notebook_configs()?; - for notebook in ¬ebooks { - security_bookmarks_for_state - .start_accessing_for_path(std::path::Path::new(¬ebook.path)); - } - notebooks - }; - + // Registry import and notebook inspection run in the startup + // initialization worker after AppState exists. // 鈹€鈹€ 0) 鍚姩璁惧鐧昏 / last_seen 鍒锋柊 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ // 不阻�? spawn 一�?fire-and-forget tokio 任务, �?��内部 // �?sleep 10s �?POST, 与产品更�?7s 检查错开。远�?�� @@ -255,8 +236,19 @@ pub fn run() { &user_config_dir_for_device, app_version, )); + if version_initialization_pending(&device_registry) { + startup_coordinator.mark_running( + crate::app::startup::StartupStage::Initialization, + "versionInitialization", + ); + } else { + startup_coordinator.mark_running( + crate::app::startup::StartupStage::WorkspacePreparation, + "preparingData", + ); + } device_registry.clone().spawn_startup_registration(); - app.manage(device_registry); + app.manage(device_registry.clone()); // 鈹€鈹€ 1) 鍚姩鎺㈡祴 external CLI 璺緞 鈹€鈹€ // �?source=auto/缺失�?agent 跑探测链 (env>PATH>候�?shell), @@ -264,6 +256,44 @@ pub fn run() { // can spawn processes, so run it after the first workspace paint. agent_external_config.load_into_registry(); + let is_dev_build = option_env!("FLOWIX_DEV_MODE") == Some("1"); + // A saved Preferences selection is the source of truth. The build + // target only supplies the initial/default environment. + let cloud_environment = if is_dev_build { + let saved_environment = user_config_for_state + .get_preference() + .cloud_environment; + flowix_sync::CloudEnvironment::parse(&saved_environment) + .unwrap_or_else(flowix_sync::default_cloud_environment) + } else { + flowix_sync::CloudEnvironment::Prod + }; + runtime_log::record_event( + "info", + "cloud.environment.selected", + format!( + "environment={}, api_base={}", + cloud_environment.name(), + cloud_environment.api_base() + ), + ); + let cloud_sync_db_path = if is_dev_build { + user_config_dir_for_device + .join("db") + .join(format!("sync-dev-{}.db", cloud_environment.name())) + } else { + user_config_dir_for_device.join("db").join("sync.db") + }; + let cloud_refresh_token_store: Arc = + if is_dev_build { + Arc::new(crate::config::CloudDevRefreshTokenStore::new( + user_config_for_state.clone(), + cloud_environment, + )) + } else { + user_config_for_state.clone() + }; + let cloud_state_app = app.handle().clone(); let cloud_state_notifier: Arc = Arc::new(move |state| { @@ -272,32 +302,40 @@ pub fn run() { } }); let cloud_sync_for_state = Arc::new( - flowix_sync::SyncManager::new_with_session_dependencies( - flowix_sync::DEFAULT_CLOUD_API_BASE, - user_config_dir_for_device.join("sync.db"), - user_config_for_state.clone(), + flowix_sync::SyncManager::new_with_session_dependencies_deferred( + cloud_environment.api_base(), + &cloud_sync_db_path, + cloud_refresh_token_store.clone(), cloud_state_notifier.clone(), ) .unwrap_or_else(|error| { tracing::error!( "failed to initialize cloud sync database: {error}; using a temporary database" ); - flowix_sync::SyncManager::new_with_session_dependencies( - flowix_sync::DEFAULT_CLOUD_API_BASE, + flowix_sync::SyncManager::new_with_session_dependencies_deferred( + cloud_environment.api_base(), std::env::temp_dir().join(format!("flowix-sync-{}.db", std::process::id())), - user_config_for_state.clone(), + cloud_refresh_token_store.clone(), cloud_state_notifier.clone(), ) .expect("failed to initialize temporary cloud sync database") }), ); + crate::dsh::set_user_selected_cli( + user_config_for_state + .get_preference() + .dsh_cli_path + .map(std::path::PathBuf::from), + ); let app_state = AppState { upload_sessions: Default::default(), document_access: Default::default(), + security_bookmarks: security_bookmarks_for_state.clone(), export_access: Default::default(), user_config: user_config_for_state.clone(), cloud_sync: cloud_sync_for_state.clone(), + cloud_environment, system_data, agent_external_config, memo_file: memo_file_for_state.clone(), @@ -309,22 +347,25 @@ pub fn run() { agent_history: agent_history.clone(), agent_lifecycle: agent_lifecycle.clone(), thread_manager: thread_manager_for_state.clone(), - agent_access: agent_access_for_state.clone(), - security_bookmarks: security_bookmarks_for_state.clone(), plugin_runs: crate::plugin::PluginRunCoordinator::default(), notebook_imports: Default::default(), + notebook_background: Default::default(), notebook_template_initializations: Default::default(), notebook_transition: notebook_transition_for_state, startup: startup_for_state, + watcher_retry_stop: Arc::new(AtomicBool::new(false)), }; app_state.upload_sessions.start_cleanup(); app.manage(app_state); - let session_manager = cloud_sync_for_state.clone(); - tauri::async_runtime::spawn(async move { - session_manager.run_session_service().await; - }); external_runtimes.listen_for_terminal_events(app.handle()); + #[cfg(windows)] + { + if let Err(error) = crate::native_close_region::start() { + tracing::warn!("native close input retrying: {error}"); + } + } if let Some(window) = app.get_webview_window("main") { + commands::window_shutdown::install_window(&window); // �?��即�?齐主题背�?��, 消除冷启动白�?(尤其深色主�?)�? let theme = app.state::().user_config.get_preference().theme; crate::window_chrome::apply_window_chrome_theme(&window, theme); @@ -362,18 +403,6 @@ pub fn run() { app.manage(commands::file_browser_watch::FileBrowserWatchState::new( app.handle().clone(), )); - // Restore security-scoped access for user-selected reference - // folders as well. External CLI children inherit the parent's - // active extensions, so this must happen before any agent spawn. - for entry in agent_access_for_state - .get_config() - .entries - .into_iter() - .filter(|entry| entry.enabled && !entry.missing) - { - security_bookmarks_for_state - .start_accessing_for_path(std::path::Path::new(&entry.path)); - } // �?��时把 preference.json::watcher 应用�?MemoWatcher; // 同时监听 user-config-changed,在偏好更新后热加载 watcher 规则。 { @@ -408,16 +437,18 @@ pub fn run() { } register_deep_links(app); - spawn_startup_reconciliation( + spawn_workbench_startup( app.handle().clone(), - initial_notebooks.clone(), memo_file_for_state.clone(), + cloud_sync_for_state.clone(), memo_watcher.clone(), user_config_for_watcher.clone(), startup_coordinator.clone(), external_runtimes.clone(), user_config_dir_for_device.clone(), thread_manager_for_state.clone(), + device_registry, + app.state::().watcher_retry_stop.clone(), ); record_startup_stage(&startup_coordinator, "native-setup-complete"); @@ -430,6 +461,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ // 偏好 (JSON, �?user_config) commands::product::get_product_info, + commands::product::set_cloud_environment, commands::product::open_log_dir, commands::product::open_file_with_default_app, commands::product::reveal_in_file_manager, @@ -465,20 +497,16 @@ pub fn run() { commands::settings::delete_pi_mcp_server, commands::settings::get_pi_features, commands::settings::save_pi_features, + commands::settings::clear_pi_legacy_code_mode, commands::settings::get_pi_model_catalog, commands::settings::save_pi_model_config, commands::settings::delete_pi_model_config, commands::settings::test_pi_model_config, commands::settings::discover_pi_models, commands::dsh::dsh_status, - commands::dsh::dsh_check_update, - commands::dsh::dsh_archive_size, - commands::dsh::dsh_download_status, - commands::dsh::dsh_install_runtime, - commands::dsh::dsh_update, - commands::dsh::dsh_ensure_runtime, - commands::dsh::dsh_cancel_update, - commands::dsh::dsh_uninstall, + commands::dsh::dsh_select_cli_path, + commands::dsh::dsh_set_cli_path, + commands::dsh::dsh_install_appserver_profile, commands::dsh::dsh_manage_profile_plugin, crate::app_update::install_app_update, crate::app_update::check_app_update, @@ -486,8 +514,10 @@ pub fn run() { crate::app_update::cancel_app_update, commands::boot::get_boot_features, commands::boot::set_boot_intro_displayed, - commands::boot::set_boot_onboarding_completed, + commands::boot::get_version_initialization_completed, + commands::boot::set_version_initialization_completed, commands::boot::get_startup_status, + commands::boot::retry_startup, commands::boot::get_startup_notebook_id, commands::boot::notify_startup_interactive, commands::boot::record_startup_stage, @@ -506,8 +536,15 @@ pub fn run() { commands::cloud::cloud_link_notebook, commands::cloud::cloud_set_notebook_enabled, commands::cloud::cloud_refresh_membership, + commands::cloud::cloud_ai_credit_balance, + commands::cloud::cloud_connect_pi_model_provider, + commands::cloud::cloud_refresh_pi_model_provider_at_startup, + commands::cloud::cloud_refresh_pi_model_provider, commands::cloud::cloud_list_products, + commands::cloud::cloud_list_billing_orders, + commands::cloud::cloud_check_billing_order, commands::cloud::cloud_create_checkout, + commands::cloud::cloud_submit_feedback, commands::cloud::cloud_sync_now, commands::cloud::cloud_note_history, commands::cloud::list_local_path_archives, @@ -517,11 +554,11 @@ pub fn run() { commands::cloud::cloud_resolve_markdown_conflict, commands::cloud::cloud_resolve_attachment_conflict, commands::cloud::cloud_restore_note_revision, - // agent 鍙闂洰褰?(JSON, 璧?agent_access) - commands::agent_access::get_agent_access, - commands::agent_access::set_agent_access, - commands::agent_access::get_notebook_agent_configs, - commands::agent_access::set_notebook_agent_config, + // Device-local notebook workspaces (local.db). + commands::agent_workspace::get_agent_workspace, + commands::agent_workspace::get_legacy_agent_preferences, + commands::agent_workspace::attach_notebook_directory, + commands::agent_workspace::detach_notebook_directory, // System metadata (JSON, ~/.flowix/boot/system.json) commands::kv::get_tag_system_metadata, commands::kv::set_tag_system_layout, @@ -552,22 +589,22 @@ pub fn run() { commands::memo::reads::write_document, commands::document_operations::document_operation_status, commands::document_operations::acknowledge_document_operation, - commands::document_shutdown::register_document_window, - commands::document_shutdown::finish_document_shutdown, - commands::document_shutdown::flush_document_background, + commands::window_shutdown::register_document_window, + commands::window_shutdown::finish_document_shutdown, + commands::window_shutdown::finish_document_close, commands::recovery::write_recovery_draft, commands::recovery::read_recovery_draft, commands::recovery::clear_recovery_draft_through, commands::recovery::list_recovery_drafts, commands::external_document::read_external_document, - commands::external_document::get_external_document_mime_type, + commands::external_document::detect_external_document_text, commands::external_document::write_external_document, commands::memo::reads::get_launch_open_files, commands::memo::reads::search_path_notes, commands::memo::creates::add_path_document, commands::memo::creates::list_notebook_templates, commands::memo::creates::initialize_notebook_template, - commands::memo::creates::ensure_notebook_template_setup, + commands::notebook_setup::ensure_notebook_template_setup, commands::memo::creates::import_external_document_by_path, commands::memo::creates::rename_memo_title, commands::memo::creates::move_memo_to_directory, @@ -582,20 +619,26 @@ pub fn run() { commands::memo::deletes::prune_missing_memo, // tag commands::tag::get_all_tags, - commands::tag::create_notebook_tag, commands::tag::move_memo_tag, commands::tag::delete_memo_tag, commands::tag::get_tag_prefix_counts, // notebook commands::notebook::get_notebooks, + commands::notebook::set_notebook_pinned, + commands::notebook::get_notebook_background_status, + commands::notebook::get_notebook_media_maintenance_status, + commands::notebook::request_notebook_media_maintenance, + commands::notebook::request_notebook_refresh, commands::notebook::get_default_notebook_path, commands::notebook::confirm_notebook_preset_overwrite, commands::notebook::ensure_default_notebook_path, commands::notebook::create_notebook, commands::notebook::create_notebook_from_cloud, commands::notebook::start_notebook_import, - commands::notebook::get_notebook_template_setup_status, - commands::notebook::start_notebook_template_setup, + commands::notebook_setup::get_notebook_template_setup_status, + commands::notebook_setup::start_notebook_template_setup, + commands::notebook_setup::start_or_resume_ready_notebook_template_setups, + commands::notebook_setup::get_notebook_template_setup_snapshot, commands::notebook::get_notebook_import_status, commands::notebook::update_notebook, commands::notebook::delete_notebook, @@ -631,6 +674,7 @@ pub fn run() { commands::file::move_file, commands::file::move_folder, commands::file::import_file, + commands::file::folder_import::import_folder, commands::file::rename_folder, commands::file::delete_file, commands::file::delete_folder, @@ -646,6 +690,7 @@ pub fn run() { commands::web::parse_web_page, // dialog commands::dialog::select_directory, + commands::dialog::select_import_directory, commands::dialog::select_files, commands::dialog::save_file_dialog, commands::dialog::write_export_file, @@ -657,7 +702,7 @@ pub fn run() { commands::dialog::upload_sessions::cancel_attachment_upload, commands::dialog::copy_attachment_file, commands::dialog::open_attachment_file, - commands::agent_access::add_agent_access_folder_from_picker, + commands::agent_workspace::pick_notebook_directory, // agent commands::agent::external_config::agent_runtime_status, commands::agent::external_config::get_agent_external_config, @@ -752,6 +797,8 @@ pub fn run() { // window commands::window::show_main_window, commands::window::open_preferences_window, + commands::window_input::set_window_close_region, + commands::window_input::native_close_routing_ready, commands::window::apply_window_theme, commands::window::apply_menu_language, commands::external_document_watch::watch_external_document, @@ -767,302 +814,373 @@ pub fn run() { .run(handle_run_event); } -fn spawn_startup_reconciliation( +fn spawn_workbench_startup( app: tauri::AppHandle, - initial_notebooks: Vec, memo_file: Arc>, + cloud_sync: Arc, memo_watcher: Arc>, user_config_for_watcher: Arc, startup: Arc, external_runtimes: Arc, user_config_dir: PathBuf, thread_manager: Arc, + device_registry: Arc, + watcher_retry_stop: Arc, ) { tauri::async_runtime::spawn_blocking(move || { - let started = Instant::now(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - run_startup_reconciliation( - &app, - &initial_notebooks, - &memo_file, - &memo_watcher, - &user_config_for_watcher, - &startup, - ) - })); - - match result { - Ok(Ok(())) => { - startup.mark_ready(); - record_startup_stage(&startup, "native-ready"); - tracing::info!( - elapsed_ms = started.elapsed().as_millis(), - "[startup] workspace ready" - ); - handle_cold_start_open_targets(&app); - if !startup.wait_until_interactive(Duration::from_secs(120)) { - tracing::warn!( - "[startup] first workspace paint was not reported within 120 seconds" + loop { + let started = Instant::now(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prepare_startup_storage( + &device_registry, + &memo_file, + &thread_manager, + &cloud_sync, + &user_config_for_watcher, + &user_config_dir, + &startup, + )?; + let initial_notebooks = crate::lock_utils::read_lock(&memo_file, "memo_file") + .read_notebook_configs() + .map_err(|error| format!("reload notebook registry failed: {error}"))?; + prepare_notebook_selection(&initial_notebooks, &memo_file, &startup)?; + Ok::<_, String>(initial_notebooks) + })); + + match result { + Ok(Ok(_)) => { + // Clean crash leftovers before any workspace can start a new run. + match thread_manager.clear_all_loading() { + Ok(0) => tracing::debug!("[Startup] no orphan is_loading=1 rows"), + Ok(n) => tracing::info!("[Startup] cleared {n} orphan is_loading=1 rows"), + Err(error) => tracing::warn!("[Startup] clear_all_loading failed: {error}"), + } + startup.mark_ready(); + record_startup_stage(&startup, "native-ready"); + tracing::info!( + elapsed_ms = started.elapsed().as_millis(), + "[startup] workspace ready" ); - } - record_startup_stage(&startup, "background-start"); - let app_for_cli_detection = app.clone(); - tauri::async_runtime::spawn_blocking(move || { - app_for_cli_detection - .state::() - .agent_external_config - .run_startup_detect(); - }); - match thread_manager.clear_all_loading() { - Ok(0) => tracing::debug!("[Startup] no orphan is_loading=1 rows"), - Ok(n) => tracing::info!("[Startup] cleared {n} orphan is_loading=1 rows"), - Err(error) => tracing::warn!("[Startup] clear_all_loading failed: {error}"), - } - crate::maintenance::spawn_startup_maintenance( - app.package_info().version.to_string(), - user_config_dir, - thread_manager, - ); - start_post_startup_services(app.clone(), external_runtimes); - let current_notebook_id = crate::lock_utils::read_lock(&memo_file, "memo_file") - .current_notebook_id_value(); - { - let mut watcher = memo_watcher - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - for notebook in &initial_notebooks { - if current_notebook_id.as_deref() != Some(notebook.id.as_str()) - && !watcher.add_notebook_root(notebook) - { - tracing::warn!(notebook = %notebook.id, "[startup] background notebook watch unavailable"); + handle_cold_start_open_targets(&app); + if !startup.wait_until_interactive(Duration::from_secs(120)) { + tracing::warn!( + "[startup] first workspace paint was not reported within 120 seconds; releasing independent services as a degraded liveness fallback" + ); + record_startup_stage(&startup, "interactive-timeout-degraded-release"); + } + record_startup_stage(&startup, "background-start"); + let app_for_cli_detection = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + app_for_cli_detection + .state::() + .agent_external_config + .run_startup_detect(); + }); + crate::maintenance::spawn_startup_maintenance( + app.package_info().version.to_string(), + user_config_dir, + thread_manager, + ); + // Cloud readiness/credentials are independent of local startup. + let cloud_app = app.clone(); + let session_manager = cloud_sync.clone(); + tauri::async_runtime::spawn(async move { + let mut delay = Duration::from_secs(2); + loop { + let manager = session_manager.clone(); + let initialized = tauri::async_runtime::spawn_blocking(move || { + manager.initialize_storage() + }).await; + match initialized { + Ok(Ok(())) => break, + Ok(Err(error)) => tracing::warn!(%error, retry_in_ms = delay.as_millis(), + "background cloud storage unavailable; retry scheduled"), + Err(error) => tracing::warn!(%error, retry_in_ms = delay.as_millis(), + "background cloud storage worker failed; retry scheduled"), + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(60)); } + if let Ok(state) = session_manager.state() { let _ = cloud_app.emit("cloud-state-changed", state); } + commands::cloud::start_cloud_sync_polling(cloud_app); + tauri::async_runtime::spawn(async move { session_manager.run_session_service().await; }); + }); + if let Err(error) = user_config_for_watcher.clear_legacy_watcher_exclusions() { + tracing::warn!(%error, "background watcher defaults could not be persisted"); } - } - for notebook in &initial_notebooks { - if current_notebook_id.as_deref() != Some(notebook.id.as_str()) { - let _ = reconcile_startup_notebook(&app, notebook, &memo_file); + start_post_startup_services(app.clone(), external_runtimes); + let background = app.state::().notebook_background.clone(); + // This identity only sets queue priority. Release the global + // MemoFile lock before taking the watcher binding lock. + let current_notebook_id = crate::lock_utils::read_lock(&memo_file, "memo_file") + .current_notebook_id_value(); + // Serialize a fresh independent registry snapshot + binding + // with other rebinds, without nesting the global MemoFile lock. + let initial_notebooks = { + let mut watcher = memo_watcher.write().unwrap_or_else(|p| p.into_inner()); + let configs = crate::watcher::runtime::read_registered_notebooks( + user_config_for_watcher.config_dir(), + ); + watcher.set_whitelist(user_config_for_watcher.get_preference().watcher); + match configs { + Ok(configs) => { + watcher.rebind_all(app.clone(), configs.clone()); + configs + } + Err(error) => { + tracing::warn!(%error, "startup watcher snapshot unavailable; retry deferred"); + Vec::new() + } + } + }; + if let Some(notebook) = initial_notebooks.iter().find(|n| Some(n.id.as_str()) == current_notebook_id.as_deref()) { + background.prioritize(¬ebook.id); } - let media_started = Instant::now(); - if let Err(error) = crate::lock_utils::read_lock(&memo_file, "memo_file") - .reconcile_media_resources(¬ebook.id) - { - tracing::warn!(notebook = %notebook.id, "[startup] media maintenance failed: {error}"); + background.start(app.clone(), user_config_for_watcher.config_dir().clone()); + for notebook in &initial_notebooks { + if current_notebook_id.as_deref() != Some(notebook.id.as_str()) { + background.request(¬ebook.id, false, false); + } } - tracing::info!(notebook = %notebook.id, elapsed_ms = media_started.elapsed().as_millis(), "[startup] media maintenance checked"); - record_slow_notebook_stage( - ¬ebook.id, - "media-reconcile", - media_started.elapsed(), + spawn_notebook_watch_retry( + app.clone(), + memo_watcher.clone(), + user_config_for_watcher.config_dir().clone(), + background.clone(), + watcher_retry_stop.clone(), + ); + tracing::info!( + elapsed_ms = started.elapsed().as_millis(), + "[startup] background services admitted" ); - std::thread::yield_now(); + record_startup_stage(&startup, "background-services-admitted"); + return; } - tracing::info!( - elapsed_ms = started.elapsed().as_millis(), - "[startup] background maintenance completed" - ); - record_startup_stage(&startup, "background-complete"); + Ok(Err(error)) => { + tracing::error!("[startup] foreground preparation failed: {error}"); + crate::runtime_log::record_event("error", "startup.failed", serde_json::json!({ + "step": startup.status().step, "error": error, + "elapsedMs": startup.elapsed_ms(), "attemptElapsedMs": started.elapsed().as_millis() + }).to_string()); + startup.mark_failed(error); + } + Err(_) => { + tracing::error!("[startup] foreground preparation panicked"); + crate::runtime_log::record_event( + "error", + "startup.failed", + "startup migration panicked", + ); + startup.mark_failed("startup migration panicked"); + } + } + startup.wait_for_retry(); + } + }); +} + +/// Retry external/not-yet-mounted notebook roots independently of index work. +/// A newly attached root gets one reconciliation pass to cover changes made +/// while its volume was offline. The process exit signal stops this loop. +fn spawn_notebook_watch_retry( + app: tauri::AppHandle, + memo_watcher: Arc>, + config_dir: PathBuf, + background: Arc, + stop: Arc, +) { + tauri::async_runtime::spawn_blocking(move || { + loop { + for _ in 0..15 { + if stop.load(Ordering::Acquire) { + return; + } + std::thread::sleep(Duration::from_secs(1)); } - Ok(Err(error)) => { - tracing::error!("[startup] migration gate failed: {error}"); - startup.mark_failed(error); + if stop.load(Ordering::Acquire) { + return; } - Err(_) => { - tracing::error!("[startup] migration gate panicked"); - startup.mark_failed("startup migration panicked"); + let Ok(mut watcher) = memo_watcher.write() else { + tracing::warn!("notebook watcher lock unavailable during root retry"); + continue; + }; + let configs = match crate::watcher::runtime::read_registered_notebooks(&config_dir) + { + Ok(configs) => configs, + Err(error) => { + tracing::warn!(%error, "could not reload notebook roots for watcher retry"); + continue; + } + }; + if !watcher.is_initialized() { + watcher.rebind_all(app.clone(), configs.clone()); + // Binding alone cannot cover changes made while detached. Only + // reconcile roots that actually attached; offline roots retain + // their normal retry path and suspended roots stay untouched. + crate::watcher::runtime::reconcile_bound_roots(&watcher, &configs, &background); + continue; + } + for config in &configs { + if watcher.is_watching(&config.id) + || watcher.is_notebook_suspended(&config.id) + || !Path::new(&config.path).is_dir() + { + continue; + } + if watcher.add_notebook_root(config) { + tracing::info!(notebook_id = %config.id, root = %config.path, + "attached notebook watcher after root became available"); + background.request(&config.id, false, true); + } else { + tracing::debug!(notebook_id = %config.id, root = %config.path, + "notebook watcher root retry is still unavailable"); + } } } }); } -fn run_startup_reconciliation( - app: &tauri::AppHandle, - initial_notebooks: &[flowix_core::memo_file::NotebookConfig], +fn prepare_startup_storage( + device_registry: &crate::device_registration::DeviceRegistry, memo_file: &Arc>, - memo_watcher: &Arc>, - user_config_for_watcher: &Arc, + thread_manager: &ThreadManager, + cloud_sync: &flowix_sync::SyncManager, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, startup: &crate::app::startup::StartupCoordinator, ) -> Result<(), String> { - // MemoFile starts without an operation context. Resolve the persisted - // selection before deciding which notebook is on the startup critical path. - let current_notebook_id = { - let persisted = crate::lock_utils::read_lock(memo_file, "memo_file") - .read_selected_notebook_id() - .map_err(|error| format!("read selected notebook failed: {error}"))?; - let selected = persisted - .as_deref() - .and_then(|id| initial_notebooks.iter().find(|notebook| notebook.id == id)) - .filter(|notebook| Path::new(¬ebook.path).is_dir()) - .or_else(|| { - initial_notebooks - .iter() - .find(|notebook| Path::new(¬ebook.path).is_dir()) - }) - .map(|notebook| notebook.id.clone()); - let mut memo_file = memo_file - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - memo_file.set_current_notebook(selected.clone()); - if persisted != selected { - memo_file - .write_selected_notebook_id(selected.as_deref()) - .map_err(|error| format!("persist selected notebook failed: {error}"))?; - } - selected - }; - tracing::info!(notebook = ?current_notebook_id, "[startup] selected notebook resolved"); - let legacy_watcher = user_config_for_watcher.get_preference().watcher; - for notebook in initial_notebooks { - let root = Path::new(¬ebook.path); - if root.is_dir() { - if let Err(error) = crate::commands::file::migrate_legacy_watcher_rules( - root, - &legacy_watcher, - &crate::lock_utils::read_lock(&memo_file, "memo_file"), - ) { - tracing::warn!(notebook = %notebook.id, %error, "legacy watcher rule migration deferred"); - } - } + let version_init_pending = version_initialization_pending(device_registry); + if version_init_pending { + startup.mark_running( + crate::app::startup::StartupStage::Initialization, + "versionInitialization", + ); + startup.set_progress(0, 0, 4, "prepareDatabasePaths"); + } else { + // Also reset retry state here: even a failed rebuild stays in stage 2. + startup.mark_running( + crate::app::startup::StartupStage::WorkspacePreparation, + "preparingData", + ); + } + if version_init_pending { + crate::storage_initialization::prepare_database_paths(user_config_dir) + .map_err(|error| format!("prepare database paths failed: {error}"))?; + } else { + flowix_core::memo_file::ensure_storage_directory(&user_config_dir.join("db")) + .map_err(|error| format!("prepare current database directory failed: {error}"))?; + } + if version_init_pending { + startup.set_progress(8, 1, 4, "prepareThreadDatabase"); + } + thread_manager + .initialize_storage() + .map_err(|error| format!("initialize thread database failed: {error}"))?; + if version_init_pending { + startup.set_progress(16, 2, 4, "prepareSyncDatabase"); + } + if version_init_pending { + cloud_sync.initialize_storage() + .map_err(|error| format!("initialize sync database failed: {error}"))?; + } + if version_init_pending { + startup.set_progress(24, 3, 4, "prepareNotebookRegistry"); } - runtime_log::record_event( - "info", - "startup.selection", - serde_json::json!({ "notebookId": current_notebook_id.as_deref() }).to_string(), - ); - record_startup_stage(startup, "selected-notebook-resolved"); - startup.mark_running("dataMigrations"); - let migration_started = Instant::now(); { - let memo_file = crate::lock_utils::read_lock(memo_file, "memo_file"); - let report = memo_file - .run_pending_data_migrations() - .map_err(|error| format!("startup data migration failed: {error}"))?; - if report.applied > 0 { - tracing::info!( - from_version = report.from_version, - to_version = report.to_version, - applied = report.applied, - "startup data migrations completed" - ); - } + let memo = crate::lock_utils::read_lock(memo_file, "memo_file"); + let result = if version_init_pending { memo.initialize_registry_storage() } + else { memo.initialize_current_registry_storage() }; + result.map_err(|error| format!("initialize notebook registry failed: {error}"))?; } - tracing::info!( - elapsed_ms = migration_started.elapsed().as_millis(), - "[startup] data migrations checked" - ); - record_startup_stage(startup, "data-migrations-checked"); - - startup.mark_running("migratingCurrentNotebook"); - if let Some(notebook_id) = current_notebook_id.as_deref() { - { - let memo_file = crate::lock_utils::read_lock(memo_file, "memo_file"); - let migration_started = Instant::now(); - let report = memo_file - .ensure_notebook_migrations(notebook_id) - .map_err(|error| format!("current notebook migration failed: {error}"))?; - tracing::info!(notebook = %notebook_id, elapsed_ms = migration_started.elapsed().as_millis(), "[startup] current notebook migration completed"); - if report.moved_files > 0 || report.rebuilt_tags > 0 { - tracing::info!( - notebook = %notebook_id, - moved_files = report.moved_files, - rebuilt_tags = report.rebuilt_tags, - "current notebook migrations completed" - ); - } - } + if version_init_pending { + startup.set_progress(30, 4, 4, "prepareNotebookRegistry"); + } + user_config.prepare_runtime_watcher_exclusions(); + if !version_init_pending { + // Stage 2 only opens/rebuilds current runtime stores. Do not inspect or + // replay legacy jobs, even if their completion records were lost or a + // notebook was unavailable during the first initialization. + user_config.ensure_runtime_storage()?; + user_config.prepare_initialized_runtime(); + tracing::info!("[startup] version initialization already completed for this app version"); + record_startup_stage(startup, "version-initialization-skipped"); + return Ok(()); } - record_startup_stage(startup, "current-notebook-migrations-checked"); - startup.mark_running("reconcilingCurrentNotebook"); - if let Some(notebook) = initial_notebooks - .iter() - .find(|notebook| Some(notebook.id.as_str()) == current_notebook_id.as_deref()) + if crate::lock_utils::read_lock(memo_file, "memo_file") + .local_migration_completed(crate::storage_initialization::CONFIG_LOCAL_MIGRATIONS_JOB) + .map_err(|error| format!("read configuration initialization progress failed: {error}"))? { - let _ = reconcile_startup_notebook(app, notebook, memo_file); + user_config.prepare_initialized_runtime(); } - record_startup_stage(startup, "current-notebook-reconciled"); - - // Bind the watcher after structural migrations, before background scans. - // Its index updates share the memo index lock with reconciliation. - let watcher_started = Instant::now(); - let current_watch_configs = initial_notebooks - .iter() - .filter(|notebook| Some(notebook.id.as_str()) == current_notebook_id.as_deref()) - .cloned() - .collect(); - memo_watcher - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .rebind_all(app.clone(), current_watch_configs); - let watcher_cfg = user_config_for_watcher.get_preference().watcher.clone(); - memo_watcher - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .set_whitelist(watcher_cfg); tracing::info!( - elapsed_ms = watcher_started.elapsed().as_millis(), - "[startup] watcher bound" + previous_app_version = ?device_registry.last_successful_app_version(), + "[startup] starting version initialization" ); - record_startup_stage(startup, "current-notebook-watcher-bound"); + record_startup_stage(startup, "version-initialization-start"); + device_registry.begin_version_initialization()?; + + crate::storage_initialization::initialize_legacy_storage_with_progress( + device_registry, + &crate::lock_utils::read_lock(memo_file, "memo_file"), + user_config, + user_config_dir, + &get_app_data_path(), + |completed, total, task| { + let percent = 30 + (55 * completed / total.max(1)) as u8; + startup.set_progress(percent, completed, total, task); + }, + )?; + startup.set_progress(88, 0, 2, "prepareRuntimeStorage"); + user_config.ensure_runtime_storage()?; + startup.set_progress(95, 1, 2, "activateRuntime"); + user_config.prepare_initialized_runtime(); + + crate::storage_initialization::retire_global_index_databases(user_config_dir); + device_registry.complete_version_initialization()?; + startup.set_progress(100, 2, 2, "completeInitialization"); + + record_startup_stage(startup, "version-initialization-complete"); Ok(()) } -fn reconcile_startup_notebook( - _app: &tauri::AppHandle, - notebook: &flowix_core::memo_file::NotebookConfig, +fn prepare_notebook_selection( + initial_notebooks: &[flowix_core::memo_file::NotebookConfig], memo_file: &Arc>, + startup: &crate::app::startup::StartupCoordinator, ) -> Result<(), String> { - let started = Instant::now(); - let memo_file = crate::lock_utils::read_lock(memo_file, "memo_file"); - match memo_file.migrate_note_properties_for_notebook(¬ebook.id) { - Ok(report) if report.notes_written > 0 => { - tracing::info!(notebook = %notebook.id, notes = report.notes_written, "[startup] note properties migrated") - } - Err(error) => { - tracing::warn!(notebook = %notebook.id, %error, "[startup] note property migration will retry") - } - _ => {} - } - match memo_file.migrate_note_todo_metadata_for_notebook(¬ebook.id) { - Ok(report) if report.notes_written > 0 => { - tracing::info!(notebook = %notebook.id, notes = report.notes_written, "[startup] task metadata migrated") - } - Err(error) => { - tracing::warn!(notebook = %notebook.id, %error, "[startup] task migration will retry") - } - _ => {} + startup.mark_running(crate::app::startup::StartupStage::WorkspacePreparation, "preparingData"); + // Resolve identity solely from local metadata. Probing every external disk + // or attached repository does not belong on the shell's critical path. + let persisted = crate::lock_utils::read_lock(memo_file, "memo_file") + .read_selected_notebook_id().map_err(|error| error.to_string())?; + let selected = persisted.as_deref().and_then(|id| initial_notebooks.iter().find(|n| n.id == id)) + .or_else(|| initial_notebooks.first()).map(|n| n.id.clone()); + let mut memo = memo_file.write().unwrap_or_else(|p| p.into_inner()); + memo.set_current_notebook(selected.clone()); + if persisted != selected { + memo.write_selected_notebook_id(selected.as_deref()).map_err(|error| error.to_string())?; } - let report = memo_file - .reconcile_note_index(¬ebook.id) - .map_err(|error| { - format!( - "notebook {} path reconciliation failed: {error}", - notebook.id - ) - })?; - tracing::info!( - notebook = %notebook.id, - added = report.added, - updated = report.updated, - removed = report.removed, - unchanged = report.unchanged, - elapsed_ms = started.elapsed().as_millis(), - "[startup] path index reconciled" - ); - record_slow_notebook_stage(¬ebook.id, "markdown-reconcile", started.elapsed()); + record_startup_stage(startup, "selected-notebook-resolved"); Ok(()) } + fn start_post_startup_services( app: tauri::AppHandle, external_runtimes: Arc, ) { - commands::cloud::start_cloud_sync_polling(app.clone()); spawn_external_agent_watchdog(app, external_runtimes); } fn handle_second_instance(app: &tauri::AppHandle, args: Vec) { // 二�?�?��: 区分 markdown 文件�?���?flowix:// 深链�? // 两个通道�?��同时触发 (用户�?`xdg-open foo.md flowix://memo/abc123` �?��)�? let app = app.clone(); + // Launch Services starts a short-lived second process for a protocol URL. + // Bring the already-running window forward before waiting for app startup + // or parsing the URL, so even a malformed/stale callback cannot leave the + // browser's transient launch as the only visible activation. + focus_main_window(&app); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = app.state::().startup.wait_until_ready() { tracing::warn!("second-instance target deferred because startup failed: {error}"); @@ -1082,16 +1200,49 @@ fn handle_second_instance(app: &tauri::AppHandle, args: Vec) { }); } +#[cfg(desktop)] +fn focus_main_window(app: &tauri::AppHandle) { + #[cfg(target_os = "macos")] + { + // A protocol launch briefly activates its own helper process. Focusing + // the existing NSWindow alone does not reactivate the already-running + // app on macOS, so explicitly activate NSApp on the main thread first. + let app = app.clone(); + let app_for_main_thread = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(mtm) = objc2::MainThreadMarker::new() { + objc2_app_kit::NSApp(mtm).activate(); + } + if let Some(window) = app_for_main_thread.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + }); + } + + #[cfg(not(target_os = "macos"))] + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + #[cfg(desktop)] fn dispatch_google_callback(app: &tauri::AppHandle, raw: &str) -> bool { - let Ok(url) = url::Url::parse(raw) else { return false; }; + let Ok(url) = url::Url::parse(raw) else { + return false; + }; let expected_scheme = if option_env!("FLOWIX_DEV_MODE") == Some("1") { "flowix-dev" } else { "flowix" }; - if url.scheme() != expected_scheme || url.host_str() != Some("auth") || - url.path() != "/google/callback" { + if url.scheme() != expected_scheme + || url.host_str() != Some("auth") + || url.path() != "/google/callback" + { return false; } let mut code = None; @@ -1106,14 +1257,20 @@ fn dispatch_google_callback(app: &tauri::AppHandle, raw: &str) -> bool { } } let callback = match (auth_error, code, oauth_state) { - (Some(error), _, Some(state)) => Some(commands::cloud::GoogleCallback::Failure { error, state }), - (None, Some(code), Some(state)) => Some(commands::cloud::GoogleCallback::Success { code, state }), + (Some(error), _, Some(state)) => { + Some(commands::cloud::GoogleCallback::Failure { error, state }) + } + (None, Some(code), Some(state)) => { + Some(commands::cloud::GoogleCallback::Success { code, state }) + } _ => None, }; if let Some(callback) = callback { let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = commands::cloud::complete_google_deep_link(app.clone(), callback).await { + if let Err(error) = + commands::cloud::complete_google_deep_link(app.clone(), callback).await + { let _ = app.emit("cloud-google-auth-error", error); } }); @@ -1125,7 +1282,11 @@ fn dispatch_google_callback(app: &tauri::AppHandle, raw: &str) -> bool { fn register_deep_links(app: &mut tauri::App) { use tauri_plugin_deep_link::DeepLinkExt; - let callback_scheme = if option_env!("FLOWIX_DEV_MODE") == Some("1") { "flowix-dev" } else { "flowix" }; + let callback_scheme = if option_env!("FLOWIX_DEV_MODE") == Some("1") { + "flowix-dev" + } else { + "flowix" + }; // Bundled app metadata owns registration on macOS; runtime registration // remains useful on platforms where the deep-link plugin supports it. let _ = app.deep_link().register(callback_scheme); @@ -1140,6 +1301,7 @@ fn register_deep_links(app: &mut tauri::App) { .map(|url| url.to_string()) .collect::>(); let app = app_handle.clone(); + focus_main_window(&app); let startup = startup.clone(); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = startup.wait_until_ready() { @@ -1175,6 +1337,7 @@ fn handle_cold_start_open_targets(app: &tauri::AppHandle) { } fn emit_open_target_batch_if_needed(app: &tauri::AppHandle, paths: &[String]) { + if commands::window_shutdown::is_exiting() { return; } let mut external_paths = Vec::new(); let state = app.state::(); let configs = crate::lock_utils::read_lock(&state.memo_file, "memo_file") @@ -1216,6 +1379,7 @@ fn emit_open_target_batch_if_needed(app: &tauri::AppHandle, paths: &[String]) { } fn emit_open_target_if_resolved(app: &tauri::AppHandle, raw: &str) { + if commands::window_shutdown::is_exiting() { return; } let state = app.state::(); let markdown_paths = commands::markdown_paths_from_args([raw.to_string()]); for path in &markdown_paths { @@ -1261,7 +1425,6 @@ fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) { event: tauri::WindowEvent::Destroyed, .. } => { - commands::document_shutdown::forget_window(app, &label); commands::document_operations::forget_window(&label); app.state::().export_access.revoke(&label); app.state::().document_access.revoke(&label); @@ -1269,6 +1432,7 @@ fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) { } #[cfg(target_os = "macos")] tauri::RunEvent::Opened { urls } => { + if commands::window_shutdown::is_exiting() { return; } let app = app.clone(); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = app.state::().startup.wait_until_ready() { @@ -1290,16 +1454,28 @@ fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) { }); } tauri::RunEvent::ExitRequested { api, code, .. } => { - if !commands::document_shutdown::request_exit(app, code.unwrap_or(0)) { + if !commands::window_shutdown::request_exit(app, code.unwrap_or(0)) { api.prevent_exit(); return; } - stop_external_agent_children(app, "exit"); - checkpoint_thread_database(app, "exit"); - } - tauri::RunEvent::Exit => { - stop_external_agent_children(app, "final exit"); - checkpoint_thread_database(app, "final exit"); + static CLEANUP_STARTED: AtomicBool = AtomicBool::new(false); + static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); + if !CLEANUP_DONE.load(Ordering::Acquire) { + api.prevent_exit(); + if !CLEANUP_STARTED.swap(true, Ordering::AcqRel) { + let app = app.clone(); + std::thread::spawn(move || { + // Run once off the UI thread. A blocked lock, panic or + // checkpoint cannot outlive the independent watchdog. + app.state::().notebook_background.stop(); + app.state::().watcher_retry_stop.store(true, Ordering::Release); + stop_external_agent_children(&app, "exit"); + checkpoint_thread_database(&app, "exit"); + CLEANUP_DONE.store(true, Ordering::Release); + app.exit(code.unwrap_or(0)); + }); + } + } } _ => {} } @@ -1317,19 +1493,25 @@ fn checkpoint_thread_database(app: &tauri::AppHandle, phase: &str) { pub(crate) fn prepare_for_update(app: &tauri::AppHandle) -> UpdateRuntimeGuard { // Construct before cleanup so unwinding also restores admission. This guard // stays on the same worker through cleanup and installer launch. - let guard = UpdateRuntimeGuard(app.state::().pi.clone()); + let state = app.state::(); + state.notebook_background.pause(); + let guard = UpdateRuntimeGuard { pi: state.pi.clone(), background: state.notebook_background.clone() }; stop_external_agent_children(app, "update"); checkpoint_thread_database(app, "update"); guard } #[cfg(windows)] -pub(crate) struct UpdateRuntimeGuard(Arc); +pub(crate) struct UpdateRuntimeGuard { + pi: Arc, + background: Arc, +} #[cfg(windows)] impl Drop for UpdateRuntimeGuard { fn drop(&mut self) { - self.0.resume_after_failed_update(); + self.pi.resume_after_failed_update(); + self.background.resume(); } } @@ -1357,7 +1539,7 @@ fn stop_external_agent_children(app: &tauri::AppHandle, phase: &str) { if total > 0 { let summary = stopped .iter() - .map(|result| format!("{}={}", result.runtime.key(), result.affected)) + .map(|result| format!("{}={}", result.runtime.id(), result.affected)) .collect::>() .join(", "); tracing::info!("stopped external agent children on {phase}: {summary}"); @@ -1371,3 +1553,264 @@ fn stop_external_agent_children(app: &tauri::AppHandle, phase: &str) { } }); } + +#[cfg(test)] +mod missing_dependency_tests { + use super::*; + use crate::app::startup::{StartupCoordinator, StartupStage}; + + fn prepare_storage( + config: &Path, + user: Arc, + device: &crate::device_registration::DeviceRegistry, + memo: &Arc>, + startup: &StartupCoordinator, + ) -> Result<(), String> { + let threads = ThreadManager::new_uninitialized(config.join("db/thread.db")).unwrap(); + let sync = flowix_sync::SyncManager::new_with_session_dependencies_deferred( + flowix_sync::CloudEnvironment::Prod.api_base(), + config.join("db/sync.db"), + user.clone(), + Arc::new(|_| {}), + ) + .unwrap(); + prepare_startup_storage(device, memo, &threads, &sync, &user, config, startup) + } + + #[test] + fn completed_version_skips_legacy_jobs_despite_old_settings_and_offline_notebooks() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = get_user_config_dir(&home); + let user = user_config::UserConfigStore::new(home.clone()); + let mut preference = user.get_preference(); + preference.watcher.skip_dirs.push("legacy".into()); + preference.watcher.skip_files.push("*.tmp".into()); + user.set_preference(preference).unwrap(); + let device = crate::device_registration::DeviceRegistry::load(&config, "test"); + device.set_version_initialization_completed(true).unwrap(); + // An older installation can have a completed version but no reset flag. + assert_eq!(device.notebook_storage_reset_version(), 0); + let boot_before = std::fs::read(config.join("boot/boot.json")).unwrap(); + let memo = flowix_core::memo_file::MemoFile::new(config.clone()); + memo.initialize_registry_storage().unwrap(); + memo.mark_local_migration_completed("config_watcher_exclusions_v1") + .unwrap(); + let root = home.join("notebook"); + std::fs::create_dir_all(root.join(".flowix")).unwrap(); + let preferences_path = root.join(".flowix/view-preferences.json"); + let custom_preferences = b"{\"default_create_folder\":\"custom\"}"; + std::fs::write(&preferences_path, custom_preferences).unwrap(); + let notebooks: Vec<_> = [("online", root), ("offline", home.join("unavailable"))] + .into_iter() + .map(|(id, path)| flowix_core::memo_file::NotebookConfig { + id: id.into(), + name: id.into(), + icon: None, + path: path.to_string_lossy().into_owned(), + is_default: false, + sort: 0, + created_at: 0, + updated_at: 0, + }) + .collect(); + memo.write_notebook_configs(¬ebooks).unwrap(); + assert!(memo.has_pending_notebook_storage_initialization().unwrap()); + drop(memo); + + // Reload all state as two independent launches would do. + for _ in 0..2 { + let user = Arc::new(user_config::UserConfigStore::new(home.clone())); + let device = crate::device_registration::DeviceRegistry::load(&config, "test"); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config.clone(), + ))); + let startup = StartupCoordinator::new(); + prepare_storage(&config, user.clone(), &device, &memo, &startup).unwrap(); + assert_eq!(startup.status().stage, StartupStage::WorkspacePreparation); + assert!(startup.status().progress.is_none()); + assert!(!user.has_legacy_watcher_exclusions()); + assert!(device.is_version_initialization_complete()); + assert_eq!( + std::fs::read(config.join("boot/boot.json")).unwrap(), + boot_before + ); + // Stage 2 must not replay resets or fabricate legacy job completion. + assert!(memo + .read() + .unwrap() + .has_pending_notebook_storage_initialization() + .unwrap()); + assert_eq!( + std::fs::read(&preferences_path).unwrap(), + custom_preferences + ); + } + } + + #[test] + fn stage_two_uses_watcher_defaults_without_persisting_configuration() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = get_user_config_dir(&home); + let user = Arc::new(user_config::UserConfigStore::new(home)); + let mut preference = user.get_preference(); + preference.watcher.skip_files.push("*.tmp".into()); + user.set_preference(preference).unwrap(); + // A directory at the file path gives a deterministic I/O failure on + // Windows, macOS and Linux, without relying on Unix permission bits. + let path = config.join("boot/preference.json"); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + let device = crate::device_registration::DeviceRegistry::load(&config, "test"); + device.set_version_initialization_completed(true).unwrap(); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config.clone(), + ))); + let startup = StartupCoordinator::new(); + for _ in 0..2 { + prepare_storage(&config, user.clone(), &device, &memo, &startup).unwrap(); + assert_eq!(startup.status().stage, StartupStage::WorkspacePreparation); + assert!(startup.status().progress.is_none()); + assert!(!user.has_legacy_watcher_exclusions()); + assert!(path.is_dir()); + assert!( + crate::device_registration::DeviceRegistry::load(&config, "test") + .is_version_initialization_complete() + ); + } + } + + #[test] + fn new_version_initializes_once_then_prepares_workspace() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = get_user_config_dir(&home); + let user = Arc::new(user_config::UserConfigStore::new(home)); + let old = crate::device_registration::DeviceRegistry::load(&config, "old"); + old.set_version_initialization_completed(true).unwrap(); + let current = crate::device_registration::DeviceRegistry::load(&config, "current"); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config.clone(), + ))); + std::fs::write(config.join("index.db"), b"retired root database").unwrap(); + std::fs::create_dir_all(config.join("db")).unwrap(); + std::fs::write(config.join("db/index.db"), b"retired canonical database").unwrap(); + let startup = StartupCoordinator::new(); + prepare_storage(&config, user.clone(), ¤t, &memo, &startup).unwrap(); + assert!(!config.join("index.db").exists()); + assert!(!config.join("db/index.db").exists()); + assert!(config.join("rebuild-backups").is_dir()); + assert!(config.join("db/rebuild-backups").is_dir()); + assert_eq!(startup.status().stage, StartupStage::Initialization); + assert_eq!(startup.status().progress.unwrap().percent, 100); + let reloaded = crate::device_registration::DeviceRegistry::load(&config, "current"); + assert!(reloaded.is_version_initialization_complete()); + let startup = StartupCoordinator::new(); + prepare_storage(&config, user, &reloaded, &memo, &startup).unwrap(); + assert_eq!(startup.status().stage, StartupStage::WorkspacePreparation); + assert!(startup.status().progress.is_none()); + } + + #[test] + fn storage_rebuild_starts_with_three_invalid_databases() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = get_user_config_dir(&home); + let user = Arc::new(user_config::UserConfigStore::new(home)); + let device = crate::device_registration::DeviceRegistry::load(&config, "test"); + device.set_version_initialization_completed(true).unwrap(); + device.complete_notebook_storage_reset_version(1).unwrap(); + std::fs::create_dir_all(config.join("db")).unwrap(); + for name in ["local.db", "thread.db", "sync.db"] { + std::fs::write(config.join("db").join(name), b"invalid database").unwrap(); + } + let threads = ThreadManager::new_uninitialized(config.join("db/thread.db")).unwrap(); + let sync = flowix_sync::SyncManager::new_with_session_dependencies_deferred( + flowix_sync::CloudEnvironment::Prod.api_base(), + config.join("db/sync.db"), + user.clone(), + Arc::new(|_| {}), + ) + .unwrap(); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config.clone(), + ))); + let startup = StartupCoordinator::new(); + prepare_startup_storage(&device, &memo, &threads, &sync, &user, &config, &startup) + .unwrap(); + assert_eq!(startup.status().stage, StartupStage::WorkspacePreparation); + assert!(startup.status().progress.is_none()); + assert!(memo + .read() + .unwrap() + .read_notebook_configs() + .unwrap() + .is_empty()); + assert!(!user + .get_property_presets() + .unwrap() + .system_fields + .is_empty()); + assert_eq!( + std::fs::read_dir(config.join("db/rebuild-backups")) + .unwrap() + .count(), + 2 + ); + // Cloud storage is independently prepared after the shell, not in stage 2. + assert_eq!(std::fs::read(config.join("db/sync.db")).unwrap(), b"invalid database"); + sync.initialize_storage().unwrap(); + assert_eq!(std::fs::read_dir(config.join("db/rebuild-backups")).unwrap().count(), 3); + } + + #[test] + fn missing_runtime_dependencies_start_with_completed_boot_marker() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config_dir = get_user_config_dir(&home); + let user_config = Arc::new(user_config::UserConfigStore::new(home)); + let device = crate::device_registration::DeviceRegistry::load(&config_dir, "test"); + device.set_version_initialization_completed(true).unwrap(); + device.complete_notebook_storage_reset_version(1).unwrap(); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config_dir.clone(), + ))); + memo.read().unwrap().initialize_registry_storage().unwrap(); + user_config.ensure_runtime_storage().unwrap(); + std::fs::remove_dir_all(config_dir.join("db")).unwrap(); + let threads = ThreadManager::new_in_memory().unwrap(); + let sync = flowix_sync::SyncManager::new_with_session_dependencies_deferred( + flowix_sync::CloudEnvironment::Prod.api_base(), + temp.path().join("sync.db"), + user_config.clone(), + Arc::new(|_| {}), + ) + .unwrap(); + let startup = crate::app::startup::StartupCoordinator::new(); + prepare_startup_storage( + &device, + &memo, + &threads, + &sync, + &user_config, + &config_dir, + &startup, + ) + .unwrap(); + assert!(device.is_version_initialization_complete()); + assert_eq!(startup.status().stage, StartupStage::WorkspacePreparation); + assert!(startup.status().progress.is_none()); + assert!(memo + .read() + .unwrap() + .read_notebook_configs() + .unwrap() + .is_empty()); + assert!(!user_config + .get_property_presets() + .unwrap() + .system_fields + .is_empty()); + } +} diff --git a/app/flowix-desktop/src/app/document_access.rs b/app/flowix-desktop/src/app/document_access.rs index 981b96e6e..5808fc3d9 100644 --- a/app/flowix-desktop/src/app/document_access.rs +++ b/app/flowix-desktop/src/app/document_access.rs @@ -5,6 +5,7 @@ use std::sync::RwLock; #[derive(Default)] pub struct DocumentAccess { paths: RwLock>, + directories: RwLock>, generations: RwLock>, } @@ -37,16 +38,43 @@ impl DocumentAccess { true } + pub fn grant_directory_for_generation( + &self, + window: &str, + generation: u64, + path: &Path, + ) -> bool { + let Ok(generations) = self.generations.read() else { + return false; + }; + if *generations.get(window).unwrap_or(&0) != generation { + return false; + } + let Some(path) = existing_directory(path) else { + return false; + }; + let Ok(mut directories) = self.directories.write() else { + return false; + }; + directories.insert((window.to_string(), path)); + true + } + pub fn contains(&self, window: &str, path: &Path) -> bool { if self.generations.read().is_err() { return false; } - let Some(path) = existing_file(path) else { + let Some(path) = existing_path(path) else { return false; }; - self.paths + let file_granted = path.is_file() && self.paths .read() - .is_ok_and(|paths| paths.contains(&(window.to_string(), path))) + .is_ok_and(|paths| paths.contains(&(window.to_string(), path.clone()))); + file_granted || self.directories.read().is_ok_and(|directories| { + directories.iter().any(|(owner, directory)| { + owner == window && path.starts_with(directory) + }) + }) } pub fn revoke(&self, window: &str) { @@ -58,6 +86,9 @@ impl DocumentAccess { if let Ok(mut paths) = self.paths.write() { paths.retain(|(owner, _)| owner != window); } + if let Ok(mut directories) = self.directories.write() { + directories.retain(|(owner, _)| owner != window); + } } } @@ -69,6 +100,21 @@ fn existing_file(path: &Path) -> Option { canonical.is_file().then_some(canonical) } +fn existing_directory(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let canonical = dunce::canonicalize(path).ok()?; + canonical.is_dir().then_some(canonical) +} + +fn existing_path(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + dunce::canonicalize(path).ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/app/flowix-desktop/src/app/mod.rs b/app/flowix-desktop/src/app/mod.rs index 23ce6c237..a04eb05de 100644 --- a/app/flowix-desktop/src/app/mod.rs +++ b/app/flowix-desktop/src/app/mod.rs @@ -2,6 +2,7 @@ pub mod bootstrap; pub mod document_access; pub mod export_access; pub mod native_menu; +pub mod notebook_background; pub mod panic; pub mod paths; pub mod startup; diff --git a/app/flowix-desktop/src/app/notebook_background.rs b/app/flowix-desktop/src/app/notebook_background.rs new file mode 100644 index 000000000..af35e3d33 --- /dev/null +++ b/app/flowix-desktop/src/app/notebook_background.rs @@ -0,0 +1,520 @@ +//! Process-local admission for passive notebook maintenance. Accurate CLI/cloud +//! snapshots keep their explicit blocking APIs and share the core OS locks. +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +use flowix_core::memo_file::MemoFile; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +pub const EVENT: &str = "flowix:notebook-maintenance"; +pub const MEDIA_EVENT: &str = "flowix:media-maintenance"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookBackgroundStatus { + pub notebook_id: String, + pub state: &'static str, + pub revision: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub memo_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub missing: Option, + pub message: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookMediaMaintenanceStatus { + pub notebook_id: String, + pub state: &'static str, + pub revision: u64, + pub message: Option, +} + +#[derive(Default)] +struct Queue { + pending: VecDeque, + urgent: HashSet, + active: Option, + active_media: bool, + media: VecDeque, + media_statuses: HashMap, + media_revision: u64, + preferred: Option, + rerun: HashSet, + statuses: HashMap, + finished: HashMap, + revision: u64, +} + +impl Queue { + fn enqueue(&mut self, id: String, urgent: bool, invalidate: bool) { + if self.active.as_deref() == Some(&id) && !self.active_media { + if invalidate { + self.rerun.insert(id); + } + return; + } + if urgent { + self.urgent.insert(id.clone()); + } + if let Some(position) = self.pending.iter().position(|queued| queued == &id) { + if urgent { + self.pending.remove(position); + self.pending.push_front(id); + } + return; + } + // Passive queries cannot continuously retry an inaccessible disk. + if !invalidate + && self + .statuses + .get(&id) + .is_some_and(|status| status.state == "failed") + && self + .finished + .get(&id) + .is_some_and(|at| at.elapsed() < Duration::from_secs(2)) + { + self.urgent.remove(&id); + return; + } + self.revision += 1; + let previous = self.statuses.get(&id); + self.statuses.insert( + id.clone(), + NotebookBackgroundStatus { + notebook_id: id.clone(), + state: "queued", + revision: self.revision, + memo_count: previous.and_then(|s| s.memo_count), + missing: previous.and_then(|s| s.missing), + message: None, + }, + ); + if urgent { + self.pending.push_front(id); + } else { + self.pending.push_back(id); + } + } + + fn enqueue_media(&mut self, id: String, urgent: bool) -> NotebookMediaMaintenanceStatus { + if self.active_media && self.active.as_deref() == Some(&id) { + return self.media_statuses[&id].clone(); + } + if let Some(position) = self.media.iter().position(|queued| queued == &id) { + if urgent && position != 0 { + self.media.remove(position); + self.media.push_front(id.clone()); + } + return self.media_statuses[&id].clone(); + } + self.media_revision += 1; + let status = NotebookMediaMaintenanceStatus { + notebook_id: id.clone(), + state: "queued", + revision: self.media_revision, + message: None, + }; + self.media_statuses.insert(id.clone(), status.clone()); + if urgent { + self.media.push_front(id); + } else { + self.media.push_back(id); + } + status + } + + fn update_media_status( + &mut self, + id: &str, + state: &'static str, + message: Option, + ) -> NotebookMediaMaintenanceStatus { + self.media_revision += 1; + let status = NotebookMediaMaintenanceStatus { + notebook_id: id.to_owned(), + state, + revision: self.media_revision, + message, + }; + self.media_statuses.insert(id.to_owned(), status.clone()); + status + } +} + +#[derive(Default)] +pub struct NotebookBackground { + queue: Mutex, + wake: Condvar, + started: AtomicBool, + stopped: AtomicBool, + paused: AtomicBool, +} + +impl NotebookBackground { + pub fn request(&self, id: &str, urgent: bool, invalidate: bool) { + if id.is_empty() || self.stopped.load(Ordering::Acquire) { + return; + } + let mut queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + // stop() and admission share the queue lock, closing the check/enqueue race. + if self.stopped.load(Ordering::Acquire) { + return; + } + queue.enqueue(id.to_owned(), urgent, invalidate); + self.wake.notify_one(); + } + + pub fn prioritize(&self, id: &str) { + self.queue + .lock() + .unwrap_or_else(|p| p.into_inner()) + .preferred = Some(id.to_owned()); + self.request(id, true, true); + } + + pub fn request_media(&self, id: &str) -> Option { + if id.is_empty() || self.stopped.load(Ordering::Acquire) { + return None; + } + let mut queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + if self.stopped.load(Ordering::Acquire) { + return None; + } + let status = queue.enqueue_media(id.to_owned(), true); + self.wake.notify_one(); + Some(status) + } + + pub fn media_snapshot(&self, id: &str) -> Option { + self.queue + .lock() + .unwrap_or_else(|p| p.into_inner()) + .media_statuses + .get(id) + .cloned() + } + + pub fn media_statuses(&self) -> Vec { + self.queue + .lock() + .unwrap_or_else(|p| p.into_inner()) + .media_statuses + .values() + .cloned() + .collect() + } + + pub fn pause(&self) { + let _queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + self.paused.store(true, Ordering::Release); + } + pub fn resume(&self) { + let _queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + self.paused.store(false, Ordering::Release); + self.wake.notify_all(); + } + + pub fn snapshot(&self, id: &str) -> Option { + self.queue + .lock() + .unwrap_or_else(|p| p.into_inner()) + .statuses + .get(id) + .cloned() + } + + pub fn statuses(&self) -> Vec { + self.queue + .lock() + .unwrap_or_else(|p| p.into_inner()) + .statuses + .values() + .cloned() + .collect() + } + + pub fn stop(&self) { + self.stopped.store(true, Ordering::Release); + let mut queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + queue.pending.clear(); + queue.media.clear(); + queue.rerun.clear(); + self.wake.notify_all(); + } + + // Called only after the workbench has painted. Requests made before then + // are coalesced in the queue, without starting filesystem work. + pub fn start(self: &Arc, app: AppHandle, config: PathBuf) { + if self.started.swap(true, Ordering::AcqRel) { + return; + } + let worker = self.clone(); + tauri::async_runtime::spawn_blocking(move || worker.run(app, config)); + } + + fn run(&self, app: AppHandle, config: PathBuf) { + loop { + let (id, urgent, media, media_status) = { + let mut queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + while ((queue.pending.is_empty() && queue.media.is_empty()) + || self.paused.load(Ordering::Acquire)) + && !self.stopped.load(Ordering::Acquire) + { + queue = self.wake.wait(queue).unwrap_or_else(|p| p.into_inner()); + } + if self.stopped.load(Ordering::Acquire) { + return; + } + let preferred_media = queue + .preferred + .as_ref() + .and_then(|id| queue.media.iter().position(|queued| queued == id)); + let media = queue.pending.is_empty() + || (queue.urgent.is_empty() && preferred_media.is_some()); + let id = if media { + if let Some(position) = preferred_media { + queue.media.remove(position).unwrap() + } else { + queue.media.pop_front().unwrap() + } + } else { + queue.pending.pop_front().unwrap() + }; + let urgent = if media { + queue.preferred.as_deref() == Some(id.as_str()) + } else { + queue.urgent.remove(&id) + }; + queue.active = Some(id.clone()); + queue.active_media = media; + if !media { + if let Some(status) = queue.statuses.get_mut(&id) { + status.state = "running"; + } + } + let media_status = media.then(|| queue.update_media_status(&id, "running", None)); + (id, urgent, media, media_status) + }; + if media { + if let Some(status) = media_status { + let _ = app.emit(MEDIA_EVENT, status); + } + } else { + if let Some(status) = self.snapshot(&id) { + let _ = app.emit(EVENT, status); + } + } + let started = Instant::now(); + let ticks = std::cell::Cell::new(0usize); + let keep_running = || { + if self.stopped.load(Ordering::Acquire) || self.paused.load(Ordering::Acquire) { + return false; + } + let tick = ticks.get() + 1; + ticks.set(tick); + if tick % 32 != 0 { + return true; + } + // Yield between batches, never while holding a SQLite transaction. + std::thread::sleep(Duration::from_millis(2)); + let queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + queue.urgent.is_empty() + || (urgent && queue.preferred.as_deref() == Some(id.as_str())) + }; + if media { + let memo = MemoFile::new(config.clone()); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + memo.reconcile_media_resources_background(&id, &keep_running) + })) + .unwrap_or_else(|_| Err(std::io::Error::other("media maintenance panicked"))); + let mut queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + queue.active = None; + queue.active_media = false; + if self.stopped.load(Ordering::Acquire) { + return; + } + let status = match result { + Ok(_) => { + let status = queue.update_media_status(&id, "ready", None); + drop(queue); + let _ = app.emit( + "file-management-changed", + serde_json::json!({ "notebookId": id, + "notebookPath": memo.get_notebook_config_by_id(&id).map(|n| n.path) }), + ); + let _ = app.emit(MEDIA_EVENT, status.clone()); + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => { + queue.enqueue_media(id.clone(), false) + } + Err(error) => { + tracing::warn!(notebook = %id, %error, "background media maintenance unavailable"); + queue.update_media_status(&id, "failed", Some(error.to_string())) + } + }; + drop(queue); + let _ = app.emit(MEDIA_EVENT, status); + continue; + } + // Use a separate facade: a full scan must not hold AppState's global + // MemoFile RwLock. Core per-file and cross-process locks still apply. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if !keep_running() { + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); + } + let memo = MemoFile::new(config.clone()); + memo.prepare_background_note_projection(&id)?; + memo.reconcile_note_index_background(&id, &keep_running)?; + if !keep_running() { + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); + } + let count = memo.cached_note_count(&id)?.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "index is being prepared by another process", + ) + })?; + Ok::<_, std::io::Error>(count) + })) + .unwrap_or_else(|_| Err(std::io::Error::other("notebook maintenance panicked"))); + let mut queue = self.queue.lock().unwrap_or_else(|p| p.into_inner()); + queue.active = None; + queue.active_media = false; + if self.stopped.load(Ordering::Acquire) { + return; + } + let yielded = result + .as_ref() + .is_err_and(|e| e.kind() == std::io::ErrorKind::Interrupted); + let rerun = queue.rerun.remove(&id) || yielded; + if let Some(status) = queue.statuses.get_mut(&id) { + match result { + Ok(count) => { + status.state = "ready"; + status.memo_count = Some(count); + status.missing = Some(false); + status.message = None; + } + Err(error) => { + status.state = if yielded { "queued" } else { "failed" }; + status.message = Some(error.to_string()); + if error.kind() == std::io::ErrorKind::NotFound { + status.missing = Some(true); + } + } + } + } + queue.finished.insert(id.clone(), Instant::now()); + let status = queue.statuses.get(&id).cloned(); + let media_status = if status.as_ref().is_some_and(|s| s.state == "ready") { + Some(queue.enqueue_media(id.clone(), false)) + } else { None }; + if rerun { + queue.enqueue(id.clone(), false, true); + } + drop(queue); + tracing::info!(notebook = %id, elapsed_ms = started.elapsed().as_millis(), + files_checked = ticks.get(), "background notebook task settled"); + if let Some(status) = status { + // Versioned completion also reconciles late frontend subscribers. + let _ = app.emit(EVENT, status); + } + if let Some(status) = media_status { + let _ = app.emit(MEDIA_EVENT, status); + } + } + } +} + +/// Schedule a missing projection without turning a read into a blocking scan. +pub fn query_error( + state: &crate::app::state::AppState, + id: &str, + error: impl std::fmt::Display, +) -> String { + let message = error.to_string(); + if message.contains("Note index is not ready") { + state.notebook_background.request(id, true, false); + if let Some(status) = state.notebook_background.snapshot(id) { + if status.state == "failed" { + return format!( + "NOTE_INDEX_UNAVAILABLE: {}", + status.message.unwrap_or_default() + ); + } + } + "NOTE_INDEX_PENDING".into() + } else { + message + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn a_missing_projection_after_success_can_be_scheduled_immediately() { + let mut queue = Queue::default(); + queue.enqueue("a".into(), false, false); + queue.pending.clear(); + queue.statuses.get_mut("a").unwrap().state = "ready"; + queue.finished.insert("a".into(), Instant::now()); + queue.enqueue("a".into(), true, false); + assert_eq!(queue.pending.front().unwrap(), "a"); + } + + #[test] + fn failed_passive_reads_back_off_but_explicit_retry_is_admitted() { + let mut queue = Queue::default(); + queue.enqueue("a".into(), false, false); + queue.pending.clear(); + queue.statuses.get_mut("a").unwrap().state = "failed"; + queue.finished.insert("a".into(), Instant::now()); + queue.enqueue("a".into(), true, false); + assert!(queue.pending.is_empty()); + queue.enqueue("a".into(), true, true); + assert_eq!(queue.pending.len(), 1); + } + + #[test] + fn repeated_requests_coalesce_and_current_notebook_moves_first() { + let mut queue = Queue::default(); + queue.enqueue("other".into(), false, false); + queue.enqueue("current".into(), false, false); + queue.enqueue("current".into(), true, false); + queue.enqueue("current".into(), true, false); + assert_eq!( + queue.pending.into_iter().collect::>(), + ["current", "other"] + ); + } + #[test] + fn active_queries_do_not_loop_but_changes_schedule_one_followup() { + let mut queue = Queue::default(); + queue.active = Some("current".into()); + queue.enqueue("current".into(), true, false); + assert!(queue.rerun.is_empty()); + for _ in 0..10 { + queue.enqueue("current".into(), true, true); + } + assert_eq!(queue.rerun.len(), 1); + assert!(queue.pending.is_empty()); + } + #[test] + fn shutdown_rejects_new_work() { + let queue = NotebookBackground::default(); + queue.request("a", false, false); + queue.stop(); + queue.request("b", true, false); + assert!(queue.queue.lock().unwrap().pending.is_empty()); + assert!(queue.snapshot("b").is_none()); + } +} diff --git a/app/flowix-desktop/src/app/paths.rs b/app/flowix-desktop/src/app/paths.rs index 739e08cf5..0ee541fe0 100644 --- a/app/flowix-desktop/src/app/paths.rs +++ b/app/flowix-desktop/src/app/paths.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -/// 用户配置�?���?(~/./ 下放 index.db / boot/preference.json / +/// 用户配置�?���?(~/./ 下放 db/*.db / boot/preference.json / /// agent-config.toml / boot/system.json 绛?銆? pub const USER_CONFIG_DIR_NAME: &str = ".flowix"; diff --git a/app/flowix-desktop/src/app/startup.rs b/app/flowix-desktop/src/app/startup.rs index 63fa18321..b4b648c98 100644 --- a/app/flowix-desktop/src/app/startup.rs +++ b/app/flowix-desktop/src/app/startup.rs @@ -19,12 +19,30 @@ pub enum StartupPhase { Failed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum StartupStage { + Initialization, + WorkspacePreparation, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupProgress { + pub percent: u8, + pub completed: usize, + pub total: usize, + pub task: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct StartupStatus { pub phase: StartupPhase, + pub stage: StartupStage, pub step: String, pub error: Option, + pub progress: Option, } struct StartupState { @@ -52,8 +70,10 @@ impl StartupCoordinator { state: Mutex::new(StartupState { status: StartupStatus { phase: StartupPhase::Pending, - step: "initializing".to_string(), + stage: StartupStage::Initialization, + step: "initialization".to_string(), error: None, + progress: None, }, interactive: false, }), @@ -73,28 +93,83 @@ impl StartupCoordinator { self.started_at.elapsed().as_millis() } - pub fn mark_running(&self, step: impl Into) { + pub fn mark_running(&self, stage: StartupStage, step: impl Into) { self.update(StartupStatus { phase: StartupPhase::Running, + stage, step: step.into(), error: None, + progress: None, + }); + } + + pub fn set_progress( + &self, + percent: u8, + completed: usize, + total: usize, + task: impl Into, + ) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + if state.status.stage != StartupStage::Initialization + || state.status.phase != StartupPhase::Running + { + return; + } + state.status.progress = Some(StartupProgress { + percent: percent.min(100), + completed, + total, + task: task.into(), }); + self.changed.notify_all(); } pub fn mark_ready(&self) { self.update(StartupStatus { phase: StartupPhase::Ready, + stage: StartupStage::WorkspacePreparation, step: "ready".to_string(), error: None, + progress: None, }); } pub fn mark_failed(&self, error: impl Into) { - self.update(StartupStatus { - phase: StartupPhase::Failed, - step: "failed".to_string(), - error: Some(error.into()), - }); + // Preserve the stage and task that failed for retry UI and diagnostics. + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + state.status.phase = StartupPhase::Failed; + state.status.error = Some(error.into()); + state.status.progress = None; + self.changed.notify_all(); + } + + /// Only a failed attempt may be retried; the existing worker owns all attempts. + pub fn request_retry(&self) -> Result<(), String> { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + if state.status.phase != StartupPhase::Failed { + return Err("startup is not failed".into()); + } + state.status = StartupStatus { + phase: StartupPhase::Pending, + stage: state.status.stage, + step: match state.status.stage { + StartupStage::Initialization => "initialization", + StartupStage::WorkspacePreparation => "preparingData", + } + .into(), + error: None, + progress: None, + }; + self.changed.notify_all(); + Ok(()) + } + + pub fn wait_for_retry(&self) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + while state.status.phase == StartupPhase::Failed { + state = self.changed.wait(state).unwrap_or_else(|p| p.into_inner()); + } } pub fn wait_until_ready(&self) -> Result<(), String> { @@ -156,6 +231,14 @@ impl StartupCoordinator { .state .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Once stage 2 starts, neither retries nor later maintenance may reopen + // version initialization. Keep this invariant at the status boundary. + if state.status.stage == StartupStage::WorkspacePreparation + && status.stage == StartupStage::Initialization + { + tracing::warn!(step = %status.step, "ignored startup transition back to initialization"); + return; + } state.status = status; self.changed.notify_all(); } @@ -163,18 +246,32 @@ impl StartupCoordinator { #[cfg(test)] mod tests { - use super::{StartupCoordinator, StartupPhase}; + use super::{StartupCoordinator, StartupPhase, StartupStage}; use std::sync::Arc; use std::thread; use std::time::Duration; + #[test] + fn retry_only_reopens_failed_startup() { + let coordinator = StartupCoordinator::new(); + assert!(coordinator.request_retry().is_err()); + coordinator.mark_failed("interrupted"); + coordinator.request_retry().unwrap(); + coordinator.wait_for_retry(); + assert_eq!(coordinator.status().phase, StartupPhase::Pending); + assert!(coordinator.status().error.is_none()); + assert!(coordinator.request_retry().is_err()); + coordinator.mark_ready(); + assert!(coordinator.request_retry().is_err()); + } + #[test] fn waiters_are_released_when_startup_is_ready() { let coordinator = Arc::new(StartupCoordinator::new()); let waiter = coordinator.clone(); let handle = thread::spawn(move || waiter.wait_until_ready()); - coordinator.mark_running("migrating"); + coordinator.mark_running(StartupStage::Initialization, "migrating"); coordinator.mark_ready(); assert_eq!(handle.join().unwrap(), Ok(())); @@ -192,6 +289,45 @@ mod tests { ); } + #[test] + fn failure_retains_the_stage_and_task() { + let coordinator = StartupCoordinator::new(); + coordinator.mark_running(StartupStage::WorkspacePreparation, "load-current-notebook"); + coordinator.mark_failed("disk unavailable"); + let status = coordinator.status(); + assert_eq!(status.stage, StartupStage::WorkspacePreparation); + assert_eq!(status.step, "load-current-notebook"); + coordinator.request_retry().unwrap(); + assert_eq!( + coordinator.status().stage, + StartupStage::WorkspacePreparation + ); + } + + #[test] + fn workspace_preparation_cannot_return_to_initialization_or_publish_its_progress() { + let coordinator = StartupCoordinator::new(); + coordinator.mark_running(StartupStage::Initialization, "migrating"); + coordinator.set_progress(50, 1, 2, "migration"); + assert!(coordinator.status().progress.is_some()); + coordinator.mark_running(StartupStage::WorkspacePreparation, "preparingData"); + + for _ in 0..2 { + coordinator.mark_running(StartupStage::Initialization, "versionInitialization"); + coordinator.set_progress(30, 0, 2, "prepareNotebookRegistry"); + let status = coordinator.status(); + assert_eq!(status.stage, StartupStage::WorkspacePreparation); + assert!(status.progress.is_none()); + coordinator.mark_failed("database unavailable"); + coordinator.request_retry().unwrap(); + assert_eq!( + coordinator.status().stage, + StartupStage::WorkspacePreparation + ); + coordinator.mark_running(StartupStage::WorkspacePreparation, "preparingData"); + } + } + #[test] fn maintenance_waits_for_the_first_interactive_workspace() { let coordinator = Arc::new(StartupCoordinator::new()); diff --git a/app/flowix-desktop/src/app/state.rs b/app/flowix-desktop/src/app/state.rs index bc0fc7899..84e21a5bc 100644 --- a/app/flowix-desktop/src/app/state.rs +++ b/app/flowix-desktop/src/app/state.rs @@ -7,7 +7,7 @@ use crate::agent_external::runtime_registry::ExternalRuntimeRegistry; use crate::agent_external_config::AgentExternalConfig; use crate::agent_session::ThreadManager; use crate::app::startup::StartupCoordinator; -use crate::config::{AgentAccessStore, SecurityBookmarkStore, UserConfigStore}; +use crate::config::{SecurityBookmarkStore, UserConfigStore}; use crate::plugin::PluginRunCoordinator; use crate::system_data::SystemData; use flowix_core::memo_file::MemoFile; @@ -39,9 +39,12 @@ pub struct NotebookImportStatus { pub struct AppState { pub upload_sessions: Arc, pub document_access: crate::app::document_access::DocumentAccess, + pub security_bookmarks: Arc, pub export_access: crate::app::export_access::ExportAccess, pub user_config: Arc, pub cloud_sync: Arc, + /// Effective Cloud target selected at startup. Release builds always use prod. + pub cloud_environment: flowix_sync::CloudEnvironment, /// Legacy system metadata reader used to migrate notebook tag state into /// each notebook's `.flowix/system.json`. pub system_data: SystemData, @@ -66,11 +69,6 @@ pub struct AppState { /// Runtimes without a lifecycle adapter keep Flowix-local semantics. pub agent_lifecycle: Arc, pub thread_manager: Arc, - /// Agent 可访问目录 (notebook + 用户额外 folder), 持久化在 - /// `~/.flowix/agent-access.json`。驱动 external agent 的 - /// 工作区 / 资料目录过滤。 - pub agent_access: Arc, - pub security_bookmarks: Arc, pub plugin_runs: PluginRunCoordinator, /// Last known state for background notebook imports. Keeping the state in /// AppState lets a Webview recover when it subscribes after an event was @@ -84,4 +82,7 @@ pub struct AppState { /// Native startup migrations are independent from WebView startup but /// remain a gate for commands that depend on a consistent memo library. pub startup: Arc, + pub notebook_background: Arc, + /// Stops the independent retry loop for offline notebook watcher roots. + pub watcher_retry_stop: Arc, } diff --git a/app/flowix-desktop/src/app/watchdog.rs b/app/flowix-desktop/src/app/watchdog.rs index 821b65847..4466a778b 100644 --- a/app/flowix-desktop/src/app/watchdog.rs +++ b/app/flowix-desktop/src/app/watchdog.rs @@ -39,7 +39,7 @@ pub fn spawn_external_agent_watchdog( if total > 0 { let summary = finalized .iter() - .map(|result| format!("{}={}", result.runtime.key(), result.affected)) + .map(|result| format!("{}={}", result.runtime.id(), result.affected)) .collect::>() .join(", "); tracing::warn!( diff --git a/app/flowix-desktop/src/app_update.rs b/app/flowix-desktop/src/app_update.rs index 18e3d2efe..25f0a1fc2 100644 --- a/app/flowix-desktop/src/app_update.rs +++ b/app/flowix-desktop/src/app_update.rs @@ -1,22 +1,25 @@ use futures::future::{AbortHandle, Abortable}; use serde_json::json; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex, -}; +use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, State}; +use tauri_plugin_updater::Update; use tauri_plugin_updater::UpdaterExt; -#[cfg(target_os = "windows")] -use std::path::PathBuf; - +#[path = "app_update_cache.rs"] +mod update_cache; #[path = "app_update_state.rs"] mod update_state; -use update_state::UpdatePhase; +use self::update_cache::{prepare_update_package, verify_cached_package, PreparedUpdate}; +use self::update_state::UpdatePhase; -#[derive(Default)] +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +#[derive(Clone, Default)] pub struct AppUpdateState { active: Arc>, + check_lock: Arc>, + prepared: Arc>>, } #[derive(Default)] @@ -168,15 +171,10 @@ fn updater(app: &AppHandle) -> Result { .map_err(|error| format!("failed to initialize updater: {error}")) } -#[tauri::command] -pub async fn check_app_update(app: AppHandle) -> Result, String> { - let update = updater(&app)? - .check() - .await - .map_err(|error| format!("failed to check for update: {error}"))?; - Ok(update.map(|update| AppUpdateInfo { - current_version: update.current_version, - version: update.version, +fn update_info(update: &Update) -> AppUpdateInfo { + AppUpdateInfo { + current_version: update.current_version.clone(), + version: update.version.clone(), // Unknown manifest fields are preserved by tauri-plugin-updater in // raw_json. Keep notifications enabled for older manifests. notify: update @@ -184,14 +182,90 @@ pub async fn check_app_update(app: AppHandle) -> Result, S .get("notify") .and_then(serde_json::Value::as_bool) .unwrap_or(true), - date: update.date.map(|date| date.to_string()), - body: update.body, - })) + date: update.date.as_ref().map(|date| date.to_string()), + body: update.body.clone(), + } +} + +fn ensure_update_is_idle(state: &AppUpdateState) -> Result<(), String> { + let active = state + .active + .lock() + .map_err(|_| "application update state is unavailable".to_string())?; + if active.phase == UpdatePhase::Idle { + Ok(()) + } else { + Err("an application update is already in progress".to_string()) + } +} + +async fn download_requested_update( + app: &AppHandle, + prepared_state: &Arc>>, + version: &str, +) -> Result<(Update, Vec), String> { + let update = updater(app)? + .check() + .await + .map_err(|error| format!("failed to check for update: {error}"))? + .ok_or_else(|| "no application update is available".to_string())?; + if update.version != version { + return Err(format!("requested update {version} is no longer available")); + } + + let prepared = prepare_update_package(app, update, true).await?; + let bytes = tokio::fs::read(&prepared.package_path) + .await + .map_err(|error| format!("failed to read prepared application update: {error}"))?; + verify_cached_package(app, &prepared.update, &bytes)?; + *prepared_state + .lock() + .map_err(|_| "application update state is unavailable".to_string())? = + Some(prepared.clone()); + Ok((prepared.update, bytes)) +} + +#[tauri::command] +pub async fn check_app_update( + app: AppHandle, + state: State<'_, AppUpdateState>, +) -> Result, String> { + ensure_update_is_idle(state.inner())?; + let _check_guard = state.check_lock.lock().await; + ensure_update_is_idle(state.inner())?; + + let update = updater(&app)? + .check() + .await + .map_err(|error| format!("failed to check for update: {error}"))?; + let Some(update) = update else { + *state + .prepared + .lock() + .map_err(|_| "application update state is unavailable".to_string())? = None; + return Ok(None); + }; + + let info = update_info(&update); + if info.notify { + let prepared = prepare_update_package(&app, update, false).await?; + *state + .prepared + .lock() + .map_err(|_| "application update state is unavailable".to_string())? = Some(prepared); + } else { + *state + .prepared + .lock() + .map_err(|_| "application update state is unavailable".to_string())? = None; + } + Ok(Some(info)) } #[tauri::command] pub async fn install_app_update( app: AppHandle, + version: String, state: State<'_, AppUpdateState>, ) -> Result<(), String> { let (abort_handle, abort_registration) = AbortHandle::new_pair(); @@ -208,56 +282,42 @@ pub async fn install_app_update( let active_guard = ActiveUpdateGuard(state.active.clone()); let download_app = app.clone(); - let downloaded_bytes = Arc::new(AtomicU64::new(0)); - let started = Arc::new(AtomicBool::new(false)); + let check_lock = state.check_lock.clone(); + let prepared_state = state.prepared.clone(); let task = async move { - let update = updater(&download_app)? - .check() - .await - .map_err(|error| format!("failed to check for update: {error}"))? - .ok_or_else(|| "no application update is available".to_string())?; - let progress_bytes = downloaded_bytes.clone(); - let progress_started = started.clone(); - let bytes = update - .download( - |chunk_length, content_length| { - if !progress_started.swap(true, Ordering::Relaxed) { - let _ = download_app.emit( - "app-update-progress", - json!({ - "phase": "started", - "contentLength": content_length, - }), - ); + let (update, bytes) = { + let _check_guard = check_lock.lock().await; + let prepared = prepared_state + .lock() + .map_err(|_| "application update state is unavailable".to_string())? + .clone() + .filter(|prepared| prepared.update.version == version); + + if let Some(prepared) = prepared { + if let Ok(bytes) = tokio::fs::read(&prepared.package_path).await { + if verify_cached_package(&download_app, &prepared.update, &bytes).is_ok() { + (prepared.update, bytes) + } else { + let _ = tokio::fs::remove_file(&prepared.package_path).await; + *prepared_state + .lock() + .map_err(|_| "application update state is unavailable".to_string())? = + None; + download_requested_update(&download_app, &prepared_state, &version).await? } - let downloaded = progress_bytes - .fetch_add(chunk_length as u64, Ordering::Relaxed) - + chunk_length as u64; - let _ = download_app.emit( - "app-update-progress", - json!({ - "phase": "progress", - "downloadedBytes": downloaded, - "contentLength": content_length, - }), - ); - }, - || { - let _ = download_app.emit( - "app-update-progress", - json!({ - "phase": "finished", - "downloadedBytes": progress_bytes.load(Ordering::Relaxed), - }), - ); - }, - ) - .await - .map_err(|error| format!("failed to download update: {error}"))?; + } else { + download_requested_update(&download_app, &prepared_state, &version).await? + } + } else { + download_requested_update(&download_app, &prepared_state, &version).await? + } + }; + + verify_cached_package(&download_app, &update, &bytes)?; #[cfg(windows)] let document_guard = - crate::commands::document_shutdown::prepare_for_update(&download_app).await?; + crate::commands::window_shutdown::prepare_for_update(&download_app).await?; #[cfg(not(windows))] let document_guard = (); Ok::<_, String>((update, bytes, document_guard)) diff --git a/app/flowix-desktop/src/app_update_cache.rs b/app/flowix-desktop/src/app_update_cache.rs new file mode 100644 index 000000000..7bd1e1932 --- /dev/null +++ b/app/flowix-desktop/src/app_update_cache.rs @@ -0,0 +1,189 @@ +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + fmt::Write as _, + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, +}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_updater::Update; + +#[derive(Clone)] +pub(super) struct PreparedUpdate { + pub(super) update: Update, + pub(super) package_path: PathBuf, +} + +fn update_cache_location(app: &AppHandle, update: &Update) -> Result { + let mut hasher = Sha256::new(); + for value in [ + update.target.as_str(), + update.version.as_str(), + update.download_url.as_str(), + update.signature.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let digest = hasher.finalize(); + let mut package_key = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(package_key, "{byte:02x}"); + } + + let directory = app + .path() + .app_cache_dir() + .map_err(|error| format!("failed to locate application cache directory: {error}"))? + .join("app-updates"); + Ok(directory.join(format!("update-{package_key}.bundle"))) +} + +pub(super) fn verify_cached_package( + app: &AppHandle, + update: &Update, + bytes: &[u8], +) -> Result<(), String> { + use base64::Engine as _; + + let encoded_key = app + .config() + .plugins + .0 + .get("updater") + .and_then(|config| config.get("pubkey")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + "updater public key is missing from application configuration".to_string() + })?; + let key_text = base64::engine::general_purpose::STANDARD + .decode(encoded_key) + .map_err(|error| format!("failed to decode updater public key: {error}"))?; + let key_text = std::str::from_utf8(&key_text) + .map_err(|error| format!("updater public key is not valid UTF-8: {error}"))?; + let public_key = minisign_verify::PublicKey::decode(key_text) + .map_err(|error| format!("failed to parse updater public key: {error}"))?; + + let signature_text = base64::engine::general_purpose::STANDARD + .decode(&update.signature) + .map_err(|error| format!("failed to decode update signature: {error}"))?; + let signature_text = std::str::from_utf8(&signature_text) + .map_err(|error| format!("update signature is not valid UTF-8: {error}"))?; + let signature = minisign_verify::Signature::decode(signature_text) + .map_err(|error| format!("failed to parse update signature: {error}"))?; + public_key + .verify(bytes, &signature, true) + .map_err(|error| format!("cached update signature verification failed: {error}")) +} + +async fn remove_old_update_packages(directory: &std::path::Path, keep: &std::path::Path) { + let Ok(mut entries) = tokio::fs::read_dir(directory).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path != keep { + let _ = tokio::fs::remove_file(path).await; + } + } +} + +pub(super) async fn prepare_update_package( + app: &AppHandle, + update: Update, + report_progress: bool, +) -> Result { + let package_path = update_cache_location(app, &update)?; + let directory = package_path + .parent() + .ok_or_else(|| "application update cache path has no parent directory".to_string())?; + tokio::fs::create_dir_all(directory) + .await + .map_err(|error| format!("failed to create application update cache: {error}"))?; + + match tokio::fs::read(&package_path).await { + Ok(bytes) if verify_cached_package(app, &update, &bytes).is_ok() => { + remove_old_update_packages(directory, &package_path).await; + return Ok(PreparedUpdate { + update, + package_path, + }); + } + Ok(_) => { + let _ = tokio::fs::remove_file(&package_path).await; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("failed to read cached application update: {error}")); + } + } + + let partial_path = package_path.with_extension("part"); + let _ = tokio::fs::remove_file(&partial_path).await; + let downloaded_bytes = Arc::new(AtomicU64::new(0)); + let started = Arc::new(AtomicBool::new(false)); + let progress_app = report_progress.then(|| app.clone()); + let chunk_app = progress_app.clone(); + let chunk_bytes = downloaded_bytes.clone(); + let chunk_started = started.clone(); + let finish_bytes = downloaded_bytes.clone(); + let bytes = update + .download( + move |chunk_length, content_length| { + let Some(progress_app) = chunk_app.as_ref() else { + return; + }; + if !chunk_started.swap(true, Ordering::Relaxed) { + let _ = progress_app.emit( + "app-update-progress", + json!({ "phase": "started", "contentLength": content_length }), + ); + } + let downloaded = chunk_bytes.fetch_add(chunk_length as u64, Ordering::Relaxed) + + chunk_length as u64; + let _ = progress_app.emit( + "app-update-progress", + json!({ + "phase": "progress", + "downloadedBytes": downloaded, + "contentLength": content_length, + }), + ); + }, + move || { + if let Some(progress_app) = progress_app.as_ref() { + let _ = progress_app.emit( + "app-update-progress", + json!({ + "phase": "finished", + "downloadedBytes": finish_bytes.load(Ordering::Relaxed), + }), + ); + } + }, + ) + .await + .map_err(|error| format!("failed to download update: {error}"))?; + + // `Update::download` verifies the signature. Verify here as well so the + // same rule applies to freshly downloaded and previously cached bytes. + verify_cached_package(app, &update, &bytes)?; + tokio::fs::write(&partial_path, &bytes) + .await + .map_err(|error| format!("failed to write cached application update: {error}"))?; + if let Err(error) = tokio::fs::rename(&partial_path, &package_path).await { + let _ = tokio::fs::remove_file(&partial_path).await; + return Err(format!( + "failed to publish cached application update: {error}" + )); + } + remove_old_update_packages(directory, &package_path).await; + + Ok(PreparedUpdate { + update, + package_path, + }) +} diff --git a/app/flowix-desktop/src/commands/agent/chat.rs b/app/flowix-desktop/src/commands/agent/chat.rs index 7a6fcdfed..e590ece3f 100644 --- a/app/flowix-desktop/src/commands/agent/chat.rs +++ b/app/flowix-desktop/src/commands/agent/chat.rs @@ -48,7 +48,7 @@ pub async fn chat_with_agent_stream( tracing::info!( "[Command] chat_with_agent_stream called for thread: {}, agent_type: {}", threadId, - runtime.key() + runtime.id() ); if let Some(title) = message @@ -61,46 +61,18 @@ pub async fn chat_with_agent_stream( manager .ensure_thread( &threadId, - crate::agent_types::AgentId::new(runtime.key()), + crate::agent_types::AgentId::new(runtime.id()), title, ) .await .map_err(|error| error.to_string())?; } - // Refresh security-scoped access at every run, not only at startup. Start - // access before validating because a macOS bookmark may be what makes the - // directory visible to this process. Explicit runtime paths must never - // silently fall back to the application cwd when a frozen path disappears. - let runtime_cwd = message - .cwd_for_runtime(runtime.key()) - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string); - let runtime_workspace_paths = message.workspace_paths_for_runtime(runtime.key()); - if let Some(path) = runtime_cwd.as_deref() { - state - .security_bookmarks - .start_accessing_for_path(Path::new(path)); - } - for path in &runtime_workspace_paths { - state - .security_bookmarks - .start_accessing_for_path(Path::new(path)); - } - if let Some(path) = runtime_cwd.as_deref() { - if !Path::new(path).is_dir() { - return Err(format!("Agent working directory is unavailable: {path}")); - } - } - for path in &runtime_workspace_paths { - if !Path::new(path).is_dir() { - return Err(format!("Agent workspace directory is unavailable: {path}")); - } - } + let (runtime_cwd, runtime_workspace_paths) = + validate_runtime_workspace(&message, runtime.id(), &state)?; if let Some(path) = runtime_cwd.as_deref() { - sync_native_agent_instructions(Path::new(path), runtime.key(), &runtime_workspace_paths)?; + sync_native_agent_instructions(Path::new(path), runtime.id(), &runtime_workspace_paths)?; } // runtime 的 `chat_stream` 内部已经 `tokio::spawn` ── IPC 立即返回, @@ -113,12 +85,103 @@ pub async fn chat_with_agent_stream( .await; tracing::info!( "[Command] {} chat_with_agent_stream result: {:?}", - runtime.key(), + runtime.id(), result.is_ok() ); result.map(|response| AgentChatResponse { response }) } +fn validate_runtime_workspace( + message: &AgentUserMessage, + runtime: &str, + state: &AppState, +) -> Result<(Option, Vec), String> { + // Explicit runtime paths must never silently fall back to the application + // cwd when a frozen path disappears. + let runtime_cwd = message + .cwd_for_runtime(runtime) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string); + let runtime_workspace_paths = message.workspace_paths_for_runtime(runtime); + { + let memo = state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned")?; + let workspace = memo.read_notebook_workspace().map_err(|e| e.to_string())?; + let scope = message + .runtime_config + .as_ref() + .and_then(|config| config.notebook_id.as_deref()); + check_workspace_paths( + &workspace, + scope, + runtime_cwd + .iter() + .chain(runtime_workspace_paths.iter()) + .map(String::as_str), + )?; + } + if let Some(path) = runtime_cwd.as_deref() { + if !Path::new(path).is_dir() { + return Err(format!("Agent working directory is unavailable: {path}")); + } + } + for path in &runtime_workspace_paths { + if !Path::new(path).is_dir() { + return Err(format!("Agent workspace directory is unavailable: {path}")); + } + } + + Ok((runtime_cwd, runtime_workspace_paths)) +} + +fn check_workspace_paths<'a>( + workspace: &flowix_core::memo_file::NotebookWorkspaceSnapshot, + scope: Option<&str>, + paths: impl Iterator, +) -> Result<(), String> { + let mut roots = Vec::new(); + for notebook in &workspace.notebooks { + if scope.is_some_and(|id| id != notebook.id) { + continue; + } + roots.push(notebook.path.as_str()); + if let Some(config) = workspace.notebook_configs.get(¬ebook.id) { + roots.extend( + config + .add_dirs + .iter() + .filter(|d| d.enabled && !d.missing) + .map(|d| d.path.as_str()), + ); + } + } + for path in paths { + let path = Path::new(path); + if !roots + .iter() + .any(|root| crate::config::path_is_inside(path, Path::new(root))) + { + return Err(format!( + "Agent workspace directory is no longer mounted: {}", + path.display() + )); + } + if workspace.notebooks.iter().any(|notebook| { + crate::config::path_is_inside_reserved_directory( + path, + Path::new(¬ebook.path), + ".flowix", + ) + }) { + return Err("Agent workspace cannot use notebook internal storage".into()); + } + } + Ok(()) +} + /// Append input to the currently active steerable turn without creating a new run. #[tauri::command] #[allow(non_snake_case)] @@ -130,6 +193,7 @@ pub async fn steer_agent_stream( state: State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result<(), String> { + validate_runtime_workspace(&message, runtime_from_message(&message)?.id(), &state)?; match message.agent_type.as_deref() { Some("codex") => { state @@ -137,7 +201,7 @@ pub async fn steer_agent_stream( .steer_codex(&threadId, message, clientUserMessageId, &app_handle) .await } - Some("deepseek-harness") | Some("deepseek_harness") | Some("dsh") => { + Some("dsh") => { state .external_runtimes .steer_deepseek_harness(&threadId, message, clientUserMessageId, &app_handle) @@ -276,10 +340,11 @@ pub async fn execute_deepseek_harness_command( .await?; if !matches!( message.agent_type.as_deref(), - Some("deepseek-harness") | Some("deepseek_harness") | Some("dsh") | None + Some("dsh") | None ) { return Err("DSH slash commands require the deepseek-harness agent".to_string()); } + validate_runtime_workspace(&message, "dsh", &state)?; state .deepseek_harness .execute_command(&threadId, &command, &message, &app_handle) @@ -296,7 +361,7 @@ pub async fn deepseek_harness_skill_catalog( ) -> Result { if !matches!( message.agent_type.as_deref(), - Some("deepseek-harness") | Some("deepseek_harness") | Some("dsh") | None + Some("dsh") | None ) { return Err("DSH skills require the deepseek-harness agent".to_string()); } @@ -333,7 +398,7 @@ pub async fn stop_agent_stream( tracing::info!( "[Command] stop_agent_stream called for thread: {}, agent_type: {}, run_id: {}", threadId, - runtime.map(AgentRuntime::key).unwrap_or("unknown"), + runtime.map(AgentRuntime::id).unwrap_or("unknown"), runId.as_deref().unwrap_or("") ); let signalled = match runtime { @@ -463,3 +528,73 @@ pub async fn codex_slash_command( ) .await } + +#[cfg(test)] +mod workspace_tests { + use super::*; + use flowix_core::memo_file::{ + NotebookAddDir, NotebookConfig, NotebookWorkspaceConfig, NotebookWorkspaceSnapshot, + }; + #[test] + fn runtime_paths_follow_notebook_mounts_and_protect_internal_storage() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notes"); + let repo = temp.path().join("repo"); + std::fs::create_dir_all(root.join(".flowix")).unwrap(); + std::fs::create_dir(&repo).unwrap(); + let root = root.to_string_lossy().into_owned(); + let repo = repo.to_string_lossy().into_owned(); + let mut workspace = NotebookWorkspaceSnapshot { + notebooks: vec![NotebookConfig { + id: "one".into(), + name: "One".into(), + icon: None, + path: root.clone(), + is_default: true, + sort: 0, + created_at: 0, + updated_at: 0, + }], + notebook_configs: HashMap::from([( + "one".into(), + NotebookWorkspaceConfig { + version: 1, + revision: 1, + add_dirs: vec![NotebookAddDir { + id: "repo".into(), + path: repo.clone(), + label: "Repo".into(), + enabled: true, + missing: false, + }], + }, + )]), + }; + assert!(check_workspace_paths( + &workspace, + Some("one"), + [root.as_str(), repo.as_str()].into_iter() + ) + .is_ok()); + assert!( + check_workspace_paths(&workspace, Some("other"), [repo.as_str()].into_iter()).is_err() + ); + let internal = Path::new(&root) + .join(".flowix") + .to_string_lossy() + .into_owned(); + assert!( + check_workspace_paths(&workspace, Some("one"), [internal.as_str()].into_iter()) + .is_err() + ); + workspace + .notebook_configs + .get_mut("one") + .unwrap() + .add_dirs + .clear(); + assert!( + check_workspace_paths(&workspace, Some("one"), [repo.as_str()].into_iter()).is_err() + ); + } +} diff --git a/app/flowix-desktop/src/commands/agent/external_config.rs b/app/flowix-desktop/src/commands/agent/external_config.rs index 45afa46df..4a9e416c8 100644 --- a/app/flowix-desktop/src/commands/agent/external_config.rs +++ b/app/flowix-desktop/src/commands/agent/external_config.rs @@ -12,26 +12,66 @@ use crate::app::state::AppState; const CODEX_VERSION_TOO_LOW_CODE: &str = "version-too-low"; const CODEX_VERSION_TOO_LOW_REASON: &str = "Codex CLI version below 0.150.0"; const MIN_CODEX_CLI_VERSION: (u64, u64, u64) = (0, 150, 0); +const STARTUP_CLI_DETECTION_WAIT: std::time::Duration = std::time::Duration::from_secs(30); + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum InstallationState { + Unknown, + Installed, + Missing, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ReadinessState { + Unknown, + Ready, + Blocked, +} #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AgentRuntimeAvailability { - available: bool, - installed: bool, +pub struct AgentRuntimeSnapshot { + installation: InstallationState, + readiness: ReadinessState, reason_code: Option, reason: Option, } +impl AgentRuntimeSnapshot { + fn new( + installation: InstallationState, + readiness: ReadinessState, + reason_code: Option<&str>, + reason: Option, + ) -> Self { + Self { + installation, + readiness, + reason_code: reason_code.map(str::to_string), + reason, + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeAgents { + pi: AgentRuntimeSnapshot, + codex: AgentRuntimeSnapshot, + #[serde(rename = "claudecode")] + claude_code: AgentRuntimeSnapshot, + opencode: AgentRuntimeSnapshot, + hermes: AgentRuntimeSnapshot, + #[serde(rename = "dsh")] + dsh: AgentRuntimeSnapshot, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeStatus { - codex: AgentRuntimeAvailability, - claude: AgentRuntimeAvailability, - hermes: AgentRuntimeAvailability, - opencode: AgentRuntimeAvailability, - pi: AgentRuntimeAvailability, - #[serde(rename = "deepseek-harness")] - deepseek_harness: AgentRuntimeAvailability, + agents: AgentRuntimeAgents, } fn executable_available(path: &Path) -> bool { @@ -50,24 +90,37 @@ fn executable_available(path: &Path) -> bool { /// 基于 `agent-external-config` 里�?录的 path 算单�?external agent 的可用性�?/// `path = None` -> �?���?(�?��探测没探�?; `path = Some` 但失�?-> not found; /// �?�� -> `None` (调用方再叠加 preflight 错�?, �?codex �?Node 依赖)�? -fn external_availability(entry: AgentExternalEntry, label: &str) -> AgentRuntimeAvailability { +fn external_availability(entry: AgentExternalEntry, label: &str) -> AgentRuntimeSnapshot { let available = entry .path .as_ref() .map(|p| executable_available(p)) .unwrap_or(false); - let reason = match &entry.path { - None => Some(format!( - "{label} not configured (click Redetect in preferences)" - )), - Some(p) if !available => Some(format!("{label} not found ({})", p.display())), - Some(_) => None, - }; - AgentRuntimeAvailability { - available, - installed: available, - reason_code: None, - reason, + match (&entry.path, available, entry.detected) { + (None, _, false) => AgentRuntimeSnapshot::new( + InstallationState::Unknown, + ReadinessState::Unknown, + Some("path-not-detected"), + Some(format!("{label} path has not been detected")), + ), + (None, _, true) => AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("runtime-not-installed"), + Some(format!("{label} was not found")), + ), + (Some(path), false, _) => AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("configured-path-missing"), + Some(format!("{label} not found ({})", path.display())), + ), + (Some(_), true, _) => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Ready, + None, + None, + ), } } @@ -105,67 +158,70 @@ pub async fn agent_runtime_status( app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { + let startup_detection_finished = tokio::time::timeout( + STARTUP_CLI_DETECTION_WAIT, + state.agent_external_config.wait_for_startup_detect(), + ) + .await + .is_ok(); + if !startup_detection_finished { + tracing::warn!( + wait_seconds = STARTUP_CLI_DETECTION_WAIT.as_secs(), + "startup CLI detection did not finish before the runtime status deadline" + ); + } // The external CLI path comes from agent-external-config.json. Runtime // preflight can still add dependency details without hiding the entry. let cfg = &state.agent_external_config; let mut codex = external_availability(cfg.get_entry("codex"), "Codex CLI"); - if codex.available { + if matches!(codex.installation, InstallationState::Installed) { if codex_version_is_too_low().await { - codex.available = false; + codex.readiness = ReadinessState::Blocked; codex.reason_code = Some(CODEX_VERSION_TOO_LOW_CODE.to_string()); codex.reason = Some(CODEX_VERSION_TOO_LOW_REASON.to_string()); } else { - codex.reason = crate::agent_external::codex::preflight_codex().err(); + if let Err(reason) = crate::agent_external::codex::preflight_codex() { + codex.readiness = ReadinessState::Blocked; + codex.reason_code = Some("preflight-failed".to_string()); + codex.reason = Some(reason); + } } } - let claude = external_availability(cfg.get_entry("claude"), "Claude Code CLI"); + let claude_code = external_availability(cfg.get_entry("claudecode"), "Claude Code CLI"); let hermes = external_availability(cfg.get_entry("hermes"), "Hermes Agent CLI"); let opencode = external_availability(cfg.get_entry("opencode"), "OpenCode CLI"); - let pi = { - use tauri::Manager; - let platform = match std::env::consts::OS { - "macos" => "darwin", - other => other, - }; - let bundled = app.path().resource_dir().ok().is_some_and(|resources| { - let name = if cfg!(windows) { "pi.exe" } else { "pi" }; - let arch = match std::env::consts::ARCH { - "aarch64" => "arm64", - "x86_64" => "x64", - other => other, - }; - let root = resources.join("pi").join(format!("{platform}-{arch}")); - root.join("dist").join(name).is_file() || root.join(name).is_file() - }); - let env_path_available = std::env::var_os("PI_CLI_PATH") - .map(PathBuf::from) - .as_deref() - .is_some_and(crate::agent_external::cli_resolver::is_executable_file); - let path_available = crate::agent_external::cli_resolver::which_in_path( - if cfg!(windows) { "pi.exe" } else { "pi" }, - std::env::var_os("PATH").as_deref(), + let pi = if crate::agent_external::pi::resolve_pi_binary(&app).is_ok() { + AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Ready, + None, + None, + ) + } else { + AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("runtime-not-installed"), + Some("The bundled Pi runtime is missing or not executable".to_string()), ) - .is_some(); - let installed_flowix = crate::agent_external::pi::installed_flowix_pi_binary().is_some(); - let available = bundled || env_path_available || path_available || installed_flowix; - AgentRuntimeAvailability { - available, - installed: available, - reason_code: None, - reason: (!available) - .then(|| "Pi runtime not installed (or not included in this build)".to_string()), - } }; let dsh_status = crate::dsh::status(); let deepseek_harness = if !dsh_status.installed { - AgentRuntimeAvailability { - available: false, - installed: false, - reason_code: None, - reason: dsh_status + AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("runtime-not-installed"), + dsh_status .message .or_else(|| Some("DeepSeek Harness runtime is not installed".to_string())), - } + ) + } else if !dsh_status.app_server_profile_installed { + AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Blocked, + Some("appserver-profile-not-installed"), + Some("Install the DSH App Server plugin from Flowix Preferences".to_string()), + ) } else { match state.deepseek_harness.dsh_model_configs().await { Ok(configs) @@ -177,41 +233,43 @@ pub async fn agent_runtime_status( .is_ok() }) => { - AgentRuntimeAvailability { - available: true, - installed: true, - reason_code: None, - reason: None, - } + AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Ready, + None, + None, + ) } - Ok(configs) if configs.is_empty() => AgentRuntimeAvailability { - available: false, - installed: true, - reason_code: None, - reason: Some("No DeepSeek Harness model is configured".to_string()), - }, - Ok(_) => AgentRuntimeAvailability { - available: false, - installed: true, - reason_code: None, - reason: Some("DeepSeek Harness has no usable model configuration".to_string()), - }, - Err(error) => AgentRuntimeAvailability { - available: false, - installed: true, - reason_code: None, - reason: Some(format!("Could not read DeepSeek Harness models: {error}")), - }, + Ok(configs) if configs.is_empty() => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Blocked, + Some("no-model-configured"), + Some("No DeepSeek Harness model is configured".to_string()), + ), + Ok(_) => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Blocked, + Some("no-usable-model"), + Some("DeepSeek Harness has no usable model configuration".to_string()), + ), + Err(error) => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Unknown, + Some("status-check-failed"), + Some(format!("Could not read DeepSeek Harness models: {error}")), + ), } }; Ok(AgentRuntimeStatus { - codex, - claude, - hermes, - opencode, - pi, - deepseek_harness, + agents: AgentRuntimeAgents { + pi, + codex, + claude_code, + opencode, + hermes, + dsh: deepseek_harness, + }, }) } diff --git a/app/flowix-desktop/src/commands/agent/flowix_instructions.rs b/app/flowix-desktop/src/commands/agent/flowix_instructions.rs index d493877a2..3f869b9ce 100644 --- a/app/flowix-desktop/src/commands/agent/flowix_instructions.rs +++ b/app/flowix-desktop/src/commands/agent/flowix_instructions.rs @@ -64,7 +64,7 @@ pub(crate) fn sync_native_agent_instructions( agent_type: &str, workspace_paths: &[String], ) -> Result<(), String> { - if !matches!(agent_type, "codex" | "deepseek-harness" | "pi") { + if !matches!(agent_type, "codex" | "dsh" | "pi") { return Ok(()); } if !cwd.is_dir() { diff --git a/app/flowix-desktop/src/commands/agent/model_catalog.rs b/app/flowix-desktop/src/commands/agent/model_catalog.rs index 6cf4cd870..127bb9edc 100644 --- a/app/flowix-desktop/src/commands/agent/model_catalog.rs +++ b/app/flowix-desktop/src/commands/agent/model_catalog.rs @@ -33,7 +33,7 @@ pub async fn agent_supported_models( "codex" => state.codex_app_server.supported_models().await, "opencode" => state.opencode.supported_models().await, "pi" => state.pi.supported_models(&app).await, - "deepseek-harness" | "deepseek_harness" | "dsh" => { + "dsh" => { state.deepseek_harness.supported_models().await } _ => Ok(Vec::new()), diff --git a/app/flowix-desktop/src/commands/agent/runtime.rs b/app/flowix-desktop/src/commands/agent/runtime.rs index dda9e65f1..cdbc02121 100644 --- a/app/flowix-desktop/src/commands/agent/runtime.rs +++ b/app/flowix-desktop/src/commands/agent/runtime.rs @@ -39,9 +39,9 @@ pub(crate) async fn start_plugin_chat( app_handle: &tauri::AppHandle, ) -> Result<(), String> { let runtime = runtime_from_message(&message)?; - if let Some(cwd) = message.cwd_for_runtime(runtime.key()) { - let workspace_paths = message.workspace_paths_for_runtime(runtime.key()); - sync_native_agent_instructions(std::path::Path::new(cwd), runtime.key(), &workspace_paths)?; + if let Some(cwd) = message.cwd_for_runtime(runtime.id()) { + let workspace_paths = message.workspace_paths_for_runtime(runtime.id()); + sync_native_agent_instructions(std::path::Path::new(cwd), runtime.id(), &workspace_paths)?; } state.external_runtimes .dispatch_chat(thread_id, runtime, message, app_handle) diff --git a/app/flowix-desktop/src/commands/agent_access.rs b/app/flowix-desktop/src/commands/agent_access.rs deleted file mode 100644 index 7db72ba6c..000000000 --- a/app/flowix-desktop/src/commands/agent_access.rs +++ /dev/null @@ -1,633 +0,0 @@ -//! Agent 访问�?�� IPC —读写 `~/.flowix/agent-access.json`�?//! -//! �?`commands::settings` 同形: 写操作成功后 emit `agent-access-changed` -//! 事件, 其它窗口�?React 树收到后从�?盘重�?load�?前�? `set_agent_access` -//! 走乐观更�?(改本地后�?await), 失败�?store �?`loadInitial` 回滚 ── -//! 瑙?`app/flowix-web/lib/store/agent-access-store.ts`銆? -use std::collections::HashMap; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use crate::events as dispatcher; -use tauri::{AppHandle, State}; - -use crate::config::{AgentAccessConfig, AgentAccessEntry, AgentAccessKind}; - -use crate::app::state::AppState; - -/// 跨窗口同步事�?── 任一窗口成功写入 agent-access.json �?emit, 其它窗口 -/// 收到后从磁盘重新 load�?payload �?`()` (�?payload), 监听者直�?/// `loadInitial()` 拉整�?config ── 比按 entry diff 简单且不会错过任何字�?�? -pub(super) const AGENT_ACCESS_CHANGED_EVENT: &str = "agent-access-changed"; - -const NOTEBOOK_AGENT_CONFIG_VERSION: u32 = 1; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NotebookAddDir { - pub id: String, - pub path: String, - pub label: String, - #[serde(default = "default_true")] - pub enabled: bool, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NotebookAgentConfig { - pub version: u32, - #[serde(default)] - pub revision: u64, - #[serde(default)] - pub add_dirs: Vec, -} - -impl Default for NotebookAgentConfig { - fn default() -> Self { - Self { - version: NOTEBOOK_AGENT_CONFIG_VERSION, - revision: 0, - add_dirs: Vec::new(), - } - } -} - -fn default_true() -> bool { - true -} - -fn notebook_agent_path(root: &Path) -> PathBuf { - root.join(".flowix").join("agent.json") -} - -fn read_notebook_agent_config(root: &Path) -> Result, String> { - let flowix = root.join(".flowix"); - if fs::symlink_metadata(&flowix) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Flowix directory is a symbolic link: {}", - flowix.display() - )); - } - let path = notebook_agent_path(root); - if fs::symlink_metadata(&path) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Notebook agent config is a symbolic link: {}", - path.display() - )); - } - if !path.exists() { - return Ok(None); - } - let content = - fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - let config: NotebookAgentConfig = serde_json::from_str(&content) - .map_err(|error| format!("parse {}: {error}", path.display()))?; - if config.version != NOTEBOOK_AGENT_CONFIG_VERSION { - return Err(format!( - "unsupported notebook agent config version {} in {}", - config.version, - path.display() - )); - } - Ok(Some(config)) -} - -fn validate_notebook_agent_config( - root: &Path, - config: &mut NotebookAgentConfig, -) -> Result<(), String> { - let notebook_canonical = fs::canonicalize(root).map_err(|error| error.to_string())?; - let mut seen = std::collections::HashSet::new(); - for directory in &mut config.add_dirs { - directory.path = directory - .path - .trim() - .trim_end_matches(['/', '\\']) - .to_string(); - if directory.path.is_empty() - || !Path::new(&directory.path).is_absolute() - || !Path::new(&directory.path).is_dir() - { - return Err(format!("add-dir is unavailable: {}", directory.path)); - } - let canonical = fs::canonicalize(&directory.path).map_err(|error| error.to_string())?; - if canonical == notebook_canonical { - return Err("the notebook itself cannot be an add-dir".to_string()); - } - if notebook_canonical.starts_with(&canonical) { - return Err(format!( - "an ancestor of the notebook cannot be an add-dir: {}", - directory.path - )); - } - let key = canonical.to_string_lossy().to_lowercase(); - if !seen.insert(key) { - return Err(format!("duplicate add-dir: {}", directory.path)); - } - if directory.id.trim().is_empty() { - directory.id = format!("dir_{}", nanoid::nanoid!(8)); - } - if directory.label.trim().is_empty() { - directory.label = Path::new(&directory.path) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(&directory.path) - .to_string(); - } - } - Ok(()) -} - -fn check_notebook_revision(expected: u64, current: u64) -> Result<(), String> { - if expected != current { - return Err(format!( - "notebook agent config conflict: expected revision {expected}, found {current}" - )); - } - Ok(()) -} - -fn write_notebook_agent_config(root: &Path, config: &NotebookAgentConfig) -> Result<(), String> { - let flowix = root.join(".flowix"); - if fs::symlink_metadata(&flowix) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Flowix directory is a symbolic link: {}", - flowix.display() - )); - } - fs::create_dir_all(&flowix).map_err(|error| format!("create {}: {error}", flowix.display()))?; - let path = notebook_agent_path(root); - if fs::symlink_metadata(&path) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Notebook agent config is a symbolic link: {}", - path.display() - )); - } - let temporary = flowix.join("agent.json.tmp"); - if fs::symlink_metadata(&temporary) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Notebook agent temporary file is a symbolic link: {}", - temporary.display() - )); - } - let content = serde_json::to_vec_pretty(config).map_err(|error| error.to_string())?; - { - let mut file = fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&temporary) - .map_err(|error| format!("open {}: {error}", temporary.display()))?; - file.write_all(&content) - .and_then(|_| file.sync_all()) - .map_err(|error| format!("write {}: {error}", temporary.display()))?; - } - fs::rename(&temporary, &path).map_err(|error| format!("replace {}: {error}", path.display())) -} - -fn legacy_add_dirs(config: &AgentAccessConfig, notebook_id: &str) -> Vec { - let Some(defaults) = config.defaults.as_ref() else { - return Vec::new(); - }; - let Some(files) = defaults.get("files") else { - return Vec::new(); - }; - let selected = if files.get("folders").is_some() { - files - } else { - files - .get(notebook_id) - .or_else(|| files.get("_global")) - .unwrap_or(&serde_json::Value::Null) - }; - selected - .get("folders") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty()) - .enumerate() - .map(|(index, path)| NotebookAddDir { - id: format!("legacy_{}", index + 1), - path: path.trim_end_matches(['/', '\\']).to_string(), - label: Path::new(path) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(path) - .to_string(), - enabled: true, - }) - .collect() -} - -fn load_or_migrate_notebook_agent_config( - root: &Path, - notebook_id: &str, - legacy: &AgentAccessConfig, -) -> Result { - if let Some(config) = read_notebook_agent_config(root)? { - return Ok(config); - } - let mut config = NotebookAgentConfig { - version: NOTEBOOK_AGENT_CONFIG_VERSION, - revision: 1, - add_dirs: legacy_add_dirs(legacy, notebook_id), - }; - // Legacy defaults were user-editable JSON and may contain stale or unsafe - // paths. Migrate only entries that pass the same validation as new writes; - // never widen access merely because an old file listed a directory. - let mut safe_dirs = Vec::with_capacity(config.add_dirs.len()); - for directory in config.add_dirs.drain(..) { - let mut candidate = NotebookAgentConfig { - version: config.version, - revision: config.revision, - add_dirs: vec![directory], - }; - if validate_notebook_agent_config(root, &mut candidate).is_ok() { - safe_dirs.extend(candidate.add_dirs); - } - } - config.add_dirs = safe_dirs; - write_notebook_agent_config(root, &config)?; - Ok(config) -} - -fn remove_legacy_file_defaults(config: &mut AgentAccessConfig) -> bool { - let removed = config - .defaults - .as_mut() - .and_then(serde_json::Value::as_object_mut) - .and_then(|defaults| defaults.remove("files")) - .is_some(); - if removed - && config - .defaults - .as_ref() - .and_then(serde_json::Value::as_object) - .is_some_and(|defaults| defaults.is_empty()) - { - config.defaults = None; - } - removed -} - -#[tauri::command] -pub fn get_notebook_agent_configs( - state: State, - app: AppHandle, -) -> Result, String> { - let notebooks = state - .memo_file - .read() - .map_err(|_| "memo file lock poisoned".to_string())? - .read_notebook_configs() - .map_err(|error| error.to_string())?; - let legacy = state.agent_access.get_config(); - let mut result = HashMap::new(); - let mut all_notebooks_migrated = true; - for notebook in notebooks { - let root = PathBuf::from(¬ebook.path); - if !root.is_dir() { - all_notebooks_migrated = false; - continue; - } - let config = load_or_migrate_notebook_agent_config(&root, ¬ebook.id, &legacy)?; - result.insert(notebook.id, config); - } - // Once every registered, existing notebook has a local config, remove the - // obsolete global files defaults. Runtime defaults remain untouched. This - // is deliberately skipped when a notebook path is unavailable so a - // partially migrated installation keeps its rollback source. - if all_notebooks_migrated && !result.is_empty() { - let mut cleaned = legacy.clone(); - let removed = remove_legacy_file_defaults(&mut cleaned); - if removed { - state - .agent_access - .replace_config(cleaned) - .map_err(|error| format!("remove legacy file defaults: {error}"))?; - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - } - } - Ok(result) -} - -#[tauri::command] -pub fn set_notebook_agent_config( - notebook_id: String, - expected_revision: u64, - mut config: NotebookAgentConfig, - state: State, - app: AppHandle, -) -> Result { - let notebook = state - .memo_file - .read() - .map_err(|_| "memo file lock poisoned".to_string())? - .get_notebook_config_by_id(¬ebook_id) - .ok_or_else(|| format!("notebook not found: {notebook_id}"))?; - let root = PathBuf::from(notebook.path); - let current = read_notebook_agent_config(&root)?.unwrap_or_default(); - check_notebook_revision(expected_revision, current.revision)?; - config.version = NOTEBOOK_AGENT_CONFIG_VERSION; - config.revision = current.revision.saturating_add(1); - validate_notebook_agent_config(&root, &mut config)?; - write_notebook_agent_config(&root, &config)?; - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - Ok(config) -} - -/// 拉取当前 agent_access 整份 config�?每�?都从 store �? `missing` 字�? -/// �?`get_config` 内重新算, 失联�?��会立刻拿到最�?disk 状态�? -#[tauri::command] -pub fn get_agent_access(state: State) -> AgentAccessConfig { - state.agent_access.get_config() -} - -/// 用整份新 config 覆盖 (前�?走乐观更�? 整份 set 避免一�?IPC 一份的 -/// 复杂协�?)�?先落�? �?emit, 成功才更新内�?(�?user_config �?set -/// �?��完全对齐)�? -#[tauri::command] -pub fn set_agent_access( - config: AgentAccessConfig, - state: State, - app: AppHandle, -) -> Result<(), String> { - state - .agent_access - .replace_config(config) - .map(|_| { - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - Ok(()) - }) - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn add_agent_access_folder_from_picker( - app: AppHandle, - state: State<'_, AppState>, -) -> Result, String> { - let picked = crate::commands::dialog::select_directory(app.clone()).await; - let Some(path) = picked else { - return Ok(None); - }; - let trimmed = path.trim_end_matches(|c| c == '/' || c == '\\').to_string(); - if trimmed.is_empty() { - return Ok(None); - } - - let mut config = state.agent_access.get_config(); - if let Some(existing) = reusable_tracked_folder(&config, &trimmed)? { - // Folder entries form a global metadata/bookmark pool while - // notebook `.flowix/agent.json` owns per-notebook attachments. - // Returning the existing folder lets a removed folder be attached - // again and lets multiple notebooks reference the same directory. - return Ok(Some(existing)); - } - - let now = chrono::Utc::now().timestamp_millis(); - let name = Path::new(&trimmed) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(&trimmed) - .to_string(); - let entry = AgentAccessEntry { - id: format!("fld_{}", nanoid::nanoid!(6)), - kind: AgentAccessKind::Folder, - path: trimmed, - name, - enabled: true, - workspace: false, - added_at: now, - updated_at: now, - missing: false, - }; - config.entries.push(entry.clone()); - state - .agent_access - .replace_config(config) - .map_err(|e| format!("agent access persist failed: {e}"))?; - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - Ok(Some(entry)) -} - -#[cfg(test)] -mod notebook_agent_tests { - use super::*; - - fn legacy_with_folder(path: &str) -> AgentAccessConfig { - AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(serde_json::json!({ - "files": { "folders": [path] } - })), - } - } - - #[test] - fn migrates_legacy_defaults_into_notebook_config() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let add_dir = temp.path().join("资料"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&add_dir).unwrap(); - let config = load_or_migrate_notebook_agent_config( - ¬ebook, - "nb-1", - &legacy_with_folder(add_dir.to_str().unwrap()), - ) - .unwrap(); - assert_eq!(config.revision, 1); - assert_eq!(config.add_dirs.len(), 1); - assert_eq!(config.add_dirs[0].path, add_dir.to_string_lossy()); - assert!(notebook.join(".flowix/agent.json").is_file()); - } - - #[test] - fn migration_skips_unsafe_legacy_paths() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let safe = temp.path().join("资料"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&safe).unwrap(); - let legacy = AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(serde_json::json!({ - "files": { "folders": [notebook, safe] } - })), - }; - let config = load_or_migrate_notebook_agent_config(¬ebook, "nb-1", &legacy).unwrap(); - assert_eq!(config.add_dirs.len(), 1); - assert_eq!(config.add_dirs[0].path, safe.to_string_lossy()); - } - - #[test] - fn removes_only_legacy_file_defaults_and_keeps_runtime_defaults() { - let mut config = AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(serde_json::json!({ - "files": { "folders": ["/old"] }, - "runtime": { "codex": { "model": { "key": "gpt" } } } - })), - }; - assert!(remove_legacy_file_defaults(&mut config)); - let defaults = config.defaults.unwrap(); - assert!(defaults.get("files").is_none()); - assert!(defaults.get("runtime").is_some()); - } - - #[test] - fn rejects_notebook_and_ancestor_add_dirs() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("root/notebook"); - fs::create_dir_all(¬ebook).unwrap(); - for path in [¬ebook, notebook.parent().unwrap()] { - let mut config = NotebookAgentConfig::default(); - config.add_dirs.push(NotebookAddDir { - id: "x".into(), - path: path.to_string_lossy().into_owned(), - label: String::new(), - enabled: true, - }); - let error = validate_notebook_agent_config(¬ebook, &mut config).unwrap_err(); - assert!(error.contains("cannot be an add-dir")); - } - } - - #[test] - fn rejects_duplicate_add_dirs_and_revision_is_detected_before_write() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let add_dir = temp.path().join("资料"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&add_dir).unwrap(); - let mut config = NotebookAgentConfig::default(); - config.add_dirs = vec![ - NotebookAddDir { - id: "a".into(), - path: add_dir.to_string_lossy().into_owned(), - label: String::new(), - enabled: true, - }, - NotebookAddDir { - id: "b".into(), - path: add_dir.to_string_lossy().into_owned(), - label: String::new(), - enabled: true, - }, - ]; - assert!(validate_notebook_agent_config(¬ebook, &mut config) - .unwrap_err() - .contains("duplicate add-dir")); - let persisted = NotebookAgentConfig { - revision: 3, - ..Default::default() - }; - write_notebook_agent_config(¬ebook, &persisted).unwrap(); - let current = read_notebook_agent_config(¬ebook).unwrap().unwrap(); - let error = check_notebook_revision(2, current.revision).unwrap_err(); - assert!(error.contains("conflict")); - } - - #[cfg(unix)] - #[test] - fn rejects_symlink_flowix_directory() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let target = temp.path().join("target"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&target).unwrap(); - std::os::unix::fs::symlink(&target, notebook.join(".flowix")).unwrap(); - let error = - write_notebook_agent_config(¬ebook, &NotebookAgentConfig::default()).unwrap_err(); - assert!(error.contains("symbolic link")); - } -} - -fn reusable_tracked_folder( - config: &AgentAccessConfig, - path: &str, -) -> Result, String> { - let comparable = path - .trim_end_matches(|c| c == '/' || c == '\\') - .to_ascii_lowercase(); - let Some(existing) = config.entries.iter().find(|entry| { - entry - .path - .trim_end_matches(|c| c == '/' || c == '\\') - .to_ascii_lowercase() - == comparable - }) else { - return Ok(None); - }; - if existing.kind == AgentAccessKind::Folder { - Ok(Some(existing.clone())) - } else { - Err("path already tracked as notebook".to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entry(kind: AgentAccessKind, path: &str) -> AgentAccessEntry { - AgentAccessEntry { - id: "entry".to_string(), - kind, - path: path.to_string(), - name: "Entry".to_string(), - enabled: true, - workspace: false, - added_at: 1, - updated_at: 1, - missing: false, - } - } - - #[test] - fn existing_folder_is_reusable_across_notebooks_and_after_removal() { - let config = AgentAccessConfig { - version: 1, - entries: vec![entry(AgentAccessKind::Folder, "/tmp/reference")], - defaults: None, - }; - - let reused = reusable_tracked_folder(&config, "/tmp/reference/") - .unwrap() - .expect("folder should be reused"); - - assert_eq!(reused.path, "/tmp/reference"); - } - - #[test] - fn notebook_path_is_not_reused_as_folder_metadata() { - let config = AgentAccessConfig { - version: 1, - entries: vec![entry(AgentAccessKind::Notebook, "/tmp/notebook")], - defaults: None, - }; - - assert!(reusable_tracked_folder(&config, "/tmp/notebook").is_err()); - } -} diff --git a/app/flowix-desktop/src/commands/agent_workspace.rs b/app/flowix-desktop/src/commands/agent_workspace.rs new file mode 100644 index 000000000..61c4edd1c --- /dev/null +++ b/app/flowix-desktop/src/commands/agent_workspace.rs @@ -0,0 +1,87 @@ +//! Notebook workspace IPC. All active data is stored in db/local.db. +use crate::{app::state::AppState, events as dispatcher}; +use flowix_core::memo_file::NotebookWorkspaceSnapshot; +use tauri::{AppHandle, State}; + +pub(crate) const AGENT_WORKSPACE_CHANGED_EVENT: &str = "agent-workspace-changed"; + +#[tauri::command] +pub fn get_agent_workspace(state: State) -> Result { + state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned".to_string())? + .read_notebook_workspace() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_legacy_agent_preferences( + state: State, +) -> Result, String> { + state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned".to_string())? + .read_legacy_agent_runtime() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn attach_notebook_directory( + notebook_id: String, + path: String, + state: State, + app: AppHandle, +) -> Result { + update(&state, &app, ¬ebook_id, &path, true) +} + +#[tauri::command] +pub fn detach_notebook_directory( + notebook_id: String, + path: String, + state: State, + app: AppHandle, +) -> Result { + update(&state, &app, ¬ebook_id, &path, false) +} + +#[tauri::command] +pub async fn pick_notebook_directory( + notebook_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result, String> { + // Reject an invalid scope before showing a picker or creating any state. + if state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned")? + .get_notebook_config_by_id(¬ebook_id) + .is_none() + { + return Err("notebook not found".into()); + } + let Some(path) = super::dialog::select_directory(app.clone()).await else { + return Ok(None); + }; + update(&state, &app, ¬ebook_id, &path, true).map(Some) +} + +fn update( + state: &AppState, + app: &AppHandle, + notebook_id: &str, + path: &str, + attach: bool, +) -> Result { + let snapshot = state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned")? + .update_notebook_add_dir(notebook_id, path, attach) + .map_err(|e| e.to_string())?; + dispatcher::emit_to(app, AGENT_WORKSPACE_CHANGED_EVENT, ()); + Ok(snapshot) +} diff --git a/app/flowix-desktop/src/commands/artifact.rs b/app/flowix-desktop/src/commands/artifact.rs index edaa2af41..23124aa8b 100644 --- a/app/flowix-desktop/src/commands/artifact.rs +++ b/app/flowix-desktop/src/commands/artifact.rs @@ -7,8 +7,8 @@ use crate::app::state::AppState; /// can be removed while its artifact remains a readable notebook document. #[tauri::command] pub fn artifact_resolve( - memo_id: String, + note_path: String, state: State, ) -> Result { - crate::artifact::resolve(&memo_id, &state.memo_file) + crate::artifact::resolve(¬e_path, &state.memo_file) } diff --git a/app/flowix-desktop/src/commands/boot.rs b/app/flowix-desktop/src/commands/boot.rs index 659283e0a..308beac92 100644 --- a/app/flowix-desktop/src/commands/boot.rs +++ b/app/flowix-desktop/src/commands/boot.rs @@ -2,7 +2,7 @@ use serde::Serialize; use std::sync::Arc; use tauri::{Manager, State}; -use crate::app::startup::StartupStatus; +use crate::app::startup::{StartupPhase, StartupStatus}; use crate::app::state::AppState; use crate::device_registration::DeviceRegistry; @@ -11,7 +11,6 @@ use crate::device_registration::DeviceRegistry; pub struct BootFeatures { pub experimental: bool, pub is_introduct_displayed: bool, - pub is_onboarding_completed: bool, } #[tauri::command] @@ -19,7 +18,6 @@ pub fn get_boot_features(registry: State<'_, Arc>) -> BootFeatur BootFeatures { experimental: registry.experimental(), is_introduct_displayed: registry.is_introduct_displayed(), - is_onboarding_completed: registry.is_onboarding_completed(), } } @@ -29,10 +27,35 @@ pub fn set_boot_intro_displayed(registry: State<'_, Arc>) -> Res } #[tauri::command] -pub fn set_boot_onboarding_completed( +pub fn get_version_initialization_completed( registry: State<'_, Arc>, +) -> Result { + if !crate::commands::product::is_dev_build() { + return Err( + "version initialization settings are only available in a development build".to_string(), + ); + } + Ok(registry.is_version_initialization_complete()) +} + +#[tauri::command] +pub fn set_version_initialization_completed( + completed: bool, + registry: State<'_, Arc>, + state: State<'_, AppState>, ) -> Result<(), String> { - registry.set_onboarding_completed(true) + if !crate::commands::product::is_dev_build() { + return Err( + "version initialization settings are only available in a development build".to_string(), + ); + } + if matches!( + state.startup.status().phase, + StartupPhase::Pending | StartupPhase::Running + ) { + return Err("version initialization cannot be edited while startup is running".to_string()); + } + registry.set_version_initialization_completed(completed) } #[tauri::command] @@ -60,15 +83,20 @@ pub fn record_startup_stage( const ALLOWED: &[&str] = &[ "native-ready", "notebook-selected", - "first-memo-query-ready", - "workspace-restored", - "initial-document-settled", - "initial-document-skipped", - "initial-document-superseded", - "initial-document-timeout", "first-workspace-paint", + "stage3-admitted", + "workspace-restore-settled", + "workspace-restore-superseded", + "foreground-degraded-paint", + "foreground-failed-paint", ]; - if !ALLOWED.contains(&stage.as_str()) { + // Keep task-event identifiers bounded; task IDs contain only ASCII letters + // and separators. + let task_event = stage.starts_with("task:") + && stage.len() <= 96 + && stage.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_:".contains(&b)) + && [":start", ":complete", ":failed"].iter().any(|suffix| stage.ends_with(*suffix)); + if !ALLOWED.contains(&stage.as_str()) && !task_event { return; } if startup_attempt_id.len() > 64 || !startup_attempt_id.is_ascii() { @@ -94,3 +122,8 @@ pub async fn wait_for_startup_ready(app: tauri::AppHandle) -> Result<(), String> .await .map_err(|error| format!("startup wait task failed: {error}"))? } + +#[tauri::command] +pub fn retry_startup(state: State<'_, AppState>) -> Result<(), String> { + state.startup.request_retry() +} diff --git a/app/flowix-desktop/src/commands/cloud.rs b/app/flowix-desktop/src/commands/cloud.rs index e22840277..964846f2a 100644 --- a/app/flowix-desktop/src/commands/cloud.rs +++ b/app/flowix-desktop/src/commands/cloud.rs @@ -1,39 +1,35 @@ use std::collections::{HashMap, HashSet}; mod coordinator; mod deletion; -mod local_adapter; mod google_login; -use google_login::PendingGoogleLogin; -pub(crate) use google_login::GoogleCallback; +mod local_adapter; pub(crate) use coordinator::{ schedule_notebook_sync, schedule_notebook_sync_observation, start_cloud_sync_polling, }; +pub(crate) use google_login::GoogleCallback; +use google_login::PendingGoogleLogin; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use chrono::Utc; -use flowix_core::memo_file::{ - notebook_path_from_relative, IsMd, -}; +use flowix_core::memo_file::{notebook_path_from_relative, notebook_relative_path, IsMd}; use flowix_sync::{ - v2_content_hash, v2_local_content_diverged, CloudCheckout, - CloudMembership, CloudNotebook, CloudProduct, CloudState, SyncError, V2AccountSyncReport, - V2LocalNote, V2LocalNotebook, V2RemoteApply, V2SyncedNotebook, + v2_content_hash, v2_local_content_diverged, CloudAiCreditBalance, CloudCheckout, + CloudMembership, CloudNotebook, CloudOrder, CloudOrderCheck, CloudProduct, CloudState, + SyncError, V2AccountSyncReport, V2LocalNote, V2LocalNotebook, V2RemoteApply, V2SyncedNotebook, }; +use once_cell::sync::Lazy; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use tokio::sync::{mpsc, oneshot}; use tokio::time::Instant; -use once_cell::sync::Lazy; -static GOOGLE_OAUTH_LOGIN: Lazy>> = - Lazy::new(|| Mutex::new(None)); +static GOOGLE_OAUTH_LOGIN: Lazy>> = Lazy::new(|| Mutex::new(None)); use crate::app::state::AppState; use crate::lock_utils::read_lock; -use crate::memo_events::{self, MemoChangeSource, MemoDerivedChanged, MemoEvent}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -163,21 +159,36 @@ pub async fn cloud_start_google_sign_in(app: AppHandle) -> Result<(), String> { use base64::Engine; use sha2::{Digest, Sha256}; - let generation = app.state::().cloud_sync.begin_google_sign_in(); - let state = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); - let code_verifier = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); + let app_state = app.state::(); + let generation = app_state.cloud_sync.begin_google_sign_in(); + let cloud_api_base = app_state.cloud_environment.api_base(); + let state = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let code_verifier = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); { - *GOOGLE_OAUTH_LOGIN.lock().unwrap_or_else(|p| p.into_inner()) = - Some(PendingGoogleLogin::new(state.clone(), code_verifier, generation)); - } - let mut url = url::Url::parse(&format!( - "{}/v1/auth/google/start", - flowix_sync::DEFAULT_CLOUD_API_BASE - )) - .map_err(|error| error.to_string())?; + *GOOGLE_OAUTH_LOGIN.lock().unwrap_or_else(|p| p.into_inner()) = Some( + PendingGoogleLogin::new(state.clone(), code_verifier, generation), + ); + } + let mut url = url::Url::parse(&format!("{cloud_api_base}/v1/auth/google/start")) + .map_err(|error| error.to_string())?; url.query_pairs_mut() - .append_pair("mode", if option_env!("FLOWIX_DEV_MODE") == Some("1") { "desktop-dev" } else { "desktop" }) + .append_pair( + "mode", + if option_env!("FLOWIX_DEV_MODE") == Some("1") { + "desktop-dev" + } else { + "desktop" + }, + ) .append_pair("state", &state) .append_pair("code_challenge", &code_challenge); app.opener() @@ -190,7 +201,10 @@ pub(crate) async fn complete_google_deep_link( callback: GoogleCallback, ) -> Result<(), String> { match callback { - GoogleCallback::Success { code, state: oauth_state } => { + GoogleCallback::Success { + code, + state: oauth_state, + } => { let claimed = { let mut pending = GOOGLE_OAUTH_LOGIN.lock().unwrap_or_else(|p| p.into_inner()); let attempt = pending.as_mut().ok_or_else(|| { @@ -198,21 +212,42 @@ pub(crate) async fn complete_google_deep_link( })?; attempt.claim(&oauth_state).map_err(str::to_string)? }; - let Some((code_verifier, generation)) = claimed else { return Ok(()); }; - let state = app.state::(); - state.cloud_sync + let Some((code_verifier, generation)) = claimed else { + return Ok(()); + }; + let app_state = app.state::(); + app_state + .cloud_sync .sign_in_with_google_desktop(&code, &oauth_state, &code_verifier, generation) .await - .map_err(sync_error) - .map(|_| ()) + .map_err(sync_error)?; + if let Err(error) = crate::commands::window::open_preferences_window_inner( + &app, + &app_state, + Some("cloudSync"), + ) { + tracing::warn!( + "Google sign-in succeeded but account preferences could not open: {error}" + ); + } + Ok(()) } - GoogleCallback::Failure { error, state: oauth_state } => { + GoogleCallback::Failure { + error, + state: oauth_state, + } => { let accepted = { let mut pending = GOOGLE_OAUTH_LOGIN.lock().unwrap_or_else(|p| p.into_inner()); - let Some(attempt) = pending.as_mut() else { return Ok(()); }; - attempt.claim_failure(&oauth_state).map_err(str::to_string)? + let Some(attempt) = pending.as_mut() else { + return Ok(()); + }; + attempt + .claim_failure(&oauth_state) + .map_err(str::to_string)? }; - if accepted { let _ = app.emit("cloud-google-auth-error", error); } + if accepted { + let _ = app.emit("cloud-google-auth-error", error); + } Ok(()) } } @@ -239,11 +274,28 @@ pub async fn cloud_link_apple( #[tauri::command] pub async fn cloud_logout( + app: AppHandle, state: State<'_, AppState>, ) -> Result { - let logout_result = state.cloud_sync.logout().await.map_err(sync_error); - let next_state = state.cloud_sync.state().map_err(sync_error)?; - logout_result?; + let pi = state.pi.clone(); + let api_base = state.cloud_environment.api_base().to_string(); + let pending_logout = state.cloud_sync.begin_logout(); + let logout_generation = state.cloud_sync.ai_credential_session_generation(); + let next_state = state.cloud_sync.state().map_err(sync_error); + let pi_cleanup = async { + match &next_state { + Ok(next_state) if !next_state.authenticated => { + pi.disconnect_cloud_model_provider(&app, &api_base, logout_generation) + .await + } + _ => Ok(()), + } + }; + let logout = state.cloud_sync.finish_logout(pending_logout); + let (pi_cleanup, logout_result) = tokio::join!(pi_cleanup, logout); + let next_state = next_state?; + pi_cleanup?; + logout_result.map_err(sync_error)?; Ok(next_state) } @@ -344,11 +396,71 @@ pub async fn cloud_refresh_membership( membership_result.map_err(sync_error) } +#[tauri::command] +pub async fn cloud_ai_credit_balance( + state: State<'_, AppState>, +) -> Result { + state + .cloud_sync + .ai_credit_balance() + .await + .map_err(sync_error) +} + +#[tauri::command] +pub async fn cloud_connect_pi_model_provider( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let api_base = state.cloud_environment.api_base().trim_end_matches('/'); + state.pi.connect_cloud_model_provider(&app, api_base).await +} + +#[tauri::command] +pub async fn cloud_refresh_pi_model_provider_at_startup( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let api_base = state.cloud_environment.api_base().trim_end_matches('/'); + state + .pi + .refresh_cloud_model_provider_at_startup(&app, api_base) + .await +} + +#[tauri::command] +pub async fn cloud_refresh_pi_model_provider( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let api_base = state.cloud_environment.api_base().trim_end_matches('/'); + state.pi.refresh_cloud_model_provider(&app, api_base).await +} + #[tauri::command] pub async fn cloud_list_products(state: State<'_, AppState>) -> Result, String> { state.cloud_sync.products().await.map_err(sync_error) } +#[tauri::command] +pub async fn cloud_list_billing_orders( + state: State<'_, AppState>, +) -> Result, String> { + state.cloud_sync.billing_orders().await.map_err(sync_error) +} + +#[tauri::command] +pub async fn cloud_check_billing_order( + order_id: String, + state: State<'_, AppState>, +) -> Result { + state + .cloud_sync + .check_billing_order(&order_id) + .await + .map_err(sync_error) +} + #[tauri::command] pub async fn cloud_create_checkout( product_id: String, @@ -362,6 +474,30 @@ pub async fn cloud_create_checkout( checkout_result.map_err(sync_error) } +#[tauri::command] +pub async fn cloud_submit_feedback( + device_id: String, + kind: String, + content: String, + app_version: String, + platform: String, + attachments: Vec, + state: State<'_, AppState>, +) -> Result<(), String> { + state + .cloud_sync + .submit_feedback( + &device_id, + &kind, + &content, + &app_version, + &platform, + &attachments, + ) + .await + .map_err(sync_error) +} + #[tauri::command] pub async fn cloud_sync_now( notebook_id: Option, @@ -377,7 +513,11 @@ pub async fn cloud_note_history( state: State<'_, AppState>, ) -> Result { let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); - let history = state.cloud_sync.v2_history(¬e_id).await.map_err(sync_error)?; + let history = state + .cloud_sync + .v2_history(¬e_id) + .await + .map_err(sync_error)?; if history.notebook_id != notebook_id || history.note_id != note_id { return Err("CLOUD_HISTORY_ID_MISMATCH".into()); } @@ -400,8 +540,12 @@ pub fn restore_local_path_version( state: State<'_, AppState>, ) -> Result<(), String> { let memo_file = read_lock(&state.memo_file, "memo_file"); - let _guard = memo_file.operation_locks().notebook_change(&[¬ebook_id], "restore_local_path_version").map_err(sync_error)?; - let notebook = memo_file.get_notebook_config_by_id(¬ebook_id) + let _guard = memo_file + .operation_locks() + .notebook_change(&[¬ebook_id], "restore_local_path_version") + .map_err(sync_error)?; + let notebook = memo_file + .get_notebook_config_by_id(¬ebook_id) .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; let root = Path::new(¬ebook.path); let attachment = relative_path.starts_with("attachments/"); @@ -409,7 +553,8 @@ pub fn restore_local_path_version( return Err("CLOUD_HISTORY_INVALID_PATH".into()); } let path = local_adapter::safe_cloud_file_path(root, &relative_path, attachment)?; - let target = memo_file.read_path_version_bytes(¬ebook_id, &relative_path, &version_id) + let target = memo_file + .read_path_version_bytes(¬ebook_id, &relative_path, &version_id) .ok_or_else(|| "CLOUD_HISTORY_VERSION_NOT_FOUND".to_string())?; if !attachment && std::str::from_utf8(&target).is_err() { return Err("CLOUD_HISTORY_INVALID_MARKDOWN".into()); @@ -419,26 +564,44 @@ pub fn restore_local_path_version( Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => return Err(sync_error(error)), }; - if current.as_deref() == Some(target.as_slice()) { return Ok(()); } + if current.as_deref() == Some(target.as_slice()) { + return Ok(()); + } if let Some(bytes) = current.as_deref() { - memo_file.create_path_version_bytes( - ¬ebook_id, &relative_path, bytes, - flowix_core::memo_file::MemoVersionSource::RestoreBackup, - ).map_err(sync_error)?; + memo_file + .create_path_version_bytes( + ¬ebook_id, + &relative_path, + bytes, + flowix_core::memo_file::MemoVersionSource::RestoreBackup, + ) + .map_err(sync_error)?; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(sync_error)?; } - if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(sync_error)?; } let fallback = local_adapter::cloud_conflict_copy_path(&path, &format!("restore-{version_id}")); match local_adapter::safely_replace_cloud_file( - &path, current.as_deref(), &target, &format!("restore-{version_id}"), &fallback, + &path, + current.as_deref(), + &target, + &format!("restore-{version_id}"), + &fallback, )? { - local_adapter::ConflictFileOutcome::Applied { preserved: None } => {}, + local_adapter::ConflictFileOutcome::Applied { preserved: None } => {} outcome => { let preserved = match outcome { local_adapter::ConflictFileOutcome::Applied { preserved } | local_adapter::ConflictFileOutcome::Interrupted { preserved } => preserved, }; if let Some(preserved) = preserved { - local_adapter::register_preserved_conflict_copy(&memo_file, ¬ebook_id, root, &preserved, attachment)?; + local_adapter::register_preserved_conflict_copy( + &memo_file, + ¬ebook_id, + root, + &preserved, + attachment, + )?; } return Err("CLOUD_HISTORY_LOCAL_CHANGED".into()); } @@ -448,16 +611,22 @@ pub fn restore_local_path_version( // or media refresh below fails after the watcher self-write marker. schedule_notebook_sync_observation(app.clone(), notebook_id.clone(), true); if attachment { - memo_file.refresh_media_resource_path(¬ebook_id, &relative_path).map_err(sync_error)?; - let _ = app.emit("media-properties-changed", serde_json::json!({ "notebookId": notebook_id })); + memo_file + .refresh_media_resource_path(¬ebook_id, &relative_path) + .map_err(sync_error)?; + let _ = app.emit( + "media-properties-changed", + serde_json::json!({ "notebookId": notebook_id }), + ); } else { - let updated = memo_file.register_existing_file_for_notebook_id(¬ebook_id, &path).map_err(sync_error)?; - memo_events::emit(&app, MemoEvent::Updated { - id: updated.id.clone(), path: path.to_string_lossy().into_owned(), - notebook_id: notebook_id.clone(), - derived_changed: MemoDerivedChanged { tags: true, todos: true, agents: true }, - memo: updated, source: MemoChangeSource::CloudSync, - }); + memo_file + .refresh_note_path(¬ebook_id, &relative_path) + .map_err(sync_error)?; + crate::commands::path_change::NotebookPathInvalidated::new( + notebook_id.clone(), + relative_path.clone(), + ) + .emit(&app); } Ok(()) } @@ -474,11 +643,17 @@ pub async fn cloud_preview_note_revision( return Err("CLOUD_PREVIEW_MARKDOWN_ONLY".into()); } let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); - let (history, bytes) = state.cloud_sync.v2_historical_bytes(¬e_id, &revision).await.map_err(sync_error)?; + let (history, bytes) = state + .cloud_sync + .v2_historical_bytes(¬e_id, &revision) + .await + .map_err(sync_error)?; if history.notebook_id != notebook_id || history.note_id != note_id { return Err("CLOUD_HISTORY_ID_MISMATCH".into()); } - if bytes.len() > 2 * 1024 * 1024 { return Err("CLOUD_PREVIEW_TOO_LARGE".into()); } + if bytes.len() > 2 * 1024 * 1024 { + return Err("CLOUD_PREVIEW_TOO_LARGE".into()); + } String::from_utf8(bytes).map_err(|_| "CLOUD_PREVIEW_NOT_UTF8".into()) } @@ -488,19 +663,34 @@ pub fn cloud_list_conflicts( state: State<'_, AppState>, ) -> Result, String> { let memo_file = read_lock(&state.memo_file, "memo_file"); - let notebook = memo_file.get_notebook_config_by_id(¬ebook_id) + let notebook = memo_file + .get_notebook_config_by_id(¬ebook_id) .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; - let mut paths: Vec = memo_file.read_all_memos_for_notebook_id(Some(¬ebook_id)) + let notes = match memo_file.read_note_entries(¬ebook_id) { + Ok(notes) => notes, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + memo_file + .reconcile_note_index_blocking(¬ebook_id) + .map_err(|error| error.to_string())?; + memo_file + .read_note_entries(¬ebook_id) + .map_err(|error| error.to_string())? + } + Err(error) => return Err(error.to_string()), + }; + let mut paths: Vec = notes .into_iter() - .filter(|memo| memo.relative_path.contains("(Flowix conflict ")) - .map(|memo| memo.relative_path) + .filter(|note| note.relative_path.contains("(Flowix conflict ")) + .map(|note| note.relative_path) .collect(); let mut attachments = Vec::new(); let root = Path::new(¬ebook.path); local_adapter::scan_cloud_attachments(root, &root.join("attachments"), &mut attachments)?; for path in attachments { let relative = flowix_core::memo_file::notebook_relative_path(root, &path)?; - if relative.contains("(Flowix conflict ") { paths.push(relative); } + if relative.contains("(Flowix conflict ") { + paths.push(relative); + } } paths.sort(); paths.dedup(); @@ -517,104 +707,178 @@ pub fn cloud_resolve_markdown_conflict( state: State<'_, AppState>, ) -> Result<(), String> { let memo_file = read_lock(&state.memo_file, "memo_file"); - let _guard = memo_file.operation_locks().notebook_change(&[¬ebook_id], "resolve_markdown_conflict").map_err(sync_error)?; - let notebook = memo_file.get_notebook_config_by_id(¬ebook_id) + let _guard = memo_file + .operation_locks() + .notebook_change(&[¬ebook_id], "resolve_markdown_conflict") + .map_err(sync_error)?; + let notebook = memo_file + .get_notebook_config_by_id(¬ebook_id) .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; let root = Path::new(¬ebook.path); let original = local_adapter::safe_cloud_note_path(root, &relative_path)?; let copy = local_adapter::safe_cloud_note_path(root, &conflict_path)?; - if !conflict_path.contains("(Flowix conflict ") || original.parent() != copy.parent() - || original == copy { + let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); + if !conflict_path.contains("(Flowix conflict ") + || original.parent() != copy.parent() + || original == copy + { return Err("CLOUD_INVALID_CONFLICT_COPY".into()); } - let copy_memo = memo_file.find_memo_by_relative_path_for_notebook_id(¬ebook_id, &conflict_path) - .ok_or_else(|| "CLOUD_CONFLICT_COPY_MISSING".to_string())?; + if !copy.is_file() { + return Err("CLOUD_CONFLICT_COPY_MISSING".to_string()); + } let copy_bytes = std::fs::read(©).map_err(sync_error)?; local_adapter::record_rejected_cloud_version( - &memo_file, ¬ebook_id, root, &original, ©_bytes, + &memo_file, + ¬ebook_id, + root, + &original, + ©_bytes, )?; if use_local { - let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); let current = match std::fs::read(&original) { Ok(bytes) => Some(bytes), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => return Err(sync_error(error)), }; let chosen = copy_bytes.clone(); - let baseline = state.cloud_sync.v2_note_state(¬e_id).map_err(sync_error)? + let baseline = state + .cloud_sync + .v2_note_state(¬e_id) + .map_err(sync_error)? .ok_or_else(|| "CLOUD_CONFLICT_ORIGINAL_NOT_SYNCED".to_string())?; let current_hash = current.as_ref().map(|bytes| v2_content_hash(bytes)); if baseline.deleted != current.is_none() || baseline.content_hash.as_deref() != current_hash.as_deref() - || state.cloud_sync.has_pending_v2_note_change(¬e_id).map_err(sync_error)? { + || state + .cloud_sync + .has_pending_v2_note_change(¬e_id) + .map_err(sync_error)? + { return Err("CLOUD_CONFLICT_ORIGINAL_CHANGED".into()); } if let Some(current) = current.as_ref() { - let stem = original.file_stem().and_then(|value| value.to_str()).unwrap_or("Note"); - let backup = original.with_file_name(format!("{stem} (Flowix before conflict resolve {}).md", baseline.revision)); + let stem = original + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Note"); + let backup = original.with_file_name(format!( + "{stem} (Flowix before conflict resolve {}).md", + baseline.revision + )); if !backup.exists() { - flowix_core::memo_file::atomic_create_bytes(&backup, ¤t).map_err(sync_error)?; - memo_file.register_existing_file_for_notebook_id(¬ebook_id, &backup).map_err(sync_error)?; - let backup_relative = flowix_core::memo_file::notebook_relative_path(root, &backup)?; - state.cloud_sync.record_v2_local_change(¬ebook_id, - &flowix_sync::v2_path_note_id(¬ebook_id, &backup_relative), - flowix_sync::LocalChangeKind::Put, &v2_content_hash(¤t)).map_err(sync_error)?; + flowix_core::memo_file::atomic_create_bytes(&backup, ¤t) + .map_err(sync_error)?; + let backup_relative = + flowix_core::memo_file::notebook_relative_path(root, &backup)?; + memo_file + .refresh_note_path(¬ebook_id, &backup_relative) + .map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + &flowix_sync::v2_path_note_id(¬ebook_id, &backup_relative), + flowix_sync::LocalChangeKind::Put, + &v2_content_hash(¤t), + ) + .map_err(sync_error)?; } else if std::fs::read(&backup).map_err(sync_error)? != *current { return Err("CLOUD_CONFLICT_BACKUP_COLLISION".into()); } } - if let Some(parent) = original.parent() { std::fs::create_dir_all(parent).map_err(sync_error)?; } + if let Some(parent) = original.parent() { + std::fs::create_dir_all(parent).map_err(sync_error)?; + } crate::watcher::runtime::mark_self_write_for(&app, &original); - let preserved_path = local_adapter::cloud_conflict_copy_path(&original, &format!("resolve-{note_id}")); - match local_adapter::safely_replace_cloud_file(&original, current.as_deref(), &chosen, - &format!("resolve-{note_id}"), &preserved_path)? { - local_adapter::ConflictFileOutcome::Applied { preserved: None } => {}, + let preserved_path = + local_adapter::cloud_conflict_copy_path(&original, &format!("resolve-{note_id}")); + match local_adapter::safely_replace_cloud_file( + &original, + current.as_deref(), + &chosen, + &format!("resolve-{note_id}"), + &preserved_path, + )? { + local_adapter::ConflictFileOutcome::Applied { preserved: None } => {} outcome => { let preserved = match outcome { local_adapter::ConflictFileOutcome::Applied { preserved } | local_adapter::ConflictFileOutcome::Interrupted { preserved } => preserved, }; if let Some(preserved) = preserved { - local_adapter::register_preserved_conflict_copy(&memo_file, ¬ebook_id, - root, &preserved, false)?; + local_adapter::register_preserved_conflict_copy( + &memo_file, + ¬ebook_id, + root, + &preserved, + false, + )?; } return Err("CLOUD_CONFLICT_ORIGINAL_CHANGED".into()); } } - let updated = memo_file.register_existing_file_for_notebook_id(¬ebook_id, &original).map_err(sync_error)?; - memo_events::emit(&app, MemoEvent::Updated { - id: updated.id.clone(), path: original.to_string_lossy().into_owned(), - notebook_id: notebook_id.clone(), - derived_changed: MemoDerivedChanged { tags: true, todos: true, agents: true }, - memo: updated, source: MemoChangeSource::CloudSync, - }); - state.cloud_sync.record_v2_local_change(¬ebook_id, ¬e_id, - flowix_sync::LocalChangeKind::Put, &v2_content_hash(&chosen)).map_err(sync_error)?; + memo_file + .refresh_note_path(¬ebook_id, &relative_path) + .map_err(sync_error)?; + crate::commands::path_change::NotebookPathInvalidated::new( + notebook_id.clone(), + relative_path.clone(), + ) + .emit(&app); + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + ¬e_id, + flowix_sync::LocalChangeKind::Put, + &v2_content_hash(&chosen), + ) + .map_err(sync_error)?; } crate::watcher::runtime::mark_self_write_for(&app, ©); - let preserved_path = local_adapter::cloud_conflict_copy_path(©, &format!("remove-{}", copy_memo.id)); - match local_adapter::safely_remove_conflict_file(©, ©_bytes, - &format!("remove-{}", copy_memo.id), &preserved_path)? { - local_adapter::ConflictFileOutcome::Applied { preserved: None } => {}, + let copy_note_id = flowix_sync::v2_path_note_id(¬ebook_id, &conflict_path); + let preserved_path = + local_adapter::cloud_conflict_copy_path(©, &format!("remove-{copy_note_id}")); + match local_adapter::safely_remove_conflict_file( + ©, + ©_bytes, + &format!("remove-{copy_note_id}"), + &preserved_path, + )? { + local_adapter::ConflictFileOutcome::Applied { preserved: None } => {} outcome => { let preserved = match outcome { local_adapter::ConflictFileOutcome::Applied { preserved } | local_adapter::ConflictFileOutcome::Interrupted { preserved } => preserved, }; if let Some(preserved) = preserved { - local_adapter::register_preserved_conflict_copy(&memo_file, ¬ebook_id, - root, &preserved, false)?; + local_adapter::register_preserved_conflict_copy( + &memo_file, + ¬ebook_id, + root, + &preserved, + false, + )?; } return Err("CLOUD_CONFLICT_COPY_CHANGED".into()); } } - if !memo_file.prune_deleted_memo_for_notebook_id(¬ebook_id, &conflict_path, ©_memo.id) - .map_err(sync_error)? { + if copy.exists() { return Err("CLOUD_CONFLICT_COPY_CHANGED".into()); } - state.cloud_sync.record_v2_local_change(¬ebook_id, - &flowix_sync::v2_path_note_id(¬ebook_id, &conflict_path), - flowix_sync::LocalChangeKind::Delete, "deleted").map_err(sync_error)?; + memo_file + .refresh_note_path(¬ebook_id, &conflict_path) + .map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + ©_note_id, + flowix_sync::LocalChangeKind::Delete, + "deleted", + ) + .map_err(sync_error)?; drop(_guard); drop(memo_file); schedule_notebook_sync_observation(app, notebook_id, true); @@ -631,19 +895,29 @@ pub fn cloud_resolve_attachment_conflict( state: State<'_, AppState>, ) -> Result<(), String> { let memo_file = read_lock(&state.memo_file, "memo_file"); - let _guard = memo_file.operation_locks().notebook_change(&[¬ebook_id], "resolve_attachment_conflict").map_err(sync_error)?; - let notebook = memo_file.get_notebook_config_by_id(¬ebook_id) + let _guard = memo_file + .operation_locks() + .notebook_change(&[¬ebook_id], "resolve_attachment_conflict") + .map_err(sync_error)?; + let notebook = memo_file + .get_notebook_config_by_id(¬ebook_id) .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; let root = Path::new(¬ebook.path); let original = local_adapter::safe_cloud_file_path(root, &relative_path, true)?; let copy = local_adapter::safe_cloud_file_path(root, &conflict_path, true)?; - if !conflict_path.contains("(Flowix conflict ") || original.parent() != copy.parent() - || original == copy { + if !conflict_path.contains("(Flowix conflict ") + || original.parent() != copy.parent() + || original == copy + { return Err("CLOUD_INVALID_CONFLICT_COPY".into()); } let chosen = std::fs::read(©).map_err(sync_error)?; local_adapter::record_rejected_cloud_version( - &memo_file, ¬ebook_id, root, &original, &chosen, + &memo_file, + ¬ebook_id, + root, + &original, + &chosen, )?; if use_local { let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); @@ -652,86 +926,156 @@ pub fn cloud_resolve_attachment_conflict( Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => return Err(sync_error(error)), }; - let baseline = state.cloud_sync.v2_note_state(¬e_id).map_err(sync_error)? + let baseline = state + .cloud_sync + .v2_note_state(¬e_id) + .map_err(sync_error)? .ok_or_else(|| "CLOUD_CONFLICT_ORIGINAL_NOT_SYNCED".to_string())?; let current_hash = current.as_ref().map(|bytes| v2_content_hash(bytes)); if baseline.deleted != current.is_none() || baseline.content_hash.as_deref() != current_hash.as_deref() - || state.cloud_sync.has_pending_v2_note_change(¬e_id).map_err(sync_error)? { + || state + .cloud_sync + .has_pending_v2_note_change(¬e_id) + .map_err(sync_error)? + { return Err("CLOUD_CONFLICT_ORIGINAL_CHANGED".into()); } if let Some(current) = current.as_ref() { - let stem = original.file_stem().and_then(|value| value.to_str()).unwrap_or("attachment"); - let extension = original.extension().and_then(|value| value.to_str()).unwrap_or(""); + let stem = original + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("attachment"); + let extension = original + .extension() + .and_then(|value| value.to_str()) + .unwrap_or(""); let backup_name = if extension.is_empty() { - format!("{stem} (Flowix before conflict resolve {})", baseline.revision) + format!( + "{stem} (Flowix before conflict resolve {})", + baseline.revision + ) } else { - format!("{stem} (Flowix before conflict resolve {}).{extension}", baseline.revision) + format!( + "{stem} (Flowix before conflict resolve {}).{extension}", + baseline.revision + ) }; let backup = original.with_file_name(backup_name); if !backup.exists() { - flowix_core::memo_file::atomic_create_bytes(&backup, ¤t).map_err(sync_error)?; - let backup_relative = flowix_core::memo_file::notebook_relative_path(root, &backup)?; + flowix_core::memo_file::atomic_create_bytes(&backup, ¤t) + .map_err(sync_error)?; + let backup_relative = + flowix_core::memo_file::notebook_relative_path(root, &backup)?; if flowix_core::memo_file::media_kind_for_path(&backup).is_some() { - memo_file.refresh_media_resource_path(¬ebook_id, &backup_relative).map_err(sync_error)?; + memo_file + .refresh_media_resource_path(¬ebook_id, &backup_relative) + .map_err(sync_error)?; } - state.cloud_sync.record_v2_local_change(¬ebook_id, - &flowix_sync::v2_path_note_id(¬ebook_id, &backup_relative), - flowix_sync::LocalChangeKind::Put, &v2_content_hash(¤t)).map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + &flowix_sync::v2_path_note_id(¬ebook_id, &backup_relative), + flowix_sync::LocalChangeKind::Put, + &v2_content_hash(¤t), + ) + .map_err(sync_error)?; } else if std::fs::read(&backup).map_err(sync_error)? != *current { return Err("CLOUD_CONFLICT_BACKUP_COLLISION".into()); } } crate::watcher::runtime::mark_self_write_for(&app, &original); - let preserved_path = local_adapter::cloud_conflict_copy_path(&original, &format!("resolve-{note_id}")); - match local_adapter::safely_replace_cloud_file(&original, current.as_deref(), &chosen, - &format!("resolve-{note_id}"), &preserved_path)? { - local_adapter::ConflictFileOutcome::Applied { preserved: None } => {}, + let preserved_path = + local_adapter::cloud_conflict_copy_path(&original, &format!("resolve-{note_id}")); + match local_adapter::safely_replace_cloud_file( + &original, + current.as_deref(), + &chosen, + &format!("resolve-{note_id}"), + &preserved_path, + )? { + local_adapter::ConflictFileOutcome::Applied { preserved: None } => {} outcome => { let preserved = match outcome { local_adapter::ConflictFileOutcome::Applied { preserved } | local_adapter::ConflictFileOutcome::Interrupted { preserved } => preserved, }; if let Some(preserved) = preserved { - local_adapter::register_preserved_conflict_copy(&memo_file, ¬ebook_id, - root, &preserved, true)?; + local_adapter::register_preserved_conflict_copy( + &memo_file, + ¬ebook_id, + root, + &preserved, + true, + )?; } return Err("CLOUD_CONFLICT_ORIGINAL_CHANGED".into()); } } crate::watcher::runtime::mark_self_write_for(&app, &original); if flowix_core::memo_file::media_kind_for_path(&original).is_some() { - memo_file.refresh_media_resource_path(¬ebook_id, &relative_path).map_err(sync_error)?; + memo_file + .refresh_media_resource_path(¬ebook_id, &relative_path) + .map_err(sync_error)?; } - state.cloud_sync.record_v2_local_change(¬ebook_id, ¬e_id, - flowix_sync::LocalChangeKind::Put, &v2_content_hash(&chosen)).map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + ¬e_id, + flowix_sync::LocalChangeKind::Put, + &v2_content_hash(&chosen), + ) + .map_err(sync_error)?; } crate::watcher::runtime::mark_self_write_for(&app, ©); - let preserved_path = local_adapter::cloud_conflict_copy_path(©, &format!("remove-{conflict_path}")); - match local_adapter::safely_remove_conflict_file(©, &chosen, - &format!("remove-{conflict_path}"), &preserved_path)? { - local_adapter::ConflictFileOutcome::Applied { preserved: None } => {}, + let preserved_path = + local_adapter::cloud_conflict_copy_path(©, &format!("remove-{conflict_path}")); + match local_adapter::safely_remove_conflict_file( + ©, + &chosen, + &format!("remove-{conflict_path}"), + &preserved_path, + )? { + local_adapter::ConflictFileOutcome::Applied { preserved: None } => {} outcome => { let preserved = match outcome { local_adapter::ConflictFileOutcome::Applied { preserved } | local_adapter::ConflictFileOutcome::Interrupted { preserved } => preserved, }; if let Some(preserved) = preserved { - local_adapter::register_preserved_conflict_copy(&memo_file, ¬ebook_id, - root, &preserved, true)?; + local_adapter::register_preserved_conflict_copy( + &memo_file, + ¬ebook_id, + root, + &preserved, + true, + )?; } return Err("CLOUD_CONFLICT_COPY_CHANGED".into()); } } if flowix_core::memo_file::media_kind_for_path(©).is_some() { - memo_file.refresh_media_resource_path(¬ebook_id, &conflict_path).map_err(sync_error)?; + memo_file + .refresh_media_resource_path(¬ebook_id, &conflict_path) + .map_err(sync_error)?; } - state.cloud_sync.record_v2_local_change(¬ebook_id, - &flowix_sync::v2_path_note_id(¬ebook_id, &conflict_path), - flowix_sync::LocalChangeKind::Delete, "deleted").map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + &flowix_sync::v2_path_note_id(¬ebook_id, &conflict_path), + flowix_sync::LocalChangeKind::Delete, + "deleted", + ) + .map_err(sync_error)?; drop(_guard); drop(memo_file); - let _ = app.emit("media-properties-changed", serde_json::json!({ "notebookId": notebook_id })); + let _ = app.emit( + "media-properties-changed", + serde_json::json!({ "notebookId": notebook_id }), + ); schedule_notebook_sync_observation(app, notebook_id, true); Ok(()) } @@ -745,13 +1089,21 @@ pub async fn cloud_restore_note_revision( state: State<'_, AppState>, ) -> Result<(), String> { let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); - let (history, bytes) = state.cloud_sync.v2_historical_bytes(¬e_id, &revision).await.map_err(sync_error)?; + let (history, bytes) = state + .cloud_sync + .v2_historical_bytes(¬e_id, &revision) + .await + .map_err(sync_error)?; if history.notebook_id != notebook_id || history.note_id != note_id { return Err("CLOUD_HISTORY_ID_MISMATCH".into()); } let memo_file = read_lock(&state.memo_file, "memo_file"); - let _guard = memo_file.operation_locks().notebook_change(&[¬ebook_id], "restore_cloud_note_revision").map_err(sync_error)?; - let notebook = memo_file.get_notebook_config_by_id(¬ebook_id) + let _guard = memo_file + .operation_locks() + .notebook_change(&[¬ebook_id], "restore_cloud_note_revision") + .map_err(sync_error)?; + let notebook = memo_file + .get_notebook_config_by_id(¬ebook_id) .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; let root = Path::new(¬ebook.path); let attachment = relative_path.starts_with("attachments/"); @@ -761,18 +1113,33 @@ pub async fn cloud_restore_note_revision( Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => return Err(sync_error(error)), }; - let baseline = state.cloud_sync.v2_note_state(¬e_id).map_err(sync_error)? + let baseline = state + .cloud_sync + .v2_note_state(¬e_id) + .map_err(sync_error)? .ok_or_else(|| "CLOUD_RESTORE_NO_LOCAL_BASE".to_string())?; let current_hash = current.as_ref().map(|bytes| v2_content_hash(bytes)); if baseline.deleted != current.is_none() || baseline.content_hash.as_deref() != current_hash.as_deref() - || state.cloud_sync.has_pending_v2_note_change(¬e_id).map_err(sync_error)? { + || state + .cloud_sync + .has_pending_v2_note_change(¬e_id) + .map_err(sync_error)? + { return Err("CLOUD_RESTORE_LOCAL_CHANGED: save or resolve local edits first".into()); } - if current.as_deref() == Some(bytes.as_slice()) { return Ok(()); } + if current.as_deref() == Some(bytes.as_slice()) { + return Ok(()); + } if let Some(current) = current.as_ref() { - let stem = path.file_stem().and_then(|value| value.to_str()).unwrap_or("Note"); - let extension = path.extension().and_then(|value| value.to_str()).unwrap_or(""); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Note"); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or(""); let backup_name = if extension.is_empty() { format!("{stem} (Flowix before restore {})", revision) } else { @@ -782,33 +1149,57 @@ pub async fn cloud_restore_note_revision( if !backup.exists() { flowix_core::memo_file::atomic_create_bytes(&backup, ¤t).map_err(sync_error)?; if !attachment { - memo_file.register_existing_file_for_notebook_id(¬ebook_id, &backup).map_err(sync_error)?; + let backup_relative = notebook_relative_path(root, &backup)?; + memo_file + .refresh_note_path(¬ebook_id, &backup_relative) + .map_err(sync_error)?; } let backup_relative = flowix_core::memo_file::notebook_relative_path(root, &backup)?; if attachment && flowix_core::memo_file::media_kind_for_path(&backup).is_some() { - memo_file.refresh_media_resource_path(¬ebook_id, &backup_relative).map_err(sync_error)?; + memo_file + .refresh_media_resource_path(¬ebook_id, &backup_relative) + .map_err(sync_error)?; } - state.cloud_sync.record_v2_local_change(¬ebook_id, - &flowix_sync::v2_path_note_id(¬ebook_id, &backup_relative), - flowix_sync::LocalChangeKind::Put, &v2_content_hash(¤t)).map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + &flowix_sync::v2_path_note_id(¬ebook_id, &backup_relative), + flowix_sync::LocalChangeKind::Put, + &v2_content_hash(¤t), + ) + .map_err(sync_error)?; } else if std::fs::read(&backup).map_err(sync_error)? != *current { return Err("CLOUD_RESTORE_BACKUP_COLLISION".into()); } } - if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(sync_error)?; } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(sync_error)?; + } crate::watcher::runtime::mark_self_write_for(&app, &path); - let conflict_copy = local_adapter::cloud_conflict_copy_path(&path, &format!("restore-{revision}")); - match local_adapter::safely_replace_cloud_file(&path, current.as_deref(), &bytes, - &format!("restore-{revision}"), &conflict_copy)? { - local_adapter::ConflictFileOutcome::Applied { preserved: None } => {}, + let conflict_copy = + local_adapter::cloud_conflict_copy_path(&path, &format!("restore-{revision}")); + match local_adapter::safely_replace_cloud_file( + &path, + current.as_deref(), + &bytes, + &format!("restore-{revision}"), + &conflict_copy, + )? { + local_adapter::ConflictFileOutcome::Applied { preserved: None } => {} outcome => { let preserved = match outcome { local_adapter::ConflictFileOutcome::Applied { preserved } | local_adapter::ConflictFileOutcome::Interrupted { preserved } => preserved, }; if let Some(preserved) = preserved { - local_adapter::register_preserved_conflict_copy(&memo_file, ¬ebook_id, - root, &preserved, attachment)?; + local_adapter::register_preserved_conflict_copy( + &memo_file, + ¬ebook_id, + root, + &preserved, + attachment, + )?; } return Err("CLOUD_RESTORE_LOCAL_CHANGED: local file preserved".into()); } @@ -816,22 +1207,33 @@ pub async fn cloud_restore_note_revision( if attachment { crate::watcher::runtime::mark_self_write_for(&app, &path); if flowix_core::memo_file::media_kind_for_path(&path).is_some() { - memo_file.refresh_media_resource_path(¬ebook_id, &relative_path).map_err(sync_error)?; + memo_file + .refresh_media_resource_path(¬ebook_id, &relative_path) + .map_err(sync_error)?; } - let _ = app.emit("media-properties-changed", serde_json::json!({ "notebookId": notebook_id })); + let _ = app.emit( + "media-properties-changed", + serde_json::json!({ "notebookId": notebook_id }), + ); } else { - let updated = memo_file.register_existing_file_for_notebook_id(¬ebook_id, &path).map_err(sync_error)?; - memo_events::emit(&app, MemoEvent::Updated { - id: updated.id.clone(), path: path.to_string_lossy().into_owned(), - notebook_id: notebook_id.clone(), - derived_changed: MemoDerivedChanged { tags: true, todos: true, agents: true }, - memo: updated, source: MemoChangeSource::CloudSync, - }); - } - state.cloud_sync.record_v2_local_change( - ¬ebook_id, ¬e_id, flowix_sync::LocalChangeKind::Put, - &v2_content_hash(&bytes), - ).map_err(sync_error)?; + memo_file + .refresh_note_path(¬ebook_id, &relative_path) + .map_err(sync_error)?; + crate::commands::path_change::NotebookPathInvalidated::new( + notebook_id.clone(), + relative_path.clone(), + ) + .emit(&app); + } + state + .cloud_sync + .record_v2_local_change( + ¬ebook_id, + ¬e_id, + flowix_sync::LocalChangeKind::Put, + &v2_content_hash(&bytes), + ) + .map_err(sync_error)?; drop(_guard); drop(memo_file); schedule_notebook_sync_observation(app, notebook_id, true); diff --git a/app/flowix-desktop/src/commands/cloud/coordinator.rs b/app/flowix-desktop/src/commands/cloud/coordinator.rs index fb6b4bb60..6563e3035 100644 --- a/app/flowix-desktop/src/commands/cloud/coordinator.rs +++ b/app/flowix-desktop/src/commands/cloud/coordinator.rs @@ -1,6 +1,6 @@ //! Serializes manual, automatic and polling sync work, including retries. -use super::*; use super::local_adapter::*; +use super::*; static ACCOUNT_SYNC_LOCK: OnceLock>> = OnceLock::new(); @@ -255,22 +255,37 @@ async fn sync_v2_account_pass( "snapshot", None, ); - let (mut notebooks, mut notes) = v2_account_snapshot(state, full_local_snapshot, notebook_scope)?; + let (mut notebooks, mut notes) = + v2_account_snapshot(state, full_local_snapshot, notebook_scope)?; if full_local_snapshot { let present: HashSet<_> = notes.iter().map(|note| note.id.as_str()).collect(); - let moving: HashSet<_> = state.cloud_sync.v2_pending_moves().map_err(sync_error)? - .into_iter().map(|item| item.from_note_id).collect(); - let enabled_ids: HashSet<_> = enabled.iter().map(|item| item.notebook_id.as_str()).collect(); + let moving: HashSet<_> = state + .cloud_sync + .v2_pending_moves() + .map_err(sync_error)? + .into_iter() + .map(|item| item.from_note_id) + .collect(); + let enabled_ids: HashSet<_> = enabled + .iter() + .map(|item| item.notebook_id.as_str()) + .collect(); for previous in state.cloud_sync.v2_all_note_states().map_err(sync_error)? { - if !previous.deleted && enabled_ids.contains(previous.notebook_id.as_str()) + if !previous.deleted + && enabled_ids.contains(previous.notebook_id.as_str()) && notebook_scope.is_none_or(|scope| scope == previous.notebook_id) && !present.contains(previous.note_id.as_str()) && !moving.contains(&previous.note_id) { - state.cloud_sync.record_v2_local_change( - &previous.notebook_id, &previous.note_id, - flowix_sync::LocalChangeKind::Delete, "deleted", - ).map_err(sync_error)?; + state + .cloud_sync + .record_v2_local_change( + &previous.notebook_id, + &previous.note_id, + flowix_sync::LocalChangeKind::Delete, + "deleted", + ) + .map_err(sync_error)?; } } } @@ -303,19 +318,27 @@ async fn sync_v2_account_pass( match result { Err(SyncError::MoveConflict { operation_id, .. }) if conflict_attempts < 3 => { conflict_attempts += 1; - let movement = state.cloud_sync.v2_pending_move(&operation_id) + let movement = state + .cloud_sync + .v2_pending_move(&operation_id) .map_err(sync_error)? .ok_or_else(|| "CLOUD_MOVE_CONFLICT_QUEUE_MISSING".to_string())?; resolve_v2_move_conflict(state, app, &movement)?; (notebooks, notes) = v2_account_snapshot(state, true, notebook_scope)?; } Err(SyncError::RevisionConflict { - notebook_id, note_id, operation_id, operation_kind, .. + notebook_id, + note_id, + operation_id, + operation_kind, + .. }) if matches!(operation_kind.as_str(), "put" | "delete") && conflict_attempts < 3 => { conflict_attempts += 1; - let material = state.cloud_sync + let material = state + .cloud_sync .v2_conflict_material(¬ebook_id, ¬e_id, &operation_id) - .await.map_err(cloud_error)?; + .await + .map_err(cloud_error)?; if operation_kind == "delete" { resolve_v2_delete_conflict(state, app, &material)?; } else { @@ -341,7 +364,9 @@ async fn sync_v2_account_pass( apply_v2_report(state, app, &report).map_err(SyncError::InvalidState) }) .map_err(sync_error)?; - if full_local_snapshot { record_full_local_snapshot(notebook_scope); } + if full_local_snapshot { + record_full_local_snapshot(notebook_scope); + } Ok(report) } @@ -569,7 +594,15 @@ async fn run_sync_activity( let mut first_head = None; let mut previous_cursor = None; loop { - let mut report = match sync_v2_account_pass(state.inner(), app, &target, &enabled, &activity).await { + let mut report = match sync_v2_account_pass( + state.inner(), + app, + &target, + &enabled, + &activity, + ) + .await + { Ok(report) => report, Err(error) => { return Err(fail_sync_activity( @@ -587,19 +620,25 @@ async fn run_sync_activity( async { let next = tokio::time::timeout( CLOUD_SYNC_PASS_TIMEOUT, - state.cloud_sync.v2_continue_bootstrap(&report, target.notebook_scope()), + state + .cloud_sync + .v2_continue_bootstrap(&report, target.notebook_scope()), ) .await .map_err(|_| { - "云端笔记初始化超过 3 分钟仍未完成,请检查网络后重试。" - .to_string() + "云端笔记初始化超过 3 分钟仍未完成,请检查网络后重试。".to_string() })? .map_err(cloud_error)?; - state.cloud_sync.complete_v2_sync_with_apply(&next, target.notebook_scope(), || { - apply_v2_report(state.inner(), app, &next).map_err(SyncError::InvalidState) - }).map_err(sync_error)?; + state + .cloud_sync + .complete_v2_sync_with_apply(&next, target.notebook_scope(), || { + apply_v2_report(state.inner(), app, &next) + .map_err(SyncError::InvalidState) + }) + .map_err(sync_error)?; Ok::<_, String>(next) - }.await + } + .await }; report = match next { Ok(next) => next, @@ -612,7 +651,9 @@ async fn run_sync_activity( activity.absorb_report(&report); } let head = *first_head.get_or_insert(report.head_cursor); - if report.cursor >= head { break; } + if report.cursor >= head { + break; + } if previous_cursor.is_some_and(|cursor| report.cursor <= cursor) { return Err(fail_sync_activity( app, @@ -757,7 +798,10 @@ pub(crate) fn start_cloud_sync_polling(app: AppHandle) { let cloud_state = state.cloud_sync.state().ok(); matches!( cloud_state, - Some(CloudState { authenticated: true, .. }) + Some(CloudState { + authenticated: true, + .. + }) ) && !state .cloud_sync .v2_enabled_notebooks() @@ -773,7 +817,6 @@ pub(crate) fn start_cloud_sync_polling(app: AppHandle) { }); } - pub(super) async fn sync_now( notebook_id: Option, app: AppHandle, @@ -789,12 +832,13 @@ pub(super) async fn sync_now( )?; match tokio::time::timeout(MANUAL_SYNC_TIMEOUT, response).await { Ok(Ok(result)) => result, - Ok(Err(_)) => Err("cloud sync coordinator stopped before completing the request".to_string()), + Ok(Err(_)) => { + Err("cloud sync coordinator stopped before completing the request".to_string()) + } Err(_) => Err("云同步超过 5 分钟仍未完成,请检查网络后重试。".to_string()), } } - #[cfg(test)] mod tests { use super::*; diff --git a/app/flowix-desktop/src/commands/cloud/deletion.rs b/app/flowix-desktop/src/commands/cloud/deletion.rs index 223145044..e7a7b1e6b 100644 --- a/app/flowix-desktop/src/commands/cloud/deletion.rs +++ b/app/flowix-desktop/src/commands/cloud/deletion.rs @@ -1,10 +1,10 @@ use std::path::Path; use super::local_adapter::{ - cloud_conflict_copy_path, register_preserved_conflict_copy, safely_remove_conflict_file, - ConflictFileOutcome, + cloud_conflict_copy_path, register_preserved_conflict_copy, safe_cloud_note_path, + safely_remove_conflict_file, ConflictFileOutcome, }; -use flowix_core::memo_file::{notebook_path_from_relative, Memo, MemoFile}; +use flowix_core::memo_file::MemoFile; use flowix_sync::{v2_content_hash, v2_local_content_diverged, SyncManager}; pub(super) fn delete_cloud_note_locked( @@ -14,16 +14,12 @@ pub(super) fn delete_cloud_note_locked( note_id: &str, relative_path: &str, before_delete: impl FnOnce(&Path), -) -> Result, String> { - // Cloud v2 note ids are path-derived; the local note is addressed by the - // same relative path. Legacy id-based lookup is no longer supported. - let memo = memo_file.find_memo_by_relative_path_for_notebook_id(notebook_id, relative_path); - let Some(memo) = memo else { return Ok(None) }; +) -> Result { let notebook = memo_file .get_notebook_config_by_id(notebook_id) .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; - let path = notebook_path_from_relative(Path::new(¬ebook.path), &memo.relative_path) - .unwrap_or_else(|_| Path::new(¬ebook.path).join(&memo.filename)); + let path = safe_cloud_note_path(Path::new(¬ebook.path), relative_path)?; + let local_bytes = match std::fs::read(&path) { Ok(bytes) => Some(bytes), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -54,6 +50,7 @@ pub(super) fn delete_cloud_note_locked( )); } before_delete(&path); + let had_local_file = local_bytes.is_some(); if let Some(expected) = local_bytes.as_deref() { let copy = cloud_conflict_copy_path(&path, note_id); match safely_remove_conflict_file(&path, expected, note_id, ©)? { @@ -79,16 +76,16 @@ pub(super) fn delete_cloud_note_locked( } } } - if !memo_file - .prune_deleted_memo_for_notebook_id(notebook_id, &memo.relative_path, &memo.id) - .map_err(|error| error.to_string())? - { + memo_file + .refresh_note_path(notebook_id, relative_path) + .map_err(|error| format!("CLOUD_DELETE_INDEX_FAILED: {error}"))?; + if path.exists() { return Err(format!( "CLOUD_DELETE_CONFLICT: path was recreated: {}", path.display() )); } - Ok(Some(memo)) + Ok(had_local_file) } #[cfg(test)] diff --git a/app/flowix-desktop/src/commands/cloud/deletion/tests.rs b/app/flowix-desktop/src/commands/cloud/deletion/tests.rs index 79198f506..d00793dc4 100644 --- a/app/flowix-desktop/src/commands/cloud/deletion/tests.rs +++ b/app/flowix-desktop/src/commands/cloud/deletion/tests.rs @@ -1,6 +1,6 @@ use super::*; use flowix_core::memo_file::NotebookConfig; -use flowix_core::MemoService; +use flowix_core::NoteService; use flowix_sync::{V2EntityType, V2NoteState, V2OperationKind}; use std::path::PathBuf; @@ -28,15 +28,10 @@ fn fixture() -> ( updated_at: 1, }]) .unwrap(); - let created = MemoService::new(&store) - .create_memo_named_with_tag_in_directory( - Some("work"), - None, - "Untitled", - "# Note\noriginal\n", - None, - ) + let created = NoteService::new(&store) + .create("work", None, "Untitled", "# Note\noriginal\n") .unwrap(); + let note_id = "remote-note-id".to_string(); let sync = SyncManager::new( "https://cloud.example.test", directory.path().join("sync.db"), @@ -45,11 +40,11 @@ fn fixture() -> ( let content = std::fs::read(&created.path).unwrap(); sync.store() .save_v2_note_state(&V2NoteState { - note_id: created.memo.id.clone(), + note_id: note_id.clone(), notebook_id: "work".into(), revision: "revision-1".into(), content_hash: Some(v2_content_hash(&content)), - filename: created.memo.filename.clone(), + filename: "Untitled.md".to_string(), deleted: false, last_seq: 1, attachments: Vec::new(), @@ -59,9 +54,9 @@ fn fixture() -> ( directory, store, sync, - created.memo.id, + note_id, created.path, - created.memo.relative_path, + created.entry.relative_path, ) } @@ -82,7 +77,8 @@ fn remote_delete_preserves_edits_before_watcher_marks_dirty() { assert!(error.starts_with("CLOUD_DELETE_CONFLICT:")); assert_eq!(std::fs::read_to_string(&path).unwrap(), edited); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_some()); assert_eq!(sync.v2_note_state(&id).unwrap(), baseline); assert_eq!(sync.store().v2_cursor().unwrap(), cursor); @@ -111,7 +107,8 @@ fn remote_delete_preserves_pending_changes_even_when_body_hash_matches() { ); assert_eq!(std::fs::read(&path).unwrap(), original); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_some()); let dirty_after = sync.store().v2_dirty_entities().unwrap(); assert_eq!(dirty_after.len(), dirty_before.len()); @@ -136,15 +133,14 @@ fn remote_delete_removes_unchanged_file_and_index_entry() { ) .unwrap(); assert!(acknowledged); - assert_eq!(removed.unwrap().id, id); + assert!(removed); assert!(!path.exists()); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_none()); assert!( - delete_cloud_note_locked(&store, &sync, "work", &id, relative.as_str(), |_| {}) - .unwrap() - .is_none() + !delete_cloud_note_locked(&store, &sync, "work", &id, relative.as_str(), |_| {}).unwrap() ); } @@ -166,7 +162,8 @@ fn remote_delete_preserves_external_save_during_delete() { assert!(error.starts_with("CLOUD_DELETE_CONFLICT:")); assert_eq!(std::fs::read(&path).unwrap(), b"external save"); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_some()); } @@ -183,7 +180,8 @@ fn remote_delete_does_not_treat_read_failure_as_a_missing_file() { ); assert!(path.is_dir()); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_some()); } @@ -194,13 +192,12 @@ fn remote_delete_ignores_a_note_path_missing_in_the_target_notebook() { // Path-based lookup: the same relative path simply does not exist in the // other notebook, so the deletion is a no-op there. assert!( - delete_cloud_note_locked(&store, &sync, "other", &id, relative.as_str(), |_| {}) - .unwrap() - .is_none() + delete_cloud_note_locked(&store, &sync, "other", &id, relative.as_str(), |_| {}).unwrap() ); assert!(path.exists()); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_some()); } @@ -219,7 +216,8 @@ fn remote_delete_preserves_a_local_file_without_an_acknowledged_hash() { ); assert_eq!(std::fs::read(&path).unwrap(), original); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_some()); } @@ -229,11 +227,10 @@ fn remote_delete_cleans_an_already_missing_file_from_the_index() { store.delete_file(&path).unwrap(); let _guard = store.acquire_cross_process_write_lock().unwrap(); assert!( - delete_cloud_note_locked(&store, &sync, "work", &id, relative.as_str(), |_| {}) - .unwrap() - .is_some() + !delete_cloud_note_locked(&store, &sync, "work", &id, relative.as_str(), |_| {}).unwrap() ); assert!(store - .find_memo_by_relative_path_for_notebook_id("work", &relative) + .read_note_entry_by_path("work", &relative) + .unwrap() .is_none()); } diff --git a/app/flowix-desktop/src/commands/cloud/google_login.rs b/app/flowix-desktop/src/commands/cloud/google_login.rs index 4984d5fca..069ceb8c2 100644 --- a/app/flowix-desktop/src/commands/cloud/google_login.rs +++ b/app/flowix-desktop/src/commands/cloud/google_login.rs @@ -15,11 +15,19 @@ pub(crate) enum GoogleCallback { impl PendingGoogleLogin { pub(super) fn new(state: String, code_verifier: String, generation: u64) -> Self { - Self { state, code_verifier, generation, created_at: Instant::now(), callback_received: false } + Self { + state, + code_verifier, + generation, + created_at: Instant::now(), + callback_received: false, + } } fn claim_once(&mut self, state: &str) -> Result { - if self.state != state { return Ok(false); } + if self.state != state { + return Ok(false); + } // Browser fallback links and OS delivery can repeat the same callback. // Exchange its one-time code at most once, including while in flight. if self.callback_received { @@ -33,16 +41,22 @@ impl PendingGoogleLogin { } pub(super) fn claim(&mut self, state: &str) -> Result, &'static str> { - if !self.claim_once(state)? { return Ok(None); } - Ok(Some((std::mem::take(&mut self.code_verifier), self.generation))) + if !self.claim_once(state)? { + return Ok(None); + } + Ok(Some(( + std::mem::take(&mut self.code_verifier), + self.generation, + ))) } pub(super) fn claim_failure(&mut self, state: &str) -> Result { let claimed = self.claim_once(state)?; - if claimed { self.code_verifier.clear(); } + if claimed { + self.code_verifier.clear(); + } Ok(claimed) } - } #[cfg(test)] @@ -68,7 +82,10 @@ mod tests { fn expired_attempt_cannot_be_exchanged() { let mut attempt = PendingGoogleLogin::new("state".into(), "verifier".into(), 7); attempt.created_at -= Duration::from_secs(301); - assert_eq!(attempt.claim("state"), Err("Google sign-in expired; please try again")); + assert_eq!( + attempt.claim("state"), + Err("Google sign-in expired; please try again") + ); assert!(!attempt.callback_received); } @@ -85,7 +102,10 @@ mod tests { fn expired_failure_callback_is_reported_only_for_the_matching_attempt() { let mut attempt = PendingGoogleLogin::new("state".into(), "verifier".into(), 7); attempt.created_at -= Duration::from_secs(301); - assert_eq!(attempt.claim_failure("state"), Err("Google sign-in expired; please try again")); + assert_eq!( + attempt.claim_failure("state"), + Err("Google sign-in expired; please try again") + ); assert!(!attempt.callback_received); } } diff --git a/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_files.rs b/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_files.rs index 48fb13d77..6262dd94e 100644 --- a/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_files.rs +++ b/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_files.rs @@ -337,9 +337,9 @@ pub(crate) fn register_preserved_conflict_copy( .refresh_media_resource_path(notebook_id, &relative) .map_err(sync_error) } else { + let relative = flowix_core::memo_file::notebook_relative_path(notebook_root, path)?; memo_file - .register_existing_file_for_notebook_id(notebook_id, path) - .map(|_| ()) + .refresh_note_path(notebook_id, &relative) .map_err(sync_error) } } diff --git a/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/file_put.rs b/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/file_put.rs index 5d4982199..e99460310 100644 --- a/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/file_put.rs +++ b/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/file_put.rs @@ -133,6 +133,12 @@ pub(crate) fn resolve_v2_file_put_conflict( let _ = flowix_core::memo_file::rename_file_noclobber(&destination, &path); return Err(sync_error(error)); } + crate::commands::document_list::rebase_collection_paths( + &memo_file, + &material.notebook_id, + &material.remote.filename, + &target.filename, + ); } destination } else { @@ -173,16 +179,24 @@ pub(crate) fn resolve_v2_file_put_conflict( && destination.exists() && flowix_core::memo_file::rename_file_noclobber(&destination, &path).is_ok() { - let _ = memo_file.move_media_resource_path( + let restored = memo_file.move_media_resource_path( &material.notebook_id, material .relocated .as_ref() .map_or(material.remote.filename.as_str(), |item| { item.filename.as_str() - }), + }), &material.remote.filename, ); + if restored.is_ok() { + crate::commands::document_list::rebase_collection_paths( + &memo_file, + &material.notebook_id, + material.relocated.as_ref().map_or(material.remote.filename.as_str(), |item| item.filename.as_str()), + &material.remote.filename, + ); + } } return Err( "CLOUD_CONFLICT_LOCAL_CHANGED: attachment changed during recovery".into(), @@ -219,16 +233,10 @@ pub(crate) fn resolve_v2_file_put_conflict( .map_err(sync_error) }; } - let memo = memo_file - .find_memo_by_relative_path_for_notebook_id( - &material.notebook_id, - &material.remote.filename, - ) - .ok_or_else(|| "CLOUD_CONFLICT_LOCAL_NOTE_NOT_FOUND".to_string())?; - if material.remote.filename != memo.relative_path { - return Err("CLOUD_PATH_OR_DELETE_CONFLICT: local changes preserved".to_string()); + let path = safe_cloud_note_path(Path::new(¬ebook.path), &material.remote.filename)?; + if !path.is_file() { + return Err("CLOUD_CONFLICT_LOCAL_NOTE_NOT_FOUND".to_string()); } - let path = notebook_path_from_relative(Path::new(¬ebook.path), &memo.relative_path)?; let conflict_copy = path.with_file_name(format!( "{} (Flowix conflict {}).md", path.file_stem() @@ -288,14 +296,10 @@ pub(crate) fn resolve_v2_file_put_conflict( } } crate::watcher::runtime::mark_self_write_for(app, &path); - if !memo_file - .prune_deleted_memo_for_notebook_id( - &material.notebook_id, - &material.remote.filename, - &memo.id, - ) - .map_err(sync_error)? - { + memo_file + .refresh_note_path(&material.notebook_id, &material.remote.filename) + .map_err(sync_error)?; + if path.exists() { return Err( "CLOUD_CONFLICT_LOCAL_CHANGED: local note reappeared during recovery".into(), ); @@ -336,10 +340,10 @@ pub(crate) fn resolve_v2_file_put_conflict( crate::watcher::runtime::mark_self_write_for(app, &destination); flowix_core::memo_file::rename_file_noclobber(&path, &destination).map_err(sync_error)?; if let Err(error) = - memo_file.rename_memo_file_for_notebook_id(&material.notebook_id, &path, &destination) + memo_file.refresh_note_path(&material.notebook_id, &material.remote.filename) { let _ = flowix_core::memo_file::rename_file_noclobber(&destination, &path); - return Err(error); + return Err(sync_error(error)); } destination } else { @@ -379,34 +383,29 @@ pub(crate) fn resolve_v2_file_put_conflict( && destination.exists() && flowix_core::memo_file::rename_file_noclobber(&destination, &path).is_ok() { - let _ = memo_file.rename_memo_file_for_notebook_id( - &material.notebook_id, + if let Ok(relative) = flowix_core::memo_file::notebook_relative_path( + Path::new(¬ebook.path), &destination, - &path, - ); + ) { + let _ = memo_file.refresh_note_path(&material.notebook_id, &relative); + } + let _ = + memo_file.refresh_note_path(&material.notebook_id, &material.remote.filename); } return Err("CLOUD_CONFLICT_LOCAL_CHANGED: local note changed during recovery".into()); } } crate::watcher::runtime::mark_self_write_content_for(app, &destination, &next); - let updated = memo_file - .register_existing_file_for_notebook_id(&material.notebook_id, &destination) + let relative_path = + flowix_core::memo_file::notebook_relative_path(Path::new(¬ebook.path), &destination)?; + memo_file + .refresh_note_path(&material.notebook_id, &relative_path) .map_err(sync_error)?; - memo_events::emit( - app, - MemoEvent::Updated { - id: updated.id.clone(), - path: destination.to_string_lossy().into_owned(), - notebook_id: material.notebook_id.clone(), - derived_changed: MemoDerivedChanged { - tags: true, - todos: true, - agents: true, - }, - memo: updated, - source: MemoChangeSource::CloudSync, - }, - ); + crate::commands::path_change::NotebookPathInvalidated::new( + material.notebook_id.clone(), + relative_path, + ) + .emit(app); if let Some(target) = &material.relocated { state .cloud_sync diff --git a/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/move_delete.rs b/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/move_delete.rs index 26efc4405..fdfd7a370 100644 --- a/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/move_delete.rs +++ b/app/flowix-desktop/src/commands/cloud/local_adapter/conflict_resolution/move_delete.rs @@ -70,15 +70,9 @@ pub(crate) fn resolve_v2_move_conflict( memo_file .refresh_media_resource_path(&movement.notebook_id, &movement.to_path) .map_err(sync_error)?; - } else if let Some(memo) = memo_file - .find_memo_by_relative_path_for_notebook_id(&movement.notebook_id, &movement.to_path) - { - let _ = memo_file - .prune_deleted_memo_for_notebook_id( - &movement.notebook_id, - &movement.to_path, - &memo.id, - ) + } else { + memo_file + .refresh_note_path(&movement.notebook_id, &movement.to_path) .map_err(sync_error)?; } } diff --git a/app/flowix-desktop/src/commands/cloud/local_adapter/local_snapshot.rs b/app/flowix-desktop/src/commands/cloud/local_adapter/local_snapshot.rs index 80e3faf09..b3aed3a51 100644 --- a/app/flowix-desktop/src/commands/cloud/local_adapter/local_snapshot.rs +++ b/app/flowix-desktop/src/commands/cloud/local_adapter/local_snapshot.rs @@ -102,7 +102,9 @@ pub(crate) fn v2_account_snapshot( } else { Some(state.cloud_sync.v2_dirty_note_ids().map_err(sync_error)?) }; - let memo_file = read_lock(&state.memo_file, "memo_file"); + // A complete sync snapshot may scan files. It must not hold the global + // facade lock needed by foreground notebook switching. + let memo_file = flowix_core::memo_file::MemoFile::new(state.user_config.config_dir().clone()); let configs = memo_file.read_notebook_configs().map_err(sync_error)?; let mut notebooks = Vec::new(); let mut notes = Vec::new(); @@ -118,22 +120,33 @@ pub(crate) fn v2_account_snapshot( if full_scan { memo_file.reconcile_notebook_with_disk_bidirectional(&config.id)?; } - for memo in memo_file.read_all_memos_for_notebook_id(Some(&config.id)) { - let cloud_id = flowix_sync::v2_path_note_id(&config.id, &memo.relative_path); + let indexed_notes = match memo_file.read_note_entries(&config.id) { + Ok(notes) => notes, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + memo_file + .reconcile_note_index_blocking(&config.id) + .map_err(|error| error.to_string())?; + memo_file + .read_note_entries(&config.id) + .map_err(|error| error.to_string())? + } + Err(error) => return Err(error.to_string()), + }; + for note in indexed_notes { + let cloud_id = flowix_sync::v2_path_note_id(&config.id, ¬e.relative_path); if dirty_note_ids .as_ref() .is_some_and(|ids| !ids.contains(&cloud_id)) { continue; } - let path = notebook_path_from_relative(Path::new(&config.path), &memo.relative_path) - .unwrap_or_else(|_| PathBuf::from(&config.path).join(&memo.filename)); + let path = notebook_path_from_relative(Path::new(&config.path), ¬e.relative_path)?; let content = std::fs::read(&path) .map_err(|error| format!("READ_NOTE_FAILED {}: {error}", path.display()))?; notes.push(V2LocalNote { id: cloud_id, notebook_id: config.id.clone(), - filename: memo.relative_path, + filename: note.relative_path, content, attachments: Vec::new(), }); diff --git a/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs b/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs index c04b1ae94..9874a1544 100644 --- a/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs +++ b/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs @@ -79,6 +79,32 @@ fn apply_v2_note_changes( app: &AppHandle, notebook_id: &str, changes: &[&V2RemoteApply], +) -> Result<(), String> { + apply_v2_note_changes_locked(state, app, notebook_id, changes)?; + let memo_file = read_lock(&state.memo_file, "memo_file"); + for relative_path in changes.iter().filter_map(|change| { + let V2RemoteApply::Note { + filename, deleted, .. + } = *change + else { + return None; + }; + (*deleted && !filename.starts_with("attachments/")).then_some(filename.as_str()) + }) { + crate::commands::document_list::remove_table_note_records( + &memo_file, + notebook_id, + relative_path, + ); + } + Ok(()) +} + +fn apply_v2_note_changes_locked( + state: &AppState, + app: &AppHandle, + notebook_id: &str, + changes: &[&V2RemoteApply], ) -> Result<(), String> { let memo_file = read_lock(&state.memo_file, "memo_file"); let _write_guard = memo_file @@ -194,29 +220,19 @@ fn apply_v2_note_changes( continue; } if *deleted { - if let Some(memo) = deletion::delete_cloud_note_locked( + deletion::delete_cloud_note_locked( &memo_file, &state.cloud_sync, notebook_id, note_id, filename, |path| crate::watcher::runtime::mark_self_write_for(app, path), - )? { - let path = notebook_path_from_relative(&base, &memo.relative_path) - .unwrap_or_else(|_| base.join(&memo.filename)); - let derived_changed = MemoDerivedChanged::from_deleted(&memo); - memo_events::emit( - app, - MemoEvent::Deleted { - id: memo.id.clone(), - path: path.to_string_lossy().into_owned(), - relative_path: memo.relative_path.clone(), - notebook_id: notebook_id.to_string(), - derived_changed, - source: MemoChangeSource::CloudSync, - }, - ); - } + )?; + crate::commands::path_change::NotebookPathDeleted::new( + notebook_id.to_string(), + filename.to_string(), + ) + .emit(app); } else { let bytes = content .as_ref() @@ -233,17 +249,30 @@ fn apply_v2_note_changes( let markdown = std::str::from_utf8(bytes) .map_err(|_| format!("CLOUD_NOTE_NOT_UTF8: {note_id}"))?; write_cloud_attachments(&base, attachments)?; - let current_memo = - memo_file.find_memo_by_relative_path_for_notebook_id(notebook_id, filename); - let old_path = current_memo.as_ref().map(|memo| { - notebook_path_from_relative(&base, &memo.relative_path) - .unwrap_or_else(|_| base.join(&memo.filename)) - }); let desired_path = safe_cloud_note_path(&base, filename)?; + let is_indexed = match memo_file.read_note_entry_by_path(notebook_id, filename) { + Ok(entry) => entry.is_some(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + memo_file + .reconcile_note_index(notebook_id) + .map_err(sync_error)?; + memo_file + .read_note_entry_by_path(notebook_id, filename) + .map_err(sync_error)? + .is_some() + } + Err(error) => return Err(sync_error(error)), + }; + let old_path = is_indexed.then(|| desired_path.clone()); if desired_path.exists() && old_path.as_ref() != Some(&desired_path) { memo_file - .register_existing_file_for_notebook_id(notebook_id, &desired_path) + .refresh_note_path(notebook_id, filename) .map_err(sync_error)?; + crate::commands::path_change::NotebookPathInvalidated::new( + notebook_id.to_string(), + filename.to_string(), + ) + .emit(app); if std::fs::read(&desired_path).map_err(sync_error)? != *bytes { return Err(format!( "CLOUD_PATH_COLLISION: local file preserved at {filename}" @@ -270,10 +299,9 @@ fn apply_v2_note_changes( if matches!(&old_path, Some(path) if path == &desired_path) && disk_hash.as_deref() == Some(expected_hash) { - let memo = memo_file - .register_existing_file_for_notebook_id(notebook_id, &desired_path) + memo_file + .refresh_note_path(notebook_id, filename) .map_err(sync_error)?; - let _ = memo; continue; } @@ -338,29 +366,19 @@ fn apply_v2_note_changes( )); } } - let memo = memo_file - .register_existing_file_for_notebook_id(notebook_id, &desired_path) + memo_file + .refresh_note_path(notebook_id, filename) .map_err(sync_error)?; if let Some(path) = old_path.filter(|path| path != &desired_path) { if path.exists() { std::fs::remove_file(&path).map_err(sync_error)?; } } - memo_events::emit( - app, - MemoEvent::Updated { - id: memo.id.clone(), - path: desired_path.to_string_lossy().into_owned(), - notebook_id: notebook_id.to_string(), - derived_changed: MemoDerivedChanged { - tags: true, - todos: true, - agents: true, - }, - memo, - source: MemoChangeSource::CloudSync, - }, - ); + crate::commands::path_change::NotebookPathInvalidated::new( + notebook_id.to_string(), + filename.to_string(), + ) + .emit(app); } } Ok(()) @@ -412,13 +430,6 @@ pub(crate) fn apply_v2_report( .is_some_and(|(_, _, _, deleted)| *deleted); if deleted { changed = true; - if state.agent_access.remove_notebook(&config.id) { - crate::events::emit_to( - app, - crate::commands::agent_access::AGENT_ACCESS_CHANGED_EVENT, - (), - ); - } } !deleted }); @@ -451,6 +462,11 @@ pub(crate) fn apply_v2_report( .write_notebook_configs(&configs) .map_err(sync_error)?; drop(memo_file); + crate::events::emit_to( + app, + crate::commands::agent_workspace::AGENT_WORKSPACE_CHANGED_EVENT, + (), + ); crate::events::emit_to(app, crate::commands::notebook::NOTEBOOKS_CHANGED_EVENT, ()); crate::commands::helpers::refresh_watcher_roots(state, app); } diff --git a/app/flowix-desktop/src/commands/collection.rs b/app/flowix-desktop/src/commands/collection.rs index 630378122..205cf64a4 100644 --- a/app/flowix-desktop/src/commands/collection.rs +++ b/app/flowix-desktop/src/commands/collection.rs @@ -2,7 +2,7 @@ use crate::{app::state::AppState, lock_utils::read_lock}; use flowix_core::{ collection::{ - valid_id, validate_properties, CollectionDocument, CollectionPlacement, CollectionProperty, + display_name_for_path, valid_id, validate_properties, CollectionDocument, CollectionPlacement, CollectionProperty, CollectionType, }, memo_file::{ @@ -269,7 +269,7 @@ impl CollectionCatalog { Ok(document) => { item.collection_type = document.collection.kind; item.collection_id = Some(document.collection.id.clone()); - item.name = document.collection.name.clone(); + item.name = display_name_for_path(path, document.collection.placement); item.schema_version = Some(document.schema_version); item.payload_schema_version = document.payload["schema_version"].as_i64(); item.revision = Some(document.collection.revision); @@ -535,7 +535,7 @@ impl CollectionCatalog { let (item, _, content, document) = self.read(&operation.collection_id)?; complete(&CollectionMutationResult { operation_id: operation.operation_id.clone(), status: "completed".into(), - collection_id: operation.collection_id.clone(), actual_name: document.collection.name, + collection_id: operation.collection_id.clone(), actual_name: item.name.clone(), previous_relative_path: operation.previous_path.clone(), notebook_path: self.root.to_string_lossy().into_owned(), actual_relative_path: item.relative_path, actual_revision: document.collection.revision, index_sequence: item.index_sequence, content, error_code: None, @@ -639,7 +639,7 @@ impl CollectionCatalog { let path = self.root.join(&item.relative_path); let source = fs::read_to_string(&path).map_err(|error| error.to_string())?; let mut document = CollectionDocument::parse(&source)?.normalized_v2()?; - if !document.rebase_paths(&previous, &next) { continue; } + if !document.rebase_paths_for_notebook(&self.notebook_id, &previous, &next)? { continue; } document.revise()?; if fs::read_to_string(&path).map_err(|error| error.to_string())? != source { return Err("COLLECTION_CONTENT_CONFLICT".into()); @@ -775,17 +775,19 @@ fn target_relative_path( } if document.collection.placement == CollectionPlacement::Folder { + let name = request.new_name.as_deref().ok_or("INVALID_COLLECTION_NAME")?.trim(); let folder = Path::new(&item.relative_path).parent() .filter(|path| !path.as_os_str().is_empty()) .ok_or("NOTEBOOK_ROOT_RENAME_FORBIDDEN")?; return Ok(folder.parent().unwrap_or(Path::new("")) - .join(&document.collection.name).join(".index.yaml") + .join(name).join(".index.yaml") .to_string_lossy().replace('\\', "/")); } + let name = request.new_name.as_deref().ok_or("INVALID_COLLECTION_NAME")?.trim(); Ok(Path::new(&item.relative_path) .parent() .unwrap_or(Path::new("")) - .join(format!("{}.index.yaml", document.collection.name)) + .join(format!("{}.index.yaml", name)) .to_string_lossy() .replace('\\', "/")) } @@ -910,7 +912,6 @@ fn mutate_locked( { return Err("INVALID_COLLECTION_NAME".into()); } - document.collection.name = name.to_owned(); } if let Some(patch) = &request.properties { for (key, value) in patch { @@ -976,11 +977,13 @@ fn mutate_locked( || (request.target_relative_path.is_some() && document.schema_version == 1); if rewrite_content { document = document.normalized_v2()?; + document.normalize_media_resource_ids(&request.notebook_id)?; if directory_move { - document.rebase_paths( + document.rebase_paths_for_notebook( + &request.notebook_id, &Path::new(&item.relative_path).parent().ok_or("INVALID_COLLECTION_PATH")?.to_string_lossy().replace('\\', "/"), &Path::new(&target_relative).parent().ok_or("INVALID_COLLECTION_PATH")?.to_string_lossy().replace('\\', "/"), - ); + )?; } document.revise()?; } @@ -1092,13 +1095,38 @@ pub async fn mutate_collection( ) -> Result { let memo = state.memo_file.clone(); crate::commands::document_operations::run("mutate_collection", operation_id, window.label().to_owned(), move||{ - let result=mutate_with_completion(&read_lock(&memo,"memo_file"),&request, &|result| emit_mutation_result(&app, &request.notebook_id, result))?; + let memo = read_lock(&memo, "memo_file"); + // Recovery may finish a prior directory move too, so protect the + // notebook's open documents until completion has rebased their leases. + let _watch_move = memo.get_notebook_config_by_id(&request.notebook_id) + .and_then(|notebook| crate::commands::external_document_watch::begin_path_move(&app, Path::new(¬ebook.path))); + let result=mutate_with_completion(&memo,&request, &|result| emit_mutation_result(&app, &request.notebook_id, result))?; Ok(result) }).await } +pub(crate) fn path_change_for_result(notebook_id: &str, result: &CollectionMutationResult) -> Option { + let (previous, next, directory) = match &result.directory_move { + Some(moved) => (&moved.previous_relative_path, &moved.relative_path, true), + None if result.previous_relative_path != result.actual_relative_path => + (&result.previous_relative_path, &result.actual_relative_path, false), + None => return None, + }; + Some(super::path_change::NotebookPathMove { + change_id: format!("collection:{}:{}", notebook_id, result.operation_id), + notebook_id: notebook_id.to_owned(), previous_relative_path: previous.clone(), + relative_path: next.clone(), directory, + }) +} + pub(crate) fn emit_mutation_result(app: &tauri::AppHandle, notebook_id: &str, result: &CollectionMutationResult) { - let _=app.emit("collection-changed",serde_json::json!({"notebookId":notebook_id,"collectionId":result.collection_id,"operationId":result.operation_id,"indexSequence":result.index_sequence,"relativePath":result.actual_relative_path,"previousRelativePath":result.previous_relative_path,"notebookPath":result.notebook_path,"name":result.actual_name,"revision":result.actual_revision})); + if let Some(moved) = &result.directory_move { + let root = Path::new(&result.notebook_path); + crate::commands::external_document_watch::rebase_path( + app, &root.join(&moved.previous_relative_path), &root.join(&moved.relative_path), + ); + } + let _=app.emit("collection-changed",serde_json::json!({"notebookId":notebook_id,"collectionId":result.collection_id,"operationId":result.operation_id,"indexSequence":result.index_sequence,"relativePath":result.actual_relative_path,"previousRelativePath":result.previous_relative_path,"notebookPath":result.notebook_path,"name":result.actual_name,"revision":result.actual_revision,"directoryMove":result.directory_move})); if let Some(moved) = &result.directory_move { if let Some(state) = app.try_state::() { let root = Path::new(&result.notebook_path); @@ -1107,12 +1135,9 @@ pub(crate) fn emit_mutation_result(app: &tauri::AppHandle, notebook_id: &str, re &root.join(&moved.previous_relative_path).to_string_lossy(), &root.join(&moved.relative_path).to_string_lossy(), ) { tracing::warn!(notebook_id, "agent references after collection folder move failed: {error}"); } } - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": notebook_id, "previousRelativePath": moved.previous_relative_path, - "relativePath": moved.relative_path, "kind": "path", "directory": true, "deleted": false, - })); - let _ = app.emit("file-management-changed", serde_json::json!({ "notebookId": notebook_id, "notebookPath": result.notebook_path })); + let _ = app.emit("file-management-changed", serde_json::json!({ "notebookId": notebook_id, "notebookPath": result.notebook_path, "operationId": result.operation_id })); } + if let Some(change) = path_change_for_result(notebook_id, result) { change.emit(app); } } /// Tree and title actions share the same collection operation when the directory owns an index. @@ -1133,6 +1158,7 @@ pub(crate) fn rename_folder_collection_at_path(memo: &MemoFile, folder: &Path, n expected_revision: document.collection.revision, operation_id: uuid::Uuid::now_v7().to_string(), new_name: Some(name.to_owned()), target_relative_path: None, properties: None, }; + let _watch_move = crate::commands::external_document_watch::begin_path_move(app, Path::new(¬ebook.path)); let result = mutate_with_completion(memo, &request, &|result| emit_mutation_result(app, &request.notebook_id, result))?; Ok(Some((notebook.id, result))) } @@ -1184,6 +1210,7 @@ pub async fn make_collection_identity_unique( return Err("COLLECTION_ID_NOT_DUPLICATED".into()); } document = document.normalized_v2()?; + document.normalize_media_resource_ids(¬ebook_id)?; document.collection.id = format!("col_{}", uuid::Uuid::now_v7().simple()); document.revise()?; atomic_write_bytes(&path, document.serialize()?.as_bytes()) @@ -1291,17 +1318,20 @@ mod tests { assert!(!root.join("Old").exists()); assert!(root.join("New/note.md").is_file()); let moved = memo.read_media_resource(&book, "New/photo.png").unwrap().unwrap(); - assert_eq!(moved.id, resource.id); + assert_eq!(moved.id, flowix_core::memo_file::media_resource_id(&book, "New/photo.png").unwrap()); assert_eq!(moved.properties["caption"], "keep"); let next = CollectionDocument::parse(&result.content).unwrap(); assert_eq!(next.collection.id, original.collection.id); assert_eq!(next.payload["dataset"]["records"]["include"][0]["relative_path"], "New/photo.png"); + assert_eq!(next.payload["dataset"]["records"]["include"][0]["resource_id"], moved.id); let other = CollectionDocument::parse(&fs::read_to_string(root.join("Outside.lib.yaml")).unwrap()).unwrap(); assert_eq!(other.collection.id, outside.collection.id); assert_eq!(other.collection.revision, 1); assert_eq!(other.payload["dataset"]["source"]["path"], "New"); assert_eq!(other.payload["dataset"]["records"]["include"][0]["relative_path"], "New/photo.png"); + assert_eq!(other.payload["dataset"]["records"]["include"][0]["resource_id"], moved.id); assert_eq!(other.payload["dataset"]["records"]["exclude"][0]["relative_path"], "New/hidden.png"); + assert_eq!(other.payload["dataset"]["records"]["exclude"][0]["resource_id"], format!("{book}::New/hidden.png")); with_catalog(&memo, &book, |catalog| { assert_eq!(catalog.resolve(&child.collection.id)?.relative_path, "New/child/.index.yaml"); Ok(()) diff --git a/app/flowix-desktop/src/commands/dialog.rs b/app/flowix-desktop/src/commands/dialog.rs index c1397c221..f30920a87 100644 --- a/app/flowix-desktop/src/commands/dialog.rs +++ b/app/flowix-desktop/src/commands/dialog.rs @@ -6,14 +6,13 @@ use tauri_plugin_opener::OpenerExt; use crate::lock_utils::read_lock; -use super::helpers::start_security_bookmark_access; use crate::app::state::AppState; // ==================== 鍩熷唴 helper ==================== +mod attachment_fingerprint; mod attachments; mod base64_input; -mod attachment_fingerprint; mod upload_quota; pub(crate) mod upload_sessions; @@ -21,60 +20,56 @@ pub(crate) mod upload_sessions; #[tauri::command] pub async fn select_directory(app: tauri::AppHandle) -> Option { - use std::sync::mpsc; - #[cfg(not(target_os = "macos"))] use tauri_plugin_dialog::DialogExt; - #[cfg(not(target_os = "macos"))] - use tokio::task; - - let (tx, rx) = mpsc::channel(); - - #[cfg(target_os = "macos")] - { - let handle = app.clone(); - let state_handle = handle.clone(); - handle - .run_on_main_thread(move || { - let result = crate::config::pick_directory_with_bookmark("选择笔记本文件夹").map( - |(path, bookmark)| { - let state = state_handle.state::(); - if let Err(e) = state - .security_bookmarks - .record_directory_bookmark(Path::new(&path), bookmark) - { - tracing::warn!("[select_directory] failed to persist bookmark: {e}"); - } - path - }, - ); - tx.send(result).ok(); + tokio::task::spawn_blocking(move || { + app.dialog() + .file() + .set_title("选择笔记本文件夹") + .blocking_pick_folder() + .map(|p| { + let path = Path::new(&p.to_string()).to_path_buf(); + record_bookmark_directory(&app, &path); + path.to_string_lossy().into_owned() }) - .ok()?; - return tokio::task::spawn_blocking(move || rx.recv().ok().flatten()) - .await - .ok() - .flatten(); - } + }) + .await + .ok() + .flatten() +} - #[cfg(not(target_os = "macos"))] - // Run blocking dialog in a background thread to avoid freezing the UI +#[tauri::command] +pub async fn select_import_directory( + app: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Option { + let generation = app + .state::() + .document_access + .generation(window.label()); + use tauri_plugin_dialog::DialogExt; let handle = app.clone(); - #[cfg(not(target_os = "macos"))] - task::spawn_blocking(move || { - let result = handle + let window_label = window.label().to_owned(); + tokio::task::spawn_blocking(move || { + handle .dialog() .file() - .set_title("选择笔记本文件夹") + .set_title("选择要导入的文件夹") .blocking_pick_folder() - .map(|p| p.to_string()); - tx.send(result).ok(); - }); - - #[cfg(not(target_os = "macos"))] - tokio::task::spawn_blocking(move || rx.recv().ok().flatten()) - .await - .ok() - .flatten() + .and_then(|path| { + let path = Path::new(&path.to_string()).to_path_buf(); + let granted = handle + .state::() + .document_access + .grant_directory_for_generation(&window_label, generation, &path); + if granted { + record_bookmark_directory(&handle, &path); + } + granted.then(|| path.to_string_lossy().into_owned()) + }) + }) + .await + .ok() + .flatten() } #[tauri::command] @@ -133,11 +128,22 @@ pub async fn select_files( .into_iter() .filter_map(|path| { let path = path.to_string(); - handle + let granted = handle .state::() .document_access - .grant_for_generation(window.label(), generation, Path::new(&path)) - .then_some(path) + .grant_for_generation(window.label(), generation, Path::new(&path)); + if granted { + if let Err(error) = handle + .state::() + .security_bookmarks + .record_file(Path::new(&path)) + { + tracing::warn!(%error, "cannot persist selected file bookmark"); + } + Some(path) + } else { + None + } }) .collect::>() }); @@ -148,6 +154,22 @@ pub async fn select_files( .flatten() } +fn record_bookmark_directory(app: &tauri::AppHandle, path: &Path) { + #[cfg(target_os = "macos")] + if let Err(error) = app + .state::() + .security_bookmarks + .record_directory(path) + { + tracing::warn!( + "failed to persist macOS folder bookmark for {}: {error}", + path.display() + ); + } + #[cfg(not(target_os = "macos"))] + let _ = (app, path); +} + #[tauri::command] pub async fn save_file_dialog( app: tauri::AppHandle, @@ -215,7 +237,6 @@ pub async fn save_notebook_asset( ) -> Result, String> { let permit = upload_quota::UPLOAD_QUOTA.acquire()?; let source = Path::new(&source_path); - start_security_bookmark_access(&state, source); let source = dunce::canonicalize(source).map_err(|error| error.to_string())?; if !super::helpers::can_access_document_path(&source, window.label(), &state) { return Err("Notebook asset source is not authorized".to_string()); @@ -240,7 +261,6 @@ pub async fn save_notebook_asset( .map_err(|error| error.to_string())?; attachments::notebook_root(&store, Some(&owner)).map_err(|error| error.to_string())? }; - start_security_bookmark_access(&state, &root); // Notebook-local assets are already durable workspace files. Return their // canonical path instead of making an attachment copy. if reuse_notebook_file.unwrap_or(false) && source.starts_with(&root) { @@ -291,8 +311,6 @@ pub async fn copy_attachment_file( .map_err(|error| error.to_string())?; let source = source.as_path(); - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, Path::new(&target_path)); let mut reader = fs::File::open(source).map_err(|error| error.to_string())?; state .export_access @@ -319,7 +337,6 @@ pub async fn open_attachment_file( .map_err(|error| error.to_string())?; let source = source.as_path(); - start_security_bookmark_access(&state, source); app.opener() .open_path(source.display().to_string(), None::) .map_err(|e| e.to_string()) diff --git a/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs b/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs index f94635e53..49af01751 100644 --- a/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs +++ b/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs @@ -1,6 +1,6 @@ +use sha2::{Digest, Sha256}; use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; -use sha2::{Digest, Sha256}; pub(super) fn fingerprint(file: &mut File) -> io::Result<(String, u64)> { file.seek(SeekFrom::Start(0))?; diff --git a/app/flowix-desktop/src/commands/dialog/attachments/tests.rs b/app/flowix-desktop/src/commands/dialog/attachments/tests.rs index 31c5ad044..0eed96473 100644 --- a/app/flowix-desktop/src/commands/dialog/attachments/tests.rs +++ b/app/flowix-desktop/src/commands/dialog/attachments/tests.rs @@ -39,19 +39,13 @@ fn explicit_destination_does_not_switch_current_notebook() { #[test] fn note_owner_resolves_notebook_from_absolute_path() { let (directory, store) = fixture(); - let created = flowix_core::MemoService::new(&store) - .create_memo_named_with_tag_in_directory( - Some("second"), - None, - "Untitled", - "# Note\nbody\n", - None, - ) + let created = flowix_core::NoteService::new(&store) + .create("second", None, "Untitled", "# Note\nbody\n") .unwrap(); let note = directory .path() .join("second") - .join(&created.memo.relative_path); + .join(&created.entry.relative_path); assert_eq!( resolve_note_owner(&store, None, Some(note.to_str().unwrap())).unwrap(), ("second".to_string(), Some("Untitled.md".to_string())) @@ -77,19 +71,13 @@ fn missing_or_unknown_owner_never_falls_back_to_current_notebook() { fn owned_save_validates_identity_before_creating_attachments() { let (directory, store) = fixture(); assert!(save_for_owner(&store, None, None, "data", &mut &b"data"[..], false).is_err()); - let created = flowix_core::MemoService::new(&store) - .create_memo_named_with_tag_in_directory( - Some("second"), - None, - "Untitled", - "# Owner\nbody\n", - None, - ) + let created = flowix_core::NoteService::new(&store) + .create("second", None, "Untitled", "# Owner\nbody\n") .unwrap(); let note = directory .path() .join("second") - .join(&created.memo.relative_path); + .join(&created.entry.relative_path); assert!(save_for_owner( &store, Some("first"), diff --git a/app/flowix-desktop/src/commands/dialog/upload_sessions.rs b/app/flowix-desktop/src/commands/dialog/upload_sessions.rs index 5c01c4858..eaa35ecdd 100644 --- a/app/flowix-desktop/src/commands/dialog/upload_sessions.rs +++ b/app/flowix-desktop/src/commands/dialog/upload_sessions.rs @@ -222,9 +222,6 @@ pub fn begin_attachment_upload( let store = read_lock(&state.memo_file, "memo_file"); let (notebook_id, owner_note) = attachments::resolve_note_owner(&store, None, Some(¬e_path)) .map_err(|error| error.to_string())?; - let root = attachments::notebook_root(&store, Some(¬ebook_id)) - .map_err(|error| error.to_string())?; - super::start_security_bookmark_access(&state, &root); state.upload_sessions.begin( window.label(), generation, diff --git a/app/flowix-desktop/src/commands/document_list.rs b/app/flowix-desktop/src/commands/document_list.rs index 446baa029..c2fc7d46a 100644 --- a/app/flowix-desktop/src/commands/document_list.rs +++ b/app/flowix-desktop/src/commands/document_list.rs @@ -89,9 +89,9 @@ pub(crate) fn refresh_view_document_catalog(memo: &MemoFile, id: &str, root: &Pa } } -fn rebase_collection_document_paths(source: &str, old_path: &str, new_path: &str) -> Option { +fn rebase_collection_document_paths(source: &str, notebook_id: &str, old_path: &str, new_path: &str) -> Option { let mut document = flowix_core::collection::CollectionDocument::parse(source).ok()?.normalized_v2().ok()?; - if !document.rebase_paths(old_path, new_path) { return None; } + if !document.rebase_paths_for_notebook(notebook_id, old_path, new_path).ok()? { return None; } document.revise().ok()?; document.serialize().ok() } @@ -215,7 +215,7 @@ pub(crate) fn rebase_collection_paths( let Ok(source) = fs::read_to_string(&path) else { continue; }; - let Some(next) = rebase_collection_document_paths(&source, old_relative_path, new_relative_path) + let Some(next) = rebase_collection_document_paths(&source, notebook_id, old_relative_path, new_relative_path) else { continue; }; diff --git a/app/flowix-desktop/src/commands/document_shutdown.rs b/app/flowix-desktop/src/commands/document_shutdown.rs deleted file mode 100644 index 7b58457f4..000000000 --- a/app/flowix-desktop/src/commands/document_shutdown.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::{ - collections::HashSet, - sync::{Mutex, OnceLock}, -}; -use tauri::{AppHandle, Emitter}; - -#[path = "document_shutdown_state.rs"] -mod shutdown_state; - -#[derive(Default)] -struct Shutdown { - windows: HashSet, - waiting: Option<(u64, HashSet, Completion)>, - sequence: u64, - approved: bool, -} -enum Completion { - Exit(i32), - #[cfg(windows)] - Prepared(tokio::sync::oneshot::Sender), -} -fn state() -> &'static Mutex { - static STATE: OnceLock> = OnceLock::new(); - STATE.get_or_init(Mutex::default) -} - -#[tauri::command] -pub fn register_document_window(window: tauri::WebviewWindow) { - state() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .windows - .insert(window.label().into()); -} - -pub fn forget_window(app: &AppHandle, label: &str) { - let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); - state.windows.remove(label); - finish_window(app, state, label); -} - -/// Return false while registered editor windows protect their drafts. -pub fn request_exit(app: &AppHandle, code: i32) -> bool { - begin_request(app, Completion::Exit(code)) -} - -/// Tauri's Windows updater uses process::exit, so saving must finish before -/// handing it the installation payload. Cancellation never exits the app. -#[cfg(windows)] -pub async fn prepare_for_update(app: &AppHandle) -> Result { - let (sender, receiver) = tokio::sync::oneshot::channel(); - begin_request(app, Completion::Prepared(sender)); - receiver.await.map_err(|_| { - "Update cancelled: documents could not finish saving. Save your documents and retry." - .to_string() - }) -} - -/// Unfreeze the editor if launching installation fails or the task is cancelled. -#[cfg(windows)] -pub struct UpdatePreparation { - app: AppHandle, - request: u64, -} -#[cfg(windows)] -impl Drop for UpdatePreparation { - fn drop(&mut self) { - let _ = self.app.emit("document:exit-cancelled", self.request); - } -} - -fn begin_request(app: &AppHandle, completion: Completion) -> bool { - let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); - if (state.approved && matches!(&completion, Completion::Exit(_))) || state.windows.is_empty() { - #[cfg(windows)] - if let Completion::Prepared(sender) = completion { - let _ = sender.send(UpdatePreparation { - app: app.clone(), - request: 0, - }); - } - return true; - } - if state.waiting.is_some() { - return false; - } - state.sequence += 1; - let request = state.sequence; - let windows = state.windows.clone(); - state.waiting = Some((request, windows.clone(), completion)); - drop(state); - let timeout_app = app.clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(20)).await; - if state_reset(request) { - let _ = timeout_app.emit("document:exit-cancelled", request); - } - }); - for window in windows { - if let Err(error) = app.emit_to(&window, "document:prepare-exit", request) { - tracing::error!("cannot prepare document window for exit: {error}"); - if state_reset(request) { - let _ = app.emit("document:exit-cancelled", request); - } - } - } - false -} - -fn state_reset(request: u64) -> bool { - let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); - if state - .waiting - .as_ref() - .is_some_and(|(id, _, _)| *id == request) - { - state.waiting = None; - true - } else { - false - } -} - -#[tauri::command] -pub async fn flush_document_background() -> bool { - crate::document_derived::flush().await -} - -#[tauri::command] -pub fn finish_document_shutdown( - window: tauri::WebviewWindow, - app: AppHandle, - request: u64, - ready: bool, -) { - let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); - let Some((active_request, _, _)) = state.waiting.as_ref() else { - return; - }; - if *active_request != request { - return; - } - if !ready { - state.waiting = None; - drop(state); - let _ = app.emit("document:exit-cancelled", request); - return; - } - finish_window(&app, state, window.label()); -} - -/// Both acknowledgements and window destruction can release the last draft. -fn finish_window(app: &AppHandle, mut state: std::sync::MutexGuard<'_, Shutdown>, label: &str) { - let Some((request, completion)) = shutdown_state::finish_window(&mut state.waiting, label) - else { - return; - }; - state.approved = matches!(&completion, Completion::Exit(_)); - drop(state); - match completion { - Completion::Exit(code) => app.exit(code), - #[cfg(windows)] - Completion::Prepared(sender) => { - // An unread queued guard is also dropped when an update is cancelled. - let _ = sender.send(UpdatePreparation { - app: app.clone(), - request, - }); - } - } -} diff --git a/app/flowix-desktop/src/commands/document_shutdown_state.rs b/app/flowix-desktop/src/commands/document_shutdown_state.rs deleted file mode 100644 index 2e2b6cac4..000000000 --- a/app/flowix-desktop/src/commands/document_shutdown_state.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::collections::HashSet; - -/// Release a ready or destroyed window and complete only after the last participant. -pub(crate) fn finish_window( - waiting: &mut Option<(u64, HashSet, C)>, - label: &str, -) -> Option<(u64, C)> { - if let Some((_, windows, _)) = waiting.as_mut() { - windows.remove(label); - } - if waiting - .as_ref() - .is_some_and(|(_, windows, _)| windows.is_empty()) - { - waiting - .take() - .map(|(request, _, completion)| (request, completion)) - } else { - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pending() -> Option<(u64, HashSet, i32)> { - Some((7, ["editor-a".into(), "editor-b".into()].into(), 42)) - } - - #[test] - fn acknowledgements_complete_only_after_last_window() { - let mut waiting = pending(); - assert_eq!(finish_window(&mut waiting, "editor-a"), None); - assert_eq!(finish_window(&mut waiting, "editor-b"), Some((7, 42))); - assert!(waiting.is_none()); - assert_eq!(finish_window(&mut waiting, "editor-b"), None); - } - - #[test] - fn destruction_completes_only_after_last_pending_window() { - let mut waiting = pending(); - assert_eq!(finish_window(&mut waiting, "unregistered-window"), None); - assert_eq!(finish_window(&mut waiting, "editor-b"), None); - assert_eq!(finish_window(&mut waiting, "editor-b"), None); - assert_eq!(finish_window(&mut waiting, "editor-a"), Some((7, 42))); - } - - #[test] - fn cancellation_does_not_produce_completion() { - let mut waiting: Option<(u64, HashSet, i32)> = None; - assert_eq!(finish_window(&mut waiting, "editor-a"), None); - } -} diff --git a/app/flowix-desktop/src/commands/dsh.rs b/app/flowix-desktop/src/commands/dsh.rs index 3559149ef..3c5a0984b 100644 --- a/app/flowix-desktop/src/commands/dsh.rs +++ b/app/flowix-desktop/src/commands/dsh.rs @@ -1,7 +1,8 @@ -//! DSH Runtime installation and Flowix plugin integration commands. +//! DSH discovery and Flowix plugin integration commands. use crate::app::state::AppState; use crate::events as dispatcher; +use std::path::PathBuf; use tauri::State; pub const DSH_RUNTIME_STATUS_CHANGED_EVENT: &str = "dsh-runtime-status-changed"; @@ -11,125 +12,75 @@ fn emit_runtime_status(app: &tauri::AppHandle, status: &crate::dsh::DshStatus) { } #[tauri::command] -pub fn dsh_status() -> crate::dsh::DshStatus { +pub fn dsh_status(state: State<'_, AppState>) -> crate::dsh::DshStatus { + let selected = state.user_config.get_preference().dsh_cli_path.map(PathBuf::from); + crate::dsh::set_user_selected_cli(selected); crate::dsh::status() } #[tauri::command] -pub async fn dsh_check_update() -> Result { - tauri::async_runtime::spawn_blocking(crate::dsh::check_for_update) - .await - .map_err(|error| format!("DSH update check task failed: {error}"))? -} - -#[tauri::command] -pub async fn dsh_archive_size() -> Option { - tauri::async_runtime::spawn_blocking(crate::dsh::latest_archive_size) - .await - .ok() - .flatten() -} +pub async fn dsh_select_cli_path(app: tauri::AppHandle) -> Option { + use std::sync::mpsc; + use tauri_plugin_dialog::DialogExt; -#[tauri::command] -pub fn dsh_download_status() -> Option { - crate::dsh::download_progress() + let (tx, rx) = mpsc::channel(); + let handle = app.clone(); + tokio::task::spawn_blocking(move || { + let result = handle + .dialog() + .file() + .set_title("Select DSH executable") + .blocking_pick_file() + .map(|path| path.to_string()); + let _ = tx.send(result); + }); + rx.recv().ok().flatten() } #[tauri::command] -pub async fn dsh_install_runtime( +pub fn dsh_set_cli_path( + path: String, state: State<'_, AppState>, app: tauri::AppHandle, ) -> Result { - ensure_runtime_replaceable(&state).await?; - let manager = state.deepseek_harness.clone(); - let install_app = app.clone(); - let status = tauri::async_runtime::spawn_blocking(move || { - crate::dsh::install_runtime_with_progress_before_publish(Some(install_app), move || { - tauri::async_runtime::block_on(manager.invalidate_hosts()) - .map_err(|error| format!("stop DeepSeek Harness before install: {error}")) - }) - }) - .await - .map_err(|error| format!("DSH installer task failed: {error}"))??; - emit_runtime_status(&app, &status); - Ok(status) -} - -#[tauri::command] -pub async fn dsh_update( - state: State<'_, AppState>, - app: tauri::AppHandle, -) -> Result { - ensure_runtime_replaceable(&state).await?; - let manager = state.deepseek_harness.clone(); - let update_app = app.clone(); - let status = tauri::async_runtime::spawn_blocking(move || { - crate::dsh::install_runtime_with_progress_before_publish(Some(update_app), move || { - tauri::async_runtime::block_on(manager.invalidate_hosts()) - .map_err(|error| format!("stop DeepSeek Harness before update: {error}")) - }) - }) - .await - .map_err(|error| format!("DSH updater task failed: {error}"))??; - emit_runtime_status(&app, &status); - Ok(status) -} - -/// Start or resume the managed runtime installation/update through one -/// command shared by the main window and Preferences. -#[tauri::command] -pub async fn dsh_ensure_runtime( - state: State<'_, AppState>, - app: tauri::AppHandle, -) -> Result { - ensure_runtime_replaceable(&state).await?; - let manager = state.deepseek_harness.clone(); - let ensure_app = app.clone(); - let status = tauri::async_runtime::spawn_blocking(move || { - crate::dsh::install_runtime_with_progress_before_publish(Some(ensure_app), move || { - tauri::async_runtime::block_on(manager.invalidate_hosts()) - .map_err(|error| format!("stop DeepSeek Harness before install/update: {error}")) - }) - }) - .await - .map_err(|error| format!("DSH ensure task failed: {error}"))??; + let path = PathBuf::from(path); + if !crate::dsh::is_valid_cli_path(&path) { + return Err("所选路径不是可执行的 DSH CLI".to_string()); + } + state + .user_config + .patch_preference(serde_json::json!({"dshCliPath": path.to_string_lossy()})) + .map_err(|error| error.to_string())?; + crate::dsh::set_user_selected_cli(Some(path)); + let status = crate::dsh::status(); emit_runtime_status(&app, &status); Ok(status) } -async fn ensure_runtime_replaceable(state: &State<'_, AppState>) -> Result<(), String> { - if crate::dsh::status().installed { - state - .deepseek_harness - .ensure_hosts_replaceable() - .await - .map_err(|error| format!("prepare DeepSeek Harness install/update: {error}"))?; - } - Ok(()) -} - -#[tauri::command] -pub fn dsh_cancel_update() -> bool { - crate::dsh::cancel_update(); - true -} - -/// Shut down every running dsh-host, then remove the managed runtime tree. -/// User state under `~/.dsh` (settings, sessions) is preserved; reinstalling -/// from the Runtime tab restores a clean runtime. +/// Install the Flowix App Server profile after the user requests it from +/// Preferences. Runtime discovery at startup is read-only and never installs. #[tauri::command] -pub async fn dsh_uninstall( +pub async fn dsh_install_appserver_profile( state: State<'_, AppState>, app: tauri::AppHandle, ) -> Result { state .deepseek_harness - .prepare_uninstall() - .await - .map_err(|error| format!("stop DeepSeek Harness before uninstall: {error}"))?; - let status = tauri::async_runtime::spawn_blocking(crate::dsh::uninstall_runtime) + .ensure_hosts_replaceable() .await - .map_err(|error| format!("DSH uninstall task failed: {error}"))??; + .map_err(|error| format!("stop DSH before installing App Server: {error}"))?; + state.deepseek_harness.invalidate_hosts().await?; + let install_app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + crate::dsh::install_appserver_profile(&install_app) + }) + .await + .map_err(|error| format!("DSH App Server installer task failed: {error}"))??; + if let Err(error) = state.deepseek_harness.dsh_model_configs().await { + let _ = state.deepseek_harness.invalidate_hosts().await; + return Err(format!("DSH App Server 安装后连接检查失败:{error}")); + } + let status = crate::dsh::status(); emit_runtime_status(&app, &status); Ok(status) } diff --git a/app/flowix-desktop/src/commands/external_document.rs b/app/flowix-desktop/src/commands/external_document.rs index 0d8f2e804..f5cad0972 100644 --- a/app/flowix-desktop/src/commands/external_document.rs +++ b/app/flowix-desktop/src/commands/external_document.rs @@ -8,17 +8,35 @@ use crate::app::state::AppState; use crate::commands::external_document_watch::ExternalDocumentWatchState; use crate::commands::helpers::{ can_access_document_path, can_access_scoped_file, refresh_notebook_note_index, - start_security_bookmark_access, }; use crate::lock_utils::read_lock; use flowix_core::memo_file::{FileWriteOutcome, MergedFileWriteOutcome}; #[tauri::command] -pub fn get_external_document_mime_type(file_path: String) -> String { - mime_guess::from_path(file_path) - .first_or_octet_stream() - .essence_str() - .to_string() +pub async fn detect_external_document_text( + window: tauri::WebviewWindow, + file_path: String, + #[allow(non_snake_case)] scopePath: Option, + app: tauri::AppHandle, +) -> Result { + let window_label = window.label().to_string(); + crate::document_io::run("external_document_text_detection", move || { + let state = app.state::(); + let path = PathBuf::from(file_path); + if !path.is_absolute() || !path.is_file() { + return Err("external document is unavailable".to_string()); + } + if !can_access_document_path(&path, &window_label, &state) + && !can_access_scoped_file(&path, scopePath.as_deref(), &state) + { + return Err("external document is outside its authorized scope".to_string()); + } + + // A local file has no HTTP Content-Type header. This is only a text + // readability probe for unknown paths, not a MIME type resolver. + Ok(looks_like_utf8_text_file(&path)) + }) + .await? } #[derive(Debug, Clone, Serialize)] @@ -185,7 +203,6 @@ pub(crate) fn exact_existing_external_path( { return Err("external document is outside its authorized scope".to_string()); } - start_security_bookmark_access(state.inner(), &requested); if !supported_text_document_path(&requested) { return Err("external document is not a supported text file".to_string()); } diff --git a/app/flowix-desktop/src/commands/external_document_watch.rs b/app/flowix-desktop/src/commands/external_document_watch.rs index 47840c359..3ee648bfb 100644 --- a/app/flowix-desktop/src/commands/external_document_watch.rs +++ b/app/flowix-desktop/src/commands/external_document_watch.rs @@ -30,6 +30,7 @@ struct WatchRegistry { delivering: HashSet, delivery_epochs: HashMap, active_writes: HashMap, + active_path_moves: HashMap, completed_writes: HashMap, } @@ -47,7 +48,87 @@ struct ExternalDocumentChangedPayload { pub struct ExternalDocumentWatchState { registry: Arc>, watcher: Mutex>, - next_lease: AtomicUsize, + next_id: AtomicUsize, +} + +// A local move can spend time updating indexes after the filesystem rename. +// Keep old-path polls silent until its confirmed path mapping has been applied. +pub(crate) struct PathMoveGuard { + registry: Arc>, + id: usize, +} + +impl Drop for PathMoveGuard { + fn drop(&mut self) { + if let Ok(mut registry) = self.registry.lock() { + registry.active_path_moves.remove(&self.id); + } + } +} + +fn watch_prefix(path: &Path) -> PathBuf { + // Resolve the parent even after a move, retaining the old basename for + // case-only renames on case-insensitive filesystems. + path.parent() + .and_then(|parent| dunce::canonicalize(parent).ok()) + .zip(path.file_name()) + .map(|(parent, name)| parent.join(name)) + .unwrap_or_else(|| path.to_path_buf()) +} + +pub(crate) fn begin_path_move(app: &tauri::AppHandle, path: &Path) -> Option { + let state = app.try_state::()?; + let path = watch_prefix(path); + let mut registry = state.registry.lock().ok()?; + let id = state.next_id.fetch_add(1, Ordering::Relaxed); + registry.active_path_moves.insert(id, path); + Some(PathMoveGuard { + registry: state.registry.clone(), + id, + }) +} + +/// Called by both local mutations and confirmed native rename pairs before +/// publishing the existing UI path-move event. +pub(crate) fn rebase_path(app: &tauri::AppHandle, previous: &Path, next: &Path) { + if let Some(state) = app.try_state::() { + state.rebase_path(&watch_prefix(previous), &watch_prefix(next)); + } +} + +fn rebase_registry_paths(registry: &mut WatchRegistry, previous: &Path, next: &Path) { + if previous == next { + return; + } + let moved = registry + .leases + .iter() + .filter_map(|(id, lease)| { + let suffix = lease.path.strip_prefix(previous).ok()?; + Some((id.clone(), next.join(suffix))) + }) + .collect::>(); + for (id, path) in moved { + registry.leases.get_mut(&id).expect("existing lease").path = path; + *registry.generations.entry(id.clone()).or_default() += 1; + invalidate_delivery(registry, &id); + // Preserve the last observed revision: a content change concurrent + // with the move must still be delivered at the new path. + } + // A native rename can arrive before the local command finishes. Its + // notification pause follows the prefix until the same guard is released. + for prefix in registry.active_path_moves.values_mut() { + if let Ok(suffix) = prefix.strip_prefix(previous) { + *prefix = next.join(suffix); + } + } + registry.path_ref_counts.clear(); + for lease in registry.leases.values() { + *registry + .path_ref_counts + .entry(lease.path.clone()) + .or_default() += 1; + } } impl ExternalDocumentWatchState { @@ -72,7 +153,55 @@ impl ExternalDocumentWatchState { Self { registry, watcher: Mutex::new(watcher), - next_lease: AtomicUsize::new(1), + next_id: AtomicUsize::new(1), + } + } + + fn rebase_path(&self, previous: &Path, next: &Path) { + let Ok(mut registry) = self.registry.lock() else { + return; + }; + if previous == next { + return; + } + let old_paths = registry + .path_ref_counts + .keys() + .cloned() + .collect::>(); + let moved_paths = old_paths + .iter() + .filter_map(|path| { + path.strip_prefix(previous) + .ok() + .map(|suffix| next.join(suffix)) + }) + .collect::>(); + rebase_registry_paths(&mut registry, previous, next); + if moved_paths.is_empty() { + return; + } + // Use the same registry -> watcher lock order as registration. Keep + // lease changes and native watch changes together for concurrent opens. + let Ok(mut watcher) = self.watcher.lock() else { + return; + }; + if let Some(watcher) = watcher.as_mut() { + for path in &old_paths { + if !registry.path_ref_counts.contains_key(path) { + watcher.unwatch(path).ok(); + } + } + for path in &moved_paths { + watcher.unwatch(path).ok(); + if let Err(error) = watcher.watch(path, RecursiveMode::NonRecursive) { + // Revision polling remains available if native rebinding fails. + tracing::warn!( + "[external-watch] failed to watch moved path {}: {error}", + path.display() + ); + } + } } } @@ -80,7 +209,7 @@ impl ExternalDocumentWatchState { let lease_id = format!( "external-watch:{}:{}", window_label, - self.next_lease.fetch_add(1, Ordering::Relaxed) + self.next_id.fetch_add(1, Ordering::Relaxed) ); let mut registry = self @@ -179,7 +308,10 @@ impl ExternalDocumentWatchState { pub fn begin_window_write(&self, path: &Path) { if let Ok(mut registry) = self.registry.lock() { - *registry.active_writes.entry(path.to_path_buf()).or_default() += 1; + *registry + .active_writes + .entry(path.to_path_buf()) + .or_default() += 1; } } @@ -214,7 +346,9 @@ impl ExternalDocumentWatchState { if written_revision == file_revision(path) && std::fs::read_to_string(path).is_ok_and(|disk| disk == written_content) { - let leases = registry.leases.iter() + let leases = registry + .leases + .iter() .filter(|(_, lease)| lease.path == path) .map(|(id, lease)| (id.clone(), lease.clone())) .collect::>(); @@ -222,7 +356,11 @@ impl ExternalDocumentWatchState { invalidate_delivery(&mut registry, &id); let delivered = lease.window_label == origin || emit_external_document_changed( - app, &lease, written_revision.clone(), "user_edit", Some(&origin), + app, + &lease, + written_revision.clone(), + "user_edit", + Some(&origin), ); if delivered { registry.revisions.insert(id, written_revision.clone()); @@ -301,22 +439,18 @@ fn emit_if_revision_changed( lease_id: &str, lease: &WatchLease, ) { - let revision = file_revision(&lease.path); - let delivery_epoch = { - let Ok(mut registry) = registry.lock() else { - return; - }; - try_claim_revision(&mut registry, lease_id, &lease.path, &revision) - .then(|| registry.delivery_epochs.get(lease_id).copied()) - .flatten() - }; - let Some(delivery_epoch) = delivery_epoch else { + // Serialize claim + emission with path rebasing. Otherwise a poll can + // claim an old-path deletion, then emit it after the lease has moved. + let Ok(mut registry) = registry.lock() else { return; }; - let delivered = emit_external_document_changed(app, lease, revision.clone(), "external", None); - if let Ok(mut registry) = registry.lock() { - complete_revision_delivery(&mut registry, lease_id, delivery_epoch, delivered, revision); + let revision = file_revision(&lease.path); + if !try_claim_revision(&mut registry, lease_id, &lease.path, &revision) { + return; } + let delivery_epoch = registry.delivery_epochs[lease_id]; + let delivered = emit_external_document_changed(app, lease, revision.clone(), "external", None); + complete_revision_delivery(&mut registry, lease_id, delivery_epoch, delivered, revision); } /// Pure state transition: mark a lease as "delivering" for a given revision. @@ -330,22 +464,33 @@ fn try_claim_revision( lease_path: &Path, revision: &str, ) -> bool { - if registry.active_writes.contains_key(lease_path) - || registry.leases - .get(lease_id) - .is_none_or(|current| current.path != lease_path) + if registry + .active_path_moves + .values() + .any(|prefix| lease_path.starts_with(prefix)) + || registry.active_writes.contains_key(lease_path) + || registry + .leases + .get(lease_id) + .is_none_or(|current| current.path != lease_path) || registry.revisions.get(lease_id).map(String::as_str) == Some(revision) || registry.delivering.contains(lease_id) { return false; } registry.delivering.insert(lease_id.to_string()); - *registry.delivery_epochs.entry(lease_id.to_string()).or_default() += 1; + *registry + .delivery_epochs + .entry(lease_id.to_string()) + .or_default() += 1; true } fn invalidate_delivery(registry: &mut WatchRegistry, lease_id: &str) { - *registry.delivery_epochs.entry(lease_id.to_string()).or_default() += 1; + *registry + .delivery_epochs + .entry(lease_id.to_string()) + .or_default() += 1; registry.delivering.remove(lease_id); } @@ -521,6 +666,193 @@ pub fn unwatch_external_document( mod tests { use super::*; + fn add_lease(registry: &mut WatchRegistry, id: &str, path: &Path) { + registry.leases.insert( + id.into(), + WatchLease { + window_label: id.into(), + path: path.into(), + }, + ); + *registry.path_ref_counts.entry(path.into()).or_default() += 1; + registry.revisions.insert(id.into(), file_revision(path)); + } + + #[test] + fn native_watch_is_rebound_to_the_moved_document() { + let temp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp.path()).unwrap(); + let old = root.join("Old"); + let new = root.join("New"); + std::fs::create_dir(&old).unwrap(); + let old_note = old.join("note.md"); + let new_note = new.join("note.md"); + std::fs::write(&old_note, "original").unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = tx.send(event); + }) + .unwrap(); + watcher + .watch(&old_note, RecursiveMode::NonRecursive) + .unwrap(); + let mut registry = WatchRegistry::default(); + add_lease(&mut registry, "main", &old_note); + let state = ExternalDocumentWatchState { + registry: Arc::new(Mutex::new(registry)), + watcher: Mutex::new(Some(watcher)), + next_id: AtomicUsize::new(2), + }; + std::fs::rename(&old, &new).unwrap(); + state.rebase_path(&old, &new); + std::fs::write(&new_note, "external change at the new path").unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let event = rx + .recv_timeout(remaining) + .expect("native event for the moved file") + .unwrap(); + if event + .paths + .iter() + .any(|path| event_matches_watched_file(path, &new_note)) + { + break; + } + } + assert_eq!(state.registry.lock().unwrap().leases["main"].path, new_note); + } + + #[test] + fn folder_move_preserves_revisions_and_rejects_stale_deletes_across_windows() { + let temp = tempfile::tempdir().unwrap(); + let old = temp.path().join("Old"); + let new = temp.path().join("New"); + std::fs::create_dir_all(old.join("sub")).unwrap(); + let old_note = old.join("sub/note.md"); + let new_note = new.join("sub/note.md"); + std::fs::write(&old_note, "original").unwrap(); + let mut registry = WatchRegistry::default(); + add_lease(&mut registry, "main", &old_note); + add_lease(&mut registry, "second", &old_note); + let unrelated = temp.path().join("Old-other/note.md"); + add_lease(&mut registry, "other", &unrelated); + // An already-registered destination must share its native watch. + add_lease(&mut registry, "destination", &new_note); + assert!(try_claim_revision( + &mut registry, + "main", + &old_note, + "stale" + )); + let stale_epoch = registry.delivery_epochs["main"]; + let observed = registry.revisions["main"].clone(); + + std::fs::rename(&old, &new).unwrap(); + rebase_registry_paths(&mut registry, &old, &new); + rebase_registry_paths(&mut registry, &old, &new); // duplicate native rename pair + assert_eq!(registry.leases["main"].path, new_note); + assert_eq!(registry.leases["second"].path, new_note); + assert_eq!(registry.leases["other"].path, unrelated); + assert_eq!(registry.path_ref_counts[&new_note], 3); + assert!(!registry.path_ref_counts.contains_key(&old_note)); + assert!(!try_claim_revision( + &mut registry, + "main", + &old_note, + "deleted" + )); + complete_revision_delivery(&mut registry, "main", stale_epoch, true, "deleted".into()); + assert_eq!(registry.revisions["main"], observed); + assert!(!try_claim_revision( + &mut registry, + "main", + &new_note, + &file_revision(&new_note) + )); + + std::fs::write(&new_note, "genuine external content change").unwrap(); + let changed = file_revision(&new_note); + assert!(try_claim_revision( + &mut registry, + "main", + &new_note, + &changed + )); + let epoch = registry.delivery_epochs["main"]; + complete_revision_delivery(&mut registry, "main", epoch, true, changed); + std::fs::remove_file(&new_note).unwrap(); + assert!(try_claim_revision( + &mut registry, + "main", + &new_note, + &file_revision(&new_note) + )); + assert_eq!(decrement_path_ref_count(&mut registry, &new_note), None); + assert_eq!(registry.path_ref_counts[&new_note], 2); + } + + #[test] + fn path_move_guard_defers_only_its_subtree_and_releases_on_failure() { + let prefix = PathBuf::from("/notes/old"); + let note = prefix.join("note.md"); + let other = PathBuf::from("/notes/old-other/note.md"); + let registry = Arc::new(Mutex::new(WatchRegistry::default())); + { + let mut state = registry.lock().unwrap(); + add_lease(&mut state, "note", ¬e); + add_lease(&mut state, "other", &other); + state.active_path_moves.insert(1, prefix.clone()); + state.active_path_moves.insert(2, prefix.clone()); + assert!(!try_claim_revision(&mut state, "note", ¬e, "deleted")); + assert!(try_claim_revision(&mut state, "other", &other, "changed")); + } + let outer = PathMoveGuard { + registry: registry.clone(), + id: 1, + }; + let inner = PathMoveGuard { + registry: registry.clone(), + id: 2, + }; + drop(inner); + { + let mut state = registry.lock().unwrap(); + assert_eq!(state.active_path_moves.len(), 1); + let next = Path::new("/notes/new"); + rebase_registry_paths(&mut state, &prefix, next); + assert_eq!(state.active_path_moves[&1], next); + assert!(!try_claim_revision( + &mut state, + "note", + &next.join("note.md"), + "changed" + )); + } + drop(outer); // also runs when a mutation returns an error + let mut state = registry.lock().unwrap(); + assert!(state.active_path_moves.is_empty()); + assert!(try_claim_revision( + &mut state, + "note", + Path::new("/notes/new/note.md"), + "genuine-delete" + )); + } + + #[test] + fn case_only_move_updates_the_lease_spelling() { + let mut registry = WatchRegistry::default(); + add_lease(&mut registry, "note", Path::new("/notes/Old/a.md")); + rebase_registry_paths( + &mut registry, + Path::new("/notes/Old"), + Path::new("/notes/old"), + ); + assert_eq!(registry.leases["note"].path, Path::new("/notes/old/a.md")); + } + #[test] fn file_reference_count_releases_only_the_last_lease() { let path = PathBuf::from("/tmp/flowix-external-watch/Reference.md"); @@ -593,11 +925,20 @@ mod tests { fn local_write_defers_file_notifications_until_write_settles() { let path = PathBuf::from("/tmp/flowix-external-watch/docs/Reference.md"); let mut registry = WatchRegistry::default(); - registry.leases.insert("lease".into(), WatchLease { - window_label: "main".into(), path: path.clone(), - }); + registry.leases.insert( + "lease".into(), + WatchLease { + window_label: "main".into(), + path: path.clone(), + }, + ); registry.active_writes.insert(path.clone(), 1); - assert!(!try_claim_revision(&mut registry, "lease", &path, "changed")); + assert!(!try_claim_revision( + &mut registry, + "lease", + &path, + "changed" + )); registry.active_writes.remove(&path); assert!(try_claim_revision(&mut registry, "lease", &path, "changed")); } @@ -637,9 +978,13 @@ mod tests { let path = PathBuf::from("/tmp/flowix-external-watch/docs/Reference.md"); let lease_id = "lease".to_string(); let mut registry = WatchRegistry::default(); - registry.leases.insert(lease_id.clone(), WatchLease { - window_label: "main".into(), path: path.clone(), - }); + registry.leases.insert( + lease_id.clone(), + WatchLease { + window_label: "main".into(), + path: path.clone(), + }, + ); assert!(try_claim_revision(&mut registry, &lease_id, &path, "old")); let old_epoch = registry.delivery_epochs[&lease_id]; diff --git a/app/flowix-desktop/src/commands/file.rs b/app/flowix-desktop/src/commands/file.rs index 50ea0a54e..5f108dbd8 100644 --- a/app/flowix-desktop/src/commands/file.rs +++ b/app/flowix-desktop/src/commands/file.rs @@ -16,18 +16,35 @@ use crate::config::path_is_inside; use crate::lock_utils::read_lock; use flowix_core::memo_file::{ media_kind_for_path, notebook_path_from_relative, FileManagementPolicy, MediaResourceKind, - MemoColor, MemoFile, + NoteColor, MemoFile, }; use sha2::{Digest, Sha256}; use tokio::sync::{Notify, Semaphore}; use super::helpers::{ - can_access_document_path, can_access_scoped_file, is_agent_access_folder, + can_access_document_path, can_access_scoped_file, is_attached_notebook_directory, is_internal_notebook_path, is_registered_notebook_path, is_registered_notebook_root, - refresh_notebook_note_index, start_security_bookmark_access, + refresh_notebook_note_index, FileOperationFailure, }; use crate::app::state::AppState; +pub mod folder_import; + +#[cfg(target_os = "macos")] +fn is_cloud_file_not_downloaded(path: &Path) -> bool { + use std::os::macos::fs::MetadataExt; + + // macOS marks cloud-only placeholders with UF_DATALESS. + const UF_DATALESS: u32 = 0x4000_0000; + fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.st_flags() & UF_DATALESS != 0) +} + +#[cfg(not(target_os = "macos"))] +fn is_cloud_file_not_downloaded(_path: &Path) -> bool { + false +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct DocTreeItem { @@ -64,9 +81,8 @@ pub enum DocTreeResourceKind { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocTreeMemoMeta { - pub id: String, pub icon: Option, - pub colors: Vec, + pub colors: Vec, pub favorited: bool, } @@ -196,20 +212,20 @@ fn memo_tree_metadata_for_directory( .strip_prefix(¬ebook_root) .ok()? .to_path_buf(); - let index = memo_file - .read_index_for_notebook_id(Some(&config.id)) - .ok()??; + let notes = match memo_file.read_note_entries(&config.id) { + Ok(notes) => notes, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + state.notebook_background.request(&config.id, true, false); + return None; + } + Err(_) => return None, + }; Some( - index - .memos + notes .into_iter() .filter_map(|entry| { - let relative_path = if entry.relative_path.is_empty() { - entry.filename - } else { - entry.relative_path - }; + let relative_path = entry.relative_path; if !relative_memo_is_direct_child(Path::new(&relative_path), &relative_directory) { return None; } @@ -220,7 +236,6 @@ fn memo_tree_metadata_for_directory( MemoTreeMetadata { created_ms: u64::try_from(entry.created_at).ok(), memo: DocTreeMemoMeta { - id: entry.id, icon: entry.icon, colors: entry.colors, favorited: entry.favorited, @@ -444,20 +459,14 @@ pub(crate) fn default_create_folder_for_notebook(root: &Path) -> Result Result<(), String> { - if read_notebook_view_preferences(root) - .file_management - .legacy_watcher_migrated - { - return Ok(()); - } - with_registered_notebook_change(root, memo, "migrate_file_management_preferences", || { - migrate_legacy_watcher_rules_locked(root, config) - }) +pub(crate) fn write_default_notebook_view_preferences(root: &Path) -> Result<(), String> { + let path = notebook_preferences_path(root)?; + fs::create_dir_all(path.parent().ok_or("INVALID_NOTEBOOK_CONFIG_DIRECTORY")?) + .map_err(|error| format!("create notebook config directory failed: {error}"))?; + let bytes = serde_json::to_vec_pretty(&NotebookViewPreferences::default()) + .map_err(|error| format!("serialize default notebook preferences failed: {error}"))?; + flowix_core::memo_file::atomic_write_bytes(&path, &bytes) + .map_err(|error| format!("write default notebook preferences failed: {error}")) } fn with_registered_notebook_change( @@ -470,61 +479,6 @@ fn with_registered_notebook_change( .map_err(|error| error.to_string())? } -fn migrate_legacy_watcher_rules_locked( - root: &Path, - config: &crate::watcher::WhitelistConfig, -) -> Result<(), String> { - let mut preferences = read_notebook_view_preferences(root); - if preferences.file_management.legacy_watcher_migrated { - return Ok(()); - } - let policy = &mut preferences.file_management; - policy.legacy_skip_dirs = config - .skip_dirs - .iter() - .filter(|name| { - !matches!( - name.as_str(), - ".flowix" - | ".plugin-output" - | ".git" - | ".DS_Store" - | "node_modules" - | ".cache" - | ".trash" - | "attachments" - | "attachments-cache" - ) - }) - .cloned() - .collect(); - policy.legacy_skip_files = config - .skip_files - .iter() - .filter(|name| { - !matches!( - name.as_str(), - "*.tmp" | "*.swp" | "*~" | ".DS_Store" | "Thumbs.db" | "*.bak" | "*.lock" - ) - }) - .cloned() - .collect(); - policy.legacy_watcher_migrated = true; - let path = notebook_preferences_path(root)?; - fs::create_dir_all(path.parent().ok_or("INVALID_NOTEBOOK_CONFIG_DIRECTORY")?) - .map_err(|error| error.to_string())?; - if !policy.legacy_skip_dirs.is_empty() || !policy.legacy_skip_files.is_empty() { - flowix_core::memo_file::atomic_write_bytes( - &root.join(".flowix/file-management-refresh-pending"), - uuid::Uuid::new_v4().to_string().as_bytes(), - ) - .map_err(|error| error.to_string())?; - } - let bytes = serde_json::to_vec_pretty(&preferences).map_err(|error| error.to_string())?; - flowix_core::memo_file::atomic_write_bytes(&path, &bytes).map_err(|error| error.to_string())?; - Ok(()) -} - fn normalize_relative_folder_paths(folders: Vec) -> Result, String> { let mut normalized = Vec::new(); for folder in folders { @@ -571,11 +525,6 @@ pub fn get_notebook_view_preferences( if !is_registered_notebook_root(root, &state) { return Err("NOTEBOOK_NOT_REGISTERED".to_string()); } - migrate_legacy_watcher_rules( - root, - &state.user_config.get_preference().watcher, - &read_lock(&state.memo_file, "memo_file"), - )?; if root .join(".flowix/file-management-refresh-pending") .exists() @@ -656,11 +605,6 @@ pub fn get_file_management_candidates( if !is_registered_notebook_root(root, &state) { return Err("NOTEBOOK_NOT_REGISTERED".to_string()); } - migrate_legacy_watcher_rules( - root, - &state.user_config.get_preference().watcher, - &read_lock(&state.memo_file, "memo_file"), - )?; let policy = read_notebook_view_preferences(root).file_management; let defaults = FileManagementPolicy::default(); let mut pending = vec![root.to_path_buf()]; @@ -689,7 +633,6 @@ pub fn get_file_management_candidates( let is_directory = metadata.is_dir(); let name = Path::new(entry.file_name().as_os_str()).to_path_buf(); let own_name_ignored = defaults.is_ignored(&name) - || policy.matches_legacy_entry(&path) || FileManagementPolicy::has_hidden_attribute(&path); if FileManagementPolicy::is_locked_name(&entry.file_name().to_string_lossy()) { continue; @@ -783,7 +726,6 @@ fn collect_notebook_settings_tree( let relative_path = relative.to_string_lossy().replace('\\', "/"); let hidden = policy.is_tree_hidden_at(root, relative); let default_hidden = FileManagementPolicy::default().is_ignored_at(root, relative) - || policy.matches_legacy_entry(&path) || FileManagementPolicy::has_hidden_attribute(&path); if metadata.is_dir() { let is_flowix_dir = relative_path == ".flowix"; @@ -857,10 +799,6 @@ pub fn set_notebook_view_preferences( &read_lock(&state.memo_file, "memo_file"), "set_notebook_view_preferences", || { - migrate_legacy_watcher_rules_locked( - &root, - &state.user_config.get_preference().watcher, - )?; let previous_preferences = read_notebook_view_preferences(&root); let previous_policy = previous_preferences.file_management.clone(); let path = notebook_preferences_path(&root)?; @@ -883,9 +821,6 @@ pub fn set_notebook_view_preferences( excluded_index_paths: normalize_excluded_index_paths( preferences.file_management.excluded_index_paths, )?, - legacy_skip_dirs: previous_policy.legacy_skip_dirs.clone(), - legacy_skip_files: previous_policy.legacy_skip_files.clone(), - legacy_watcher_migrated: previous_policy.legacy_watcher_migrated, }, refresh_pending: false, }; @@ -975,7 +910,6 @@ fn normalize_default_create_folder(folder: Option) -> Result) -> Option> { let path = Path::new(&space_path); - start_security_bookmark_access(&state, path); if !path.exists() || is_internal_notebook_path(path, &state) || !is_browsable_scope(path, &state) @@ -996,7 +930,6 @@ pub fn get_file_tree(space_path: String, state: State) -> Option) -> Vec { let path = Path::new(&dir_path); - start_security_bookmark_access(&state, path); if !path.exists() || is_internal_notebook_path(path, &state) || !is_browsable_scope(path, &state) @@ -1019,7 +952,7 @@ pub fn get_dir_children(dir_path: String, state: State) -> Vec) -> bool { !is_internal_notebook_path(path, state) - && (is_registered_notebook_path(path, state) || is_agent_access_folder(path, state)) + && (is_registered_notebook_path(path, state) || is_attached_notebook_directory(path, state)) } #[tauri::command] @@ -1032,7 +965,6 @@ pub fn read_file( eprintln!("[read_file] refused out-of-scope path: {}", file_path); return None; } - start_security_bookmark_access(&state, Path::new(&file_path)); fs::read_to_string(&file_path).ok() } @@ -1065,7 +997,6 @@ fn authorized_docx_path( { return Err("DOCX_FORBIDDEN: file is outside its authorized space".to_string()); } - start_security_bookmark_access(state, &path); Ok(path) } @@ -1278,7 +1209,6 @@ fn create_docx_markdown_with_images( { return Err("DOCX_FORBIDDEN: attachment directory escapes this space".to_string()); } - start_security_bookmark_access(state, &attachment_directory); for (index, image) in prepared_images.iter().enumerate() { match create_docx_image_file(&attachment_directory, space_path, state, image, index) { @@ -1401,7 +1331,6 @@ pub fn read_image_file( eprintln!("[read_image_file] refused file: {}", file_path); return None; } - start_security_bookmark_access(&state, path); let bytes = fs::read(path).ok()?; Some(format!( "data:{mime};base64,{}", @@ -1422,7 +1351,6 @@ pub async fn read_image_preview( if !can_access_scoped_file(path, space_path.as_deref(), &state) || !path.is_file() { return Ok(None); } - start_security_bookmark_access(&state, path); let path = path.to_path_buf(); let max_dimension = preview_max_dimension(max_dimension); let slots = MEDIA_PREVIEW_SLOTS @@ -1552,7 +1480,6 @@ pub async fn get_media_thumbnail( if !is_image && !is_video { return Err("unsupported media thumbnail kind".to_string()); } - start_security_bookmark_access(&state, &path); let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; let modified_ns = metadata @@ -1901,7 +1828,6 @@ pub fn read_video_preview( eprintln!("[read_video_preview] refused file: {}", file_path); return None; } - start_security_bookmark_access(&state, path); #[cfg(target_os = "macos")] { @@ -1957,7 +1883,6 @@ pub fn write_file( eprintln!("[write_file] refused out-of-scope path: {}", file_path); return false; } - start_security_bookmark_access(&state, Path::new(&file_path)); let memo_file = read_lock(&state.memo_file, "memo_file"); let path = Path::new(&file_path); let notebook_ids = match memo_file.notebook_ids_for_paths(&[path]) { @@ -2002,7 +1927,6 @@ pub fn create_file( if !path.parent().is_some_and(Path::is_dir) { return Err("FILE_NOT_FOUND: parent directory does not exist".to_string()); } - start_security_bookmark_access(&state, path); let memo_file = read_lock(&state.memo_file, "memo_file"); let notebook_ids = memo_file .notebook_ids_for_paths(&[path]) @@ -2031,7 +1955,6 @@ pub fn delete_file(file_path: String, space_path: Option, state: State String { format!("{code}: {error}") } +fn target_directory_error(error: std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + "TARGET_NOT_DIRECTORY".to_string() + } else { + file_mutation_error(error) + } +} + fn validate_file_name(name: &str) -> Result<(), String> { if name.trim().is_empty() || name == "." @@ -2191,18 +2121,13 @@ fn rename_path_and_notify( target: &Path, state: &AppState, app: &tauri::AppHandle, -) -> Result<(), String> { +) -> Result, String> { let source_was_directory = source.is_dir(); - let source_was_markdown = source - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") - }); let mf = read_lock(&state.memo_file, "memo_file"); - let changes = mf - .rename_indexed_path(source, target) + let _watch_move = crate::commands::external_document_watch::begin_path_move(app, source); + mf.rename_indexed_path(source, target) .map_err(file_mutation_error)?; + crate::commands::external_document_watch::rebase_path(app, source, target); if target.is_dir() { let configs = mf.read_notebook_configs().unwrap_or_default(); if let Some(config) = configs.into_iter().find(|config| { @@ -2232,13 +2157,8 @@ fn rename_path_and_notify( crate::commands::document_list::refresh_view_document_path(&mf, source); crate::commands::document_list::refresh_view_document_path(&mf, target); } - let target_is_markdown = target - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") - }); - if source_was_directory || (source_was_markdown && target_is_markdown) { + let mut notebook_change = None; + { crate::commands::document_list::rebase_collection_paths_for_move(&mf, source, target); if let Some((notebook_id, relative_path, previous_path)) = mf .read_notebook_configs() @@ -2258,39 +2178,15 @@ fn rename_path_and_notify( )) }) { - let _ = app.emit( - "flowix:path-note-changed", - serde_json::json!({ - "notebookId": notebook_id, - "relativePath": relative_path, - "previousRelativePath": previous_path, - "kind": "path", - "directory": source_was_directory, - "deleted": false, - }), - ); + if let Some(previous_path) = previous_path { + let change = super::path_change::NotebookPathMove::new( + notebook_id, previous_path, relative_path, source_was_directory, + ); + change.emit(app); + notebook_change = Some(change); + } } } - for (notebook_id, before, memo) in changes { - let Some(config) = mf.get_notebook_config_by_id(¬ebook_id) else { - continue; - }; - let path = notebook_path_from_relative(Path::new(&config.path), &memo.relative_path)?; - crate::memo_events::emit( - app, - crate::memo_events::MemoEvent::Updated { - id: memo.id.clone(), - path: path.to_string_lossy().into_owned(), - notebook_id, - derived_changed: crate::memo_events::MemoDerivedChanged::from_memos( - Some(&before), - &memo, - ), - memo, - source: crate::memo_events::MemoChangeSource::UserEdit, - }, - ); - } let notebooks = mf.read_notebook_configs().unwrap_or_default(); let address_for = |path: &Path| { notebooks.iter().find_map(|notebook| { @@ -2321,7 +2217,7 @@ fn rename_path_and_notify( ); } } - Ok(()) + Ok(notebook_change) } #[tauri::command] @@ -2332,7 +2228,7 @@ pub async fn rename_file( name: String, space_path: String, app: tauri::AppHandle, -) -> Result { +) -> Result { crate::commands::document_operations::run( "rename_external", operation_id, @@ -2348,15 +2244,14 @@ pub async fn rename_file( { return Err("FILE_PERMISSION_DENIED".to_string()); } - start_security_bookmark_access(&state, source); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_file() { return Err("SOURCE_NOT_REGULAR_FILE".to_string()); } - rename_path_and_notify(source, &target, state.inner(), &app)?; - Ok(target.to_string_lossy().into_owned()) + let notebook_change = rename_path_and_notify(source, &target, state.inner(), &app)?; + Ok(super::path_change::PathMutationResult { path: target.to_string_lossy().into_owned(), notebook_change }) }, ) .await @@ -2372,7 +2267,7 @@ pub fn move_file( space_path: String, state: State, app: tauri::AppHandle, -) -> Result { +) -> Result { let source = Path::new(&file_path); let target_directory = Path::new(&target_directory_path); let scope = Path::new(&space_path); @@ -2380,38 +2275,41 @@ pub fn move_file( || !can_access_scoped_file(source, Some(&space_path), &state) || !can_access_scoped_file(target_directory, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, target_directory); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_file() { - return Err("SOURCE_NOT_REGULAR_FILE".to_string()); + return Err(FileOperationFailure::code("SOURCE_NOT_REGULAR_FILE")); } if !fs::symlink_metadata(target_directory) - .map_err(file_mutation_error)? + .map_err(target_directory_error)? .is_dir() { - return Err("TARGET_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); } - let file_name = source.file_name().ok_or("INVALID_FILE_PATH")?; + let file_name = source + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; let target = target_directory.join(file_name); if !can_access_scoped_file(&target, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } if source == target { - return Ok(source.to_string_lossy().into_owned()); + return Ok(super::path_change::PathMutationResult { path: source.to_string_lossy().into_owned(), notebook_change: None }); } if fs::symlink_metadata(&target).is_ok() { - return Err(file_mutation_error(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "target already exists", - ))); + return Err( + file_mutation_error(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target already exists", + )) + .into(), + ); } - rename_path_and_notify(source, &target, state.inner(), &app)?; - Ok(target.to_string_lossy().into_owned()) + let notebook_change = rename_path_and_notify(source, &target, state.inner(), &app)?; + Ok(super::path_change::PathMutationResult { path: target.to_string_lossy().into_owned(), notebook_change }) } /// Move a folder within the caller's notebook/access-folder scope. @@ -2422,7 +2320,7 @@ pub fn move_folder( space_path: String, state: State, app: tauri::AppHandle, -) -> Result { +) -> Result { let source = Path::new(&folder_path); let target_directory = Path::new(&target_directory_path); let scope = Path::new(&space_path); @@ -2433,41 +2331,44 @@ pub fn move_folder( || is_internal_notebook_path(source, &state) || is_internal_notebook_path(target_directory, &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, target_directory); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_dir() { - return Err("SOURCE_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("SOURCE_NOT_DIRECTORY")); } if !fs::symlink_metadata(target_directory) - .map_err(file_mutation_error)? + .map_err(target_directory_error)? .is_dir() { - return Err("TARGET_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); } if path_is_inside(target_directory, source) { - return Err("INVALID_MOVE_TARGET".to_string()); + return Err(FileOperationFailure::code("INVALID_MOVE_TARGET")); } - let folder_name = source.file_name().ok_or("INVALID_FILE_PATH")?; + let folder_name = source + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; let target = target_directory.join(folder_name); if !path_is_inside(&target, scope) || is_internal_notebook_path(&target, &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } if source == target { - return Ok(source.to_string_lossy().into_owned()); + return Ok(super::path_change::PathMutationResult { path: source.to_string_lossy().into_owned(), notebook_change: None }); } if fs::symlink_metadata(&target).is_ok() { - return Err(file_mutation_error(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "target already exists", - ))); + return Err( + file_mutation_error(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target already exists", + )) + .into(), + ); } - rename_path_and_notify(source, &target, state.inner(), &app)?; - Ok(target.to_string_lossy().into_owned()) + let notebook_change = rename_path_and_notify(source, &target, state.inner(), &app)?; + Ok(super::path_change::PathMutationResult { path: target.to_string_lossy().into_owned(), notebook_change }) } /// Copy a supported external resource into the caller's notebook scope. @@ -2480,14 +2381,14 @@ pub async fn import_file( target_directory_path: String, space_path: String, state: State<'_, AppState>, -) -> Result { +) -> Result { let source = Path::new(&file_path); let target_directory = Path::new(&target_directory_path); let scope = Path::new(&space_path); if !is_browsable_scope(scope, &state) || !can_access_scoped_file(target_directory, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } // WebView2 can invoke this command just before Tauri's Drop RunEvent has // recorded the exact external path in DocumentAccess. Wait briefly for @@ -2503,35 +2404,41 @@ pub async fn import_file( || can_access_scoped_file(source, Some(&space_path), &state); } if !source_allowed { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, target_directory); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_file() { - return Err("SOURCE_NOT_REGULAR_FILE".to_string()); + return Err(FileOperationFailure::code("SOURCE_NOT_REGULAR_FILE")); + } + if is_cloud_file_not_downloaded(source) { + return Err(FileOperationFailure::code("CLOUD_FILE_NOT_DOWNLOADED")); } if !fs::symlink_metadata(target_directory) - .map_err(file_mutation_error)? + .map_err(target_directory_error)? .is_dir() { - return Err("TARGET_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); } - let file_name = source.file_name().ok_or("INVALID_FILE_PATH")?; + let file_name = source + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; let target = target_directory.join(file_name); if !can_access_scoped_file(&target, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } if source == target { return Ok(target.to_string_lossy().into_owned()); } if fs::symlink_metadata(&target).is_ok() { - return Err(file_mutation_error(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "target already exists", - ))); + return Err( + file_mutation_error(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target already exists", + )) + .into(), + ); } let source = source.to_path_buf(); @@ -2589,7 +2496,7 @@ pub async fn rename_folder( name: String, space_path: String, app: tauri::AppHandle, -) -> Result { +) -> Result { crate::commands::document_operations::run( "rename_folder", operation_id, @@ -2614,7 +2521,6 @@ pub async fn rename_folder( { return Err("FILE_PERMISSION_DENIED".to_string()); } - start_security_bookmark_access(&state, source); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_dir() @@ -2627,7 +2533,10 @@ pub async fn rename_folder( }; if let Some((_notebook_id, result)) = collection_move { let path = Path::new(&result.notebook_path).join(&result.actual_relative_path); - return Ok(path.parent().ok_or("INVALID_COLLECTION_PATH")?.to_string_lossy().into_owned()); + return Ok(super::path_change::PathMutationResult { + path: path.parent().ok_or("INVALID_COLLECTION_PATH")?.to_string_lossy().into_owned(), + notebook_change: super::collection::path_change_for_result(&_notebook_id, &result), + }); } if fs::symlink_metadata(&target).is_ok() { return Err(file_mutation_error(std::io::Error::new( @@ -2635,8 +2544,8 @@ pub async fn rename_folder( "target already exists", ))); } - rename_path_and_notify(source, &target, state.inner(), &app)?; - Ok(target.to_string_lossy().into_owned()) + let notebook_change = rename_path_and_notify(source, &target, state.inner(), &app)?; + Ok(super::path_change::PathMutationResult { path: target.to_string_lossy().into_owned(), notebook_change }) }, ) .await @@ -2661,7 +2570,6 @@ pub fn create_folder( ); return None; } - start_security_bookmark_access(&state, &target_path); let memo_file = read_lock(&state.memo_file, "memo_file"); let notebook_ids = memo_file.notebook_ids_for_paths(&[&target_path]).ok()?; if notebook_ids.is_empty() { @@ -2715,7 +2623,6 @@ pub fn create_document( ); return Err("FILE_PERMISSION_DENIED".to_string()); } - start_security_bookmark_access(&state, &target_path); let memo_file = read_lock(&state.memo_file, "memo_file"); let notebook_ids = memo_file .notebook_ids_for_paths(&[&target_path]) @@ -2804,9 +2711,8 @@ mod tests { MemoTreeMetadata { created_ms: Some(42), memo: DocTreeMemoMeta { - id: "memo-1".to_string(), icon: Some("flashlight".to_string()), - colors: vec![MemoColor::Blue], + colors: vec![NoteColor::Blue], favorited: true, }, }, @@ -2815,9 +2721,8 @@ mod tests { let items = read_dir_single_level(directory.path(), Some(&metadata)); assert_eq!(items[0].memo_created_ms, Some(42)); let memo = items[0].memo_meta.as_ref().expect("indexed memo metadata"); - assert_eq!(memo.id, "memo-1"); assert_eq!(memo.icon.as_deref(), Some("flashlight")); - assert_eq!(memo.colors, vec![MemoColor::Blue]); + assert_eq!(memo.colors, vec![NoteColor::Blue]); assert!(memo.favorited); } diff --git a/app/flowix-desktop/src/commands/file/folder_import.rs b/app/flowix-desktop/src/commands/file/folder_import.rs new file mode 100644 index 000000000..ca77f15d7 --- /dev/null +++ b/app/flowix-desktop/src/commands/file/folder_import.rs @@ -0,0 +1,198 @@ +use super::*; + +const MAX_IMPORT_FOLDER_FILES: usize = 2_000; +const MAX_IMPORT_FOLDER_ENTRIES: usize = 5_000; +const MAX_IMPORT_FOLDER_DEPTH: usize = 32; +const MAX_IMPORT_FOLDER_BYTES: u64 = 5 * 1024 * 1024 * 1024; + +/// Import a selected directory while preserving its internal folder structure. +/// Limits are checked before creating anything in the destination. +#[tauri::command] +pub async fn import_folder( + window: WebviewWindow, + source_directory_path: String, + target_directory_path: String, + space_path: String, + state: State<'_, AppState>, +) -> Result { + let source_directory = Path::new(&source_directory_path); + let target_directory = Path::new(&target_directory_path); + let scope = Path::new(&space_path); + if !can_access_document_path(source_directory, window.label(), &state) + || !is_browsable_scope(scope, &state) + || !can_access_scoped_file(target_directory, Some(&space_path), &state) + { + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); + } + let source_metadata = fs::symlink_metadata(source_directory).map_err(file_mutation_error)?; + if !source_metadata.is_dir() || source_metadata.file_type().is_symlink() { + return Err(FileOperationFailure::code("SOURCE_NOT_DIRECTORY")); + } + if !fs::symlink_metadata(target_directory) + .map_err(target_directory_error)? + .is_dir() + { + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); + } + let folder_name = source_directory + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; + let target_root = target_directory.join(folder_name); + if !can_access_scoped_file(&target_root, Some(&space_path), &state) { + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); + } + if fs::symlink_metadata(&target_root).is_ok() { + return Err(FileOperationFailure::code("FILE_EXISTS")); + } + + let source = dunce::canonicalize(source_directory).map_err(file_mutation_error)?; + let memo_file = state.memo_file.clone(); + + tokio::task::spawn_blocking(move || { + use walkdir::WalkDir; + + let mut directories = Vec::new(); + let mut files = Vec::new(); + let mut entry_count = 0usize; + let mut total_bytes = 0u64; + let walker = WalkDir::new(&source) + .follow_links(false) + .min_depth(1) + .into_iter() + .filter_entry(|entry| { + entry.depth() == 0 + || !matches!(entry.file_name().to_str(), Some(".flowix" | ".DS_Store")) + }); + for entry in walker { + let entry = entry.map_err(|_| FileOperationFailure::code("IMPORT_FOLDER_SCAN_FAILED"))?; + let relative = entry + .path() + .strip_prefix(&source) + .map_err(|_| FileOperationFailure::code("INVALID_FILE_PATH"))?; + entry_count += 1; + if entry_count > MAX_IMPORT_FOLDER_ENTRIES { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_MANY_ENTRIES")); + } + let relative_depth = relative.components().count(); + let depth = if entry.file_type().is_dir() { + relative_depth + } else { + relative_depth.saturating_sub(1) + }; + if depth > MAX_IMPORT_FOLDER_DEPTH { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_DEEP")); + } + if entry.file_type().is_symlink() { + continue; + } + if entry.file_type().is_dir() { + directories.push(relative.to_path_buf()); + continue; + } + if !entry.file_type().is_file() { + continue; + } + if is_cloud_file_not_downloaded(entry.path()) { + return Err(FileOperationFailure::code("CLOUD_FILE_NOT_DOWNLOADED")); + } + let size = entry + .metadata() + .map_err(|_| FileOperationFailure::code("IMPORT_FOLDER_SCAN_FAILED"))? + .len(); + total_bytes = total_bytes.saturating_add(size); + if total_bytes > MAX_IMPORT_FOLDER_BYTES { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_LARGE")); + } + files.push((entry.path().to_path_buf(), relative.to_path_buf())); + if files.len() > MAX_IMPORT_FOLDER_FILES { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_MANY_FILES")); + } + } + + // Create the full tree only after the source passes all import limits. + fs::create_dir(&target_root).map_err(file_mutation_error)?; + let copy_result = (|| -> Result<(), FileOperationFailure> { + for relative in &directories { + fs::create_dir_all(target_root.join(relative)).map_err(file_mutation_error)?; + } + for (source_file, relative) in &files { + let target = target_root.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(file_mutation_error)?; + } + let store = read_lock(&memo_file, "memo_file"); + let ids = store + .notebook_ids_for_paths(&[&target]) + .map_err(file_mutation_error)?; + let copy = |destination: &Path| -> std::io::Result<()> { + let metadata = fs::symlink_metadata(source_file)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "source is not a regular file", + )); + } + let canonical_source = dunce::canonicalize(source_file)?; + if !canonical_source.starts_with(&source) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "source escaped selected folder", + )); + } + let mut input = fs::File::open(canonical_source)?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + if let Err(error) = std::io::copy(&mut input, &mut output) { + let _ = fs::remove_file(destination); + return Err(error); + } + output.sync_all() + }; + match ids.as_slice() { + [notebook_id] => store.with_file_write( + notebook_id, + &target, + flowix_core::memo_file::FileLockIntent::Create, + "import_folder_file", + copy, + ), + [] => { + let _guard = store + .acquire_cross_process_write_lock() + .map_err(file_mutation_error)?; + copy(&target) + } + _ => store.with_registered_path_change( + &[&target], + "import_folder_nested_notebooks", + || copy(&target), + ), + } + .map_err(file_mutation_error)?; + } + Ok(()) + })(); + if let Err(import_error) = copy_result { + if let Err(cleanup_error) = fs::remove_dir_all(&target_root) { + tracing::warn!( + target = %target_root.display(), + error = %cleanup_error, + "failed to clean up partially imported folder" + ); + return Err(FileOperationFailure { + code: "IMPORT_FOLDER_CLEANUP_FAILED".to_string(), + message: Some(format!( + "Import failed ({}) and partial files could not be removed ({cleanup_error})", + import_error.code + )), + }); + } + return Err(import_error); + } + Ok(target_root.to_string_lossy().into_owned()) + }) + .await + .map_err(|error| FileOperationFailure::code(&format!("FILE_OPERATION_FAILED: {error}")))? +} diff --git a/app/flowix-desktop/src/commands/file_browser_watch.rs b/app/flowix-desktop/src/commands/file_browser_watch.rs index d36cf3f4b..7003eb12d 100644 --- a/app/flowix-desktop/src/commands/file_browser_watch.rs +++ b/app/flowix-desktop/src/commands/file_browser_watch.rs @@ -19,8 +19,7 @@ use tauri::{Emitter, Manager}; use crate::app::state::AppState; use crate::commands::helpers::{ - is_agent_access_folder_with_state, is_registered_notebook_path_with_state, - start_security_bookmark_access, + is_attached_notebook_directory_with_state, is_registered_notebook_path_with_state, }; use crate::config::path_is_inside; @@ -354,6 +353,11 @@ fn emit_directory_changes( return; }; + // A mounted tab can outlive the mount itself; stop publishing revoked roots. + let Some(state) = app.try_state::() else { return; }; + if !is_registered_notebook_path_with_state(&root_path, &state) + && !is_attached_notebook_directory_with_state(&root_path, &state) { return; } + let mut directories = directories.into_iter().collect::>(); directories.sort(); let mut paths = paths.into_iter().collect::>(); @@ -390,10 +394,9 @@ pub fn watch_file_browser_root( root_path: String, ) -> Result { let path = PathBuf::from(&root_path); - start_security_bookmark_access(&app_state, &path); if !path.is_dir() || (!is_registered_notebook_path_with_state(&path, &app_state) - && !is_agent_access_folder_with_state(&path, &app_state)) + && !is_attached_notebook_directory_with_state(&path, &app_state)) { return Err(format!( "file browser root is not accessible: {}", diff --git a/app/flowix-desktop/src/commands/helpers.rs b/app/flowix-desktop/src/commands/helpers.rs index 5fda54d86..2fc770801 100644 --- a/app/flowix-desktop/src/commands/helpers.rs +++ b/app/flowix-desktop/src/commands/helpers.rs @@ -2,19 +2,90 @@ use std::path::Path; +use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use crate::config::{path_is_inside, path_is_inside_reserved_directory}; -use crate::lock_utils::{read_lock, write_lock}; +use crate::lock_utils::read_lock; use crate::watcher::runtime::current_watcher; use crate::app::state::AppState; -use flowix_core::memo_file::{ - notebook_relative_path, MemoFile, NotebookConfig, -}; +use flowix_core::memo_file::{notebook_relative_path, MemoFile, NotebookConfig}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileOperationFailure { + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl FileOperationFailure { + pub fn code(code: &str) -> Self { + Self { + code: code.to_string(), + message: None, + } + } +} + +impl From for FileOperationFailure { + fn from(value: String) -> Self { + let (code, message) = value.split_once(": ").unwrap_or((value.as_str(), "")); + Self { + code: code.to_string(), + message: (!message.is_empty()).then(|| message.to_string()), + } + } +} + +impl From<&str> for FileOperationFailure { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} + +impl From for FileOperationFailure { + fn from(error: flowix_core::FlowixError) -> Self { + use flowix_core::FlowixError; + + match error { + FlowixError::Io(error) => { + let code = match error.kind() { + std::io::ErrorKind::AlreadyExists => "FILE_EXISTS", + std::io::ErrorKind::NotFound => "FILE_NOT_FOUND", + std::io::ErrorKind::PermissionDenied => "FILE_PERMISSION_DENIED", + _ => "FILE_OPERATION_FAILED", + }; + Self { + code: code.to_string(), + message: Some(error.to_string()), + } + } + FlowixError::NotFound(message) => Self { + code: "FILE_NOT_FOUND".to_string(), + message: Some(message), + }, + FlowixError::PermissionDenied(message) => Self { + code: "FILE_PERMISSION_DENIED".to_string(), + message: Some(message), + }, + FlowixError::InvalidInput(message) => Self { + code: "INVALID_MOVE_TARGET".to_string(), + message: Some(message), + }, + error => Self { + code: "FILE_OPERATION_FAILED".to_string(), + message: Some(error.to_string()), + }, + } + } +} pub(crate) fn watch_created_notebook(state: &AppState, app: &AppHandle, config: &NotebookConfig) { - start_security_bookmark_access(state, Path::new(&config.path)); + state + .security_bookmarks + .record_accessible_directory(Path::new(&config.path)); if let Some(watcher) = current_watcher(app) { if let Ok(mut guard) = watcher.write() { if guard.add_notebook_root(config) { @@ -25,10 +96,6 @@ pub(crate) fn watch_created_notebook(state: &AppState, app: &AppHandle, config: refresh_watcher_roots(state, app); } -pub(crate) fn start_security_bookmark_access(state: &AppState, path: &Path) { - state.security_bookmarks.start_accessing_for_path(path); -} - /// Resolve a notebook-relative Markdown address without consulting the legacy /// memo ID table. `None` means the path is outside notebooks or is not Markdown. pub(crate) fn notebook_note_address( @@ -48,7 +115,10 @@ pub(crate) fn notebook_note_address( continue; }; let relative = Path::new(&relative_path); - if memo_file.file_management_policy(¬ebook.id).is_index_ignored_at(&root, relative) { + if memo_file + .file_management_policy(¬ebook.id) + .is_index_ignored_at(&root, relative) + { return Err("document path is inside an ignored notebook directory".into()); } if !relative @@ -88,31 +158,23 @@ pub(crate) fn refresh_notebook_note_index(memo_file: &MemoFile, path: &Path) { } pub(crate) fn refresh_watcher_roots(state: &AppState, app: &AppHandle) { - let configs = { - let memo_file = read_lock(&state.memo_file, "memo_file"); - memo_file.read_notebook_configs().unwrap_or_default() - }; - for config in &configs { - start_security_bookmark_access(state, Path::new(&config.path)); - } - if let Some(watcher) = current_watcher(app) { - if let Ok(mut guard) = watcher.write() { - guard.rebind_all(app.clone(), configs.clone()); + let Some(watcher) = current_watcher(app) else { return; }; + let Ok(mut guard) = watcher.write() else { return; }; + // Snapshot under the same lock as binding so delayed callers cannot + // overwrite a newer bind with metadata read before taking the lock. + let configs = match crate::watcher::runtime::read_registered_notebooks(state.user_config.config_dir()) { + Ok(configs) => configs, + Err(error) => { + tracing::warn!(%error, "watcher roots snapshot unavailable; retaining current bindings"); + return; } - } + }; + guard.rebind_all(app.clone(), configs.clone()); + drop(guard); // A full rebind has a short gap. Reconcile once after binding so changes // made during that gap are included even if no filesystem event arrives. - let memo_file = read_lock(&state.memo_file, "memo_file"); for config in &configs { - match memo_file.reconcile_note_index(&config.id) { - Ok(report) if report.added + report.updated + report.removed > 0 => { - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": config.id, "relativePath": "", "deleted": false, - })); - } - Err(error) => tracing::warn!(notebook_id = %config.id, "watcher rebind reconciliation failed: {error}"), - _ => {} - } + state.notebook_background.request(&config.id, false, true); } } @@ -131,21 +193,12 @@ pub(crate) fn retry_notebook_watch(state: &AppState, app: &AppHandle, notebook_i if watcher.is_watching(notebook_id) { return; } - start_security_bookmark_access(state, Path::new(&config.path)); if !watcher.add_notebook_root(&config) { tracing::warn!(notebook_id, "notebook watch retry failed"); return; } drop(watcher); - match read_lock(&state.memo_file, "memo_file").reconcile_note_index(notebook_id) { - Ok(report) if report.added + report.updated + report.removed > 0 => { - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": notebook_id, "relativePath": "", "deleted": false, - })); - } - Err(error) => tracing::warn!(notebook_id, "notebook watch retry reconciliation failed: {error}"), - _ => {} - } + state.notebook_background.request(notebook_id, true, true); } pub(crate) fn set_notebook_watching_suspended(app: &AppHandle, notebook_id: &str, suspended: bool) { @@ -171,24 +224,21 @@ pub(crate) fn refresh_notebook_watcher(state: &AppState, app: &AppHandle, notebo pub(crate) fn switch_notebook_trusting_index( state: &AppState, - app: &AppHandle, notebook_id: Option, ) -> Result<(), String> { - switch_notebook(state, app, notebook_id) + switch_notebook(state, notebook_id) } -fn switch_notebook( - state: &AppState, - app: &AppHandle, - notebook_id: Option, -) -> Result<(), String> { +fn switch_notebook(state: &AppState, notebook_id: Option) -> Result<(), String> { let prev = read_lock(&state.memo_file, "memo_file").current_notebook_id_value(); if let Some(target_id) = notebook_id.as_deref() { let target_path = read_lock(&state.memo_file, "memo_file") .get_notebook_config_by_id(target_id) .map(|config| std::path::PathBuf::from(config.path)) .ok_or_else(|| format!("notebook {target_id} not found"))?; - start_security_bookmark_access(state, &target_path); + state + .security_bookmarks + .record_accessible_directory(&target_path); if !target_path.is_dir() { return Err(format!( "notebook {target_id} path is missing: {}", @@ -198,20 +248,6 @@ fn switch_notebook( } if prev == notebook_id { - if let Some(notebook_id) = notebook_id.as_deref() { - { - let memo_file = read_lock(&state.memo_file, "memo_file"); - let report = memo_file - .ensure_notebook_migrations(notebook_id) - .map_err(|error| format!("notebook migration failed: {error}"))?; - tracing::debug!( - notebook = %notebook_id, - moved_files = report.moved_files, - rebuilt_tags = report.rebuilt_tags, - "notebook migrations checked" - ); - }; - } return Ok(()); } @@ -224,23 +260,6 @@ fn switch_notebook( }) .set_current_notebook(notebook_id.clone()); - if let Some(notebook_id) = notebook_id.as_deref() { - { - let memo_file = read_lock(&state.memo_file, "memo_file"); - let report = memo_file - .ensure_notebook_migrations(notebook_id) - .map_err(|error| format!("notebook migration failed: {error}"))?; - if report.moved_files > 0 || report.rebuilt_tags > 0 { - tracing::info!( - notebook = %notebook_id, - moved_files = report.moved_files, - rebuilt_tags = report.rebuilt_tags, - "notebook migrations completed" - ); - } - }; - } - Ok(()) } @@ -286,9 +305,14 @@ pub(crate) fn is_registered_notebook_path_with_state(path: &Path, state: &AppSta } pub(crate) fn can_access_document_path(path: &Path, window: &str, state: &State) -> bool { - is_registered_notebook_path(path, state) - || is_agent_access_folder(path, state) - || state.document_access.contains(window, path) + state.security_bookmarks.start_accessing_for_path(path); + let allowed = is_registered_notebook_path(path, state) + || is_attached_notebook_directory(path, state) + || state.document_access.contains(window, path); + if allowed && path.is_dir() { + state.security_bookmarks.record_accessible_directory(path); + } + allowed } pub(crate) fn can_access_scoped_file( @@ -312,7 +336,7 @@ pub(crate) fn can_access_scoped_file_with_state( }; let root = Path::new(space_path); (is_registered_notebook_path_with_state(root, state) - || is_agent_access_folder_with_state(root, state)) + || is_attached_notebook_directory_with_state(root, state)) && path_is_inside(file_path, root) } @@ -333,19 +357,14 @@ pub(crate) fn is_internal_notebook_path_with_state(path: &Path, state: &AppState .any(|config| path_is_inside_reserved_directory(path, Path::new(&config.path), ".flowix")) } -/// 侧栏"资料"文件夹作用域 ── agent access 配置里登记的 folder entry。 -/// `get_file_tree` / `read_file` 等文件树 IPC 用它放行用户添加的资料 -/// 文件夹 (这些目录不在注册笔记本列表里, `is_registered_notebook_path` -/// 对它们返回 false)。 -pub(crate) fn is_agent_access_folder(path: &Path, state: &State) -> bool { - is_agent_access_folder_with_state(path, state.inner()) +/// External file scopes come from enabled notebook mounts in local.db. +pub(crate) fn is_attached_notebook_directory(path: &Path, state: &State) -> bool { + is_attached_notebook_directory_with_state(path, state.inner()) } -pub(crate) fn is_agent_access_folder_with_state(path: &Path, state: &AppState) -> bool { - let config = state.agent_access.get_config(); - config.entries.iter().any(|entry| { - entry.kind == crate::config::AgentAccessKind::Folder - && entry.enabled - && path_is_inside(path, Path::new(&entry.path)) - }) +pub(crate) fn is_attached_notebook_directory_with_state(path: &Path, state: &AppState) -> bool { + let memo = read_lock(&state.memo_file, "memo_file"); + memo.attached_directory_paths() + .map(|roots| roots.iter().any(|root| path_is_inside(path, root))) + .unwrap_or(false) } diff --git a/app/flowix-desktop/src/commands/kv.rs b/app/flowix-desktop/src/commands/kv.rs index ad8792c6f..6a287c6f9 100644 --- a/app/flowix-desktop/src/commands/kv.rs +++ b/app/flowix-desktop/src/commands/kv.rs @@ -4,7 +4,6 @@ //! stored in each notebook's `.flowix/system.json`. The legacy global system //! store is read only to migrate a notebook the first time it is accessed. -use std::collections::HashMap; use std::path::PathBuf; use tauri::State; @@ -12,7 +11,7 @@ use tauri::State; use crate::app::state::AppState; use crate::system_data::{ NotebookCustomViewsData, NotebookFeaturedNotesData, - NotebookFileTreePreferences, NotebookTagSystemData, SystemFile, TagLayoutItem, TagSystemData, + NotebookFileTreePreferences, NotebookTagSystemData, SystemFile, TagLayoutItem, }; const FEATURED_NOTES_ALLOWED_OPERATORS: [&str; 3] = ["equals", "contains", "excludes"]; @@ -43,7 +42,6 @@ pub fn set_tag_system_layout( ) -> Result<(), String> { let mut file = load_notebook_system(¬ebook_id, &state)?; let notebook = file.tag.notebooks.entry(notebook_id.clone()).or_default(); - notebook.order = layout.iter().map(|item| item.id.clone()).collect(); notebook.layout = layout; persist_notebook_system(¬ebook_id, &state, &file) } @@ -88,15 +86,9 @@ fn load_notebook_system(notebook_id: &str, state: &State) -> Result, + window: tauri::WebviewWindow, app: AppHandle, ) -> Result { + let origin_window_label = window.label().to_string(); let title = title.filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%d").to_string()); crate::document_io::run("create_path_document", move || { @@ -83,13 +92,21 @@ pub async fn add_path_document( let created = NoteService::new(&memo_file) .create(¬ebook_id, effective_parent.as_deref(), &title, &body)?; mark_self_write_for(&app, &created.path); - Ok::<_, MemoCreateError>(CreatedPathDocumentResult { + let notebook_change = super::super::path_change::NotebookPathCreated::from_window( + created.notebook.id.clone(), + created.entry.relative_path.clone(), + origin_window_label, + ); + let result = CreatedPathDocumentResult { notebook_id: created.notebook.id, relative_path: created.entry.relative_path.clone(), path: created.path.to_string_lossy().to_string(), initial_content: created.body, entry: created.entry, - }) + notebook_change, + }; + result.publish_created(&app); + Ok::<_, MemoCreateError>(result) }).await.map_err(|message| MemoCreateError { code: "INTERNAL_ERROR", message })? } @@ -232,6 +249,7 @@ pub fn create_path_from_template( template_id: String, notebook_id: String, state: State, + window: tauri::WebviewWindow, app: AppHandle, ) -> Result { let template_name = Path::new(&template_id).file_name().and_then(|name| name.to_str()) @@ -250,13 +268,21 @@ pub fn create_path_from_template( .create(¬ebook_id, parent.as_deref(), &title, body) .map_err(|e| e.to_string())?; mark_self_write_for(&app, &created.path); - Ok(CreatedPathDocumentResult { + let notebook_change = super::super::path_change::NotebookPathCreated::from_window( + created.notebook.id.clone(), + created.entry.relative_path.clone(), + window.label().to_string(), + ); + let result = CreatedPathDocumentResult { notebook_id: created.notebook.id, relative_path: created.entry.relative_path.clone(), path: created.path.to_string_lossy().to_string(), initial_content: created.body, entry: created.entry, - }) + notebook_change, + }; + result.publish_created(&app); + Ok(result) } #[tauri::command] @@ -266,6 +292,7 @@ pub fn import_external_document_by_path( content: String, notebook_id: String, state: State, + window: tauri::WebviewWindow, app: AppHandle, ) -> Result { let title = Path::new(&file_path).file_stem().and_then(|stem| stem.to_str()) @@ -277,13 +304,21 @@ pub fn import_external_document_by_path( .create(¬ebook_id, parent.as_deref(), title, &content) .map_err(|error| error.to_string())?; mark_self_write_for(&app, &created.path); - Ok(CreatedPathDocumentResult { + let notebook_change = super::super::path_change::NotebookPathCreated::from_window( + created.notebook.id.clone(), + created.entry.relative_path.clone(), + window.label().to_string(), + ); + let result = CreatedPathDocumentResult { notebook_id: created.notebook.id, relative_path: created.entry.relative_path.clone(), path: created.path.to_string_lossy().to_string(), initial_content: created.body, entry: created.entry, - }) + notebook_change, + }; + result.publish_created(&app); + Ok(result) } #[derive(serde::Deserialize, serde::Serialize, Clone)] @@ -315,7 +350,7 @@ const MAX_NOTEBOOK_TEMPLATE_FILES: usize = 2_000; const MAX_NOTEBOOK_TEMPLATE_BYTES: usize = 10 * 1024 * 1024; const NOTEBOOK_TEMPLATE_INIT_MARKER: &str = ".flowix/notebook-template-initializing.json"; -fn validate_notebook_template(template: &NotebookTemplate) -> Result<(), String> { +pub(crate) fn validate_notebook_template(template: &NotebookTemplate) -> Result<(), String> { if template.id.trim().is_empty() || template.id.len() > 128 || template.id.chars().any(char::is_control) @@ -334,6 +369,8 @@ fn validate_notebook_template(template: &NotebookTemplate) -> Result<(), String> } fn read_notebook_template_index() -> Result, String> { + let home = dirs::home_dir().ok_or_else(|| "template directory not available".to_string())?; + template_store::initialize_notebook_templates(&home.join(USER_CONFIG_DIR_NAME))?; let directory = notebook_template_dir().ok_or_else(|| "template directory not available".to_string())?; let path = directory.join("index.json"); @@ -354,10 +391,10 @@ fn read_notebook_template_index() -> Result, String> { } #[tauri::command] -pub fn list_notebook_templates() -> Result, String> { - let home = dirs::home_dir().ok_or_else(|| "template directory not available".to_string())?; - template_store::initialize_notebook_templates(&home.join(USER_CONFIG_DIR_NAME))?; - read_notebook_template_index() +pub async fn list_notebook_templates() -> Result, String> { + tauri::async_runtime::spawn_blocking(read_notebook_template_index) + .await + .map_err(|error| format!("notebook template list task failed: {error}"))? } pub(crate) fn validate_notebook_template_id(template_id: &str) -> Result<(), String> { @@ -374,86 +411,42 @@ pub(crate) fn validate_notebook_template_id(template_id: &str) -> Result<(), Str } } -/// Attach a deferred template to an already registered notebook. -/// This is used by retry-safe onboarding when its chosen default path was -/// registered before the current onboarding attempt. -#[tauri::command] -pub fn ensure_notebook_template_setup( - notebook_id: String, - template_id: String, - overwrite_existing: Option, - state: State, -) -> Result { - validate_notebook_template_id(&template_id)?; - let running = state - .notebook_template_initializations - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let memo_file = read_lock(&state.memo_file, "memo_file"); - let notebook = memo_file - .get_notebook_config_by_id(¬ebook_id) - .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; - let existing = memo_file - .get_notebook_setup_job(¬ebook_id) - .map_err(|error| format!("read notebook setup status failed: {error}"))?; - if let Some(existing) = &existing { - if existing.template_id.as_deref() != Some(template_id.as_str()) { - return Err("NOTEBOOK_TEMPLATE_SELECTION_CONFLICT".to_string()); - } - } +pub(crate) fn resolve_notebook_template(template_id: &str) -> Result { + let templates = read_notebook_template_index()?; + find_notebook_template(&templates, template_id) + .cloned() + .ok_or_else(|| "UNKNOWN_NOTEBOOK_TEMPLATE".to_string()) +} - let notebook_root = PathBuf::from(¬ebook.path); - if !notebook_root.is_dir() { - return Err("PATH_MISSING".to_string()); - } - let overwrite_existing = overwrite_existing.unwrap_or(false); - if existing.is_none() - && !overwrite_existing - && super::super::notebook::notebook_folder_has_content(¬ebook_root)? - { - return Err("NOTEBOOK_PRESET_OVERWRITE_CONFIRM_REQUIRED".to_string()); - } - if overwrite_existing - && existing.as_ref().is_some_and(|job| !job.overwrite_existing) - && running.contains(¬ebook_id) - { - return Err("NOTEBOOK_TEMPLATE_SETUP_IN_PROGRESS".to_string()); - } - MemoFile::ensure_notebook_scene_id(¬ebook, &template_id) - .map_err(|error| format!("NOTEBOOK_SCENE_ID_WRITE_FAILED: {error}"))?; - - if let Some(mut existing) = existing { - if overwrite_existing && !existing.overwrite_existing { - existing.overwrite_existing = true; - existing.status = NotebookSetupJobStatus::Pending; - existing.stage = "template".to_string(); - existing.completed_files = 0; - existing.total_files = 0; - existing.message = None; - existing.report = None; - existing.updated_at = chrono::Utc::now().timestamp_millis(); - memo_file - .write_notebook_setup_job(&existing) - .map_err(|error| format!("persist notebook setup status failed: {error}"))?; - } - return Ok(existing); +fn find_notebook_template<'a>( + templates: &'a [NotebookTemplate], + template_id: &str, +) -> Option<&'a NotebookTemplate> { + templates + .iter() + .find(|template| template.id == template_id) + .or_else(|| { + // Preserve the legacy scene-to-preset mapping for durable jobs and markers. + template_id + .strip_prefix("scene-") + .and_then(|number| number.parse::().ok()) + .and_then(|number| number.checked_sub(1)) + .and_then(|index| templates.get(index)) + }) +} + +pub(crate) fn notebook_template_ids_match(left: &str, right: &str) -> Result { + if left == right { + return Ok(true); } - let job = NotebookSetupJob { - notebook_id, - template_id: Some(template_id), - overwrite_existing, - status: NotebookSetupJobStatus::Pending, - stage: "template".to_string(), - completed_files: 0, - total_files: 0, - message: None, - report: None, - updated_at: chrono::Utc::now().timestamp_millis(), - }; - memo_file - .write_notebook_setup_job(&job) - .map_err(|error| format!("persist notebook setup status failed: {error}"))?; - Ok(job) + let templates = read_notebook_template_index()?; + Ok(match ( + find_notebook_template(&templates, left), + find_notebook_template(&templates, right), + ) { + (Some(left), Some(right)) => left.id == right.id, + _ => false, + }) } fn load_notebook_template_files( @@ -497,9 +490,8 @@ fn load_notebook_template_files_with_failures( return Err("INVALID_NOTEBOOK_TEMPLATE_DIRECTORY".to_string()); } - let mut files = Vec::new(); let mut failures = Vec::new(); - let mut total_bytes = 0usize; + let mut sources = Vec::new(); let mut scanned_files = 0usize; for entry in walkdir::WalkDir::new(&source).follow_links(false) { let entry = match entry { @@ -544,7 +536,13 @@ fn load_notebook_template_files_with_failures( if scanned_files > MAX_NOTEBOOK_TEMPLATE_FILES { return Err("NOTEBOOK_TEMPLATE_TOO_LARGE".to_string()); } - match fs::read(entry.path()) { + sources.push((path, entry.path().to_path_buf())); + } + + let mut files = Vec::with_capacity(sources.len()); + let mut total_bytes = 0usize; + for (path, source_path) in sources { + match fs::read(source_path) { Ok(content) => { total_bytes = total_bytes .checked_add(path.len()) @@ -557,7 +555,6 @@ fn load_notebook_template_files_with_failures( } Err(error) => failures.push((path, error.to_string())), } - } files.sort_by(|left, right| left.path.cmp(&right.path)); @@ -723,15 +720,15 @@ mod notebook_template_content_tests { validate_notebook_template_path, NotebookTemplateIndex, }; use flowix_core::memo_file::{ - atomic_write_bytes, extract_frontmatter_key, is_ignored_notebook_relative_path, MemoFile, + atomic_write_bytes, is_ignored_notebook_relative_path, MemoFile, NotebookConfig, }; - use flowix_core::MemoService; + use flowix_core::NoteService; use std::fs; use std::path::Path; #[test] - fn scenario_template_creates_all_indexed_notes_and_replaces_source_keys() { + fn scenario_template_indexes_markdown_notes_by_path() { let directory = tempfile::tempdir().expect("temporary template test directory"); let config_dir = directory.path().join("config"); crate::template_store::initialize_notebook_templates(&config_dir) @@ -802,16 +799,10 @@ mod notebook_template_content_tests { .unwrap_or_else(|error| panic!("decode {}: {error}", file.path)); let parent_relative = parent_relative.map(|parent| parent.to_string_lossy().replace('\\', "/")); - let created = MemoService::new(&memo_file) - .create_memo_named_with_tag_in_directory( - Some("nb_template_test"), - parent_relative.as_deref(), - title, - content, - None, - ) + let created = NoteService::new(&memo_file) + .create("nb_template_test", parent_relative.as_deref(), title, content) .unwrap_or_else(|error| panic!("create {}: {error}", file.path)); - created_notes.push((created.memo.id, created.path)); + created_notes.push(created.path); } else { atomic_write_bytes(&parent.join(filename), &file.content) .unwrap_or_else(|error| panic!("copy {}: {error}", file.path)); @@ -819,9 +810,9 @@ mod notebook_template_content_tests { } assert_eq!(created_notes.len(), expected_note_count); - for (_id, path) in created_notes { + for path in created_notes { let content = fs::read_to_string(path).expect("read created template note"); - assert_eq!(extract_frontmatter_key(&content), None); + assert!(!content.contains("flowix_key:")); } } } @@ -853,13 +844,58 @@ fn read_notebook_template_init_marker(path: &Path) -> Result, Str Ok(Some(marker.template_id)) } +/// Remove a template initialization marker left behind if the process exited +/// after persisting the indexing stage but before normal marker cleanup. +pub(crate) fn clear_notebook_template_init_marker( + notebook_id: &str, + template_id: &str, + state: &AppState, +) -> Result<(), String> { + let notebook_root = read_lock(&state.memo_file, "memo_file") + .get_notebook_config_by_id(notebook_id) + .map(|config| PathBuf::from(config.path)) + .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; + let notebook_root = fs::canonicalize(¬ebook_root) + .map_err(|error| format!("resolve notebook directory failed: {error}"))?; + let internal_dir = notebook_root.join(".flowix"); + let internal_metadata = match fs::symlink_metadata(&internal_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("inspect notebook metadata directory failed: {error}")), + }; + if !internal_metadata.is_dir() && !internal_metadata.file_type().is_symlink() { + return Err("TEMPLATE_PATH_OUTSIDE_NOTEBOOK".to_string()); + } + let canonical_internal_dir = fs::canonicalize(&internal_dir) + .map_err(|error| format!("resolve notebook metadata directory failed: {error}"))?; + if !canonical_internal_dir.starts_with(¬ebook_root) + || !fs::metadata(&canonical_internal_dir) + .map_err(|error| format!("inspect notebook metadata directory failed: {error}"))? + .is_dir() + { + return Err("TEMPLATE_PATH_OUTSIDE_NOTEBOOK".to_string()); + } + + let marker_path = canonical_internal_dir.join("notebook-template-initializing.json"); + match read_notebook_template_init_marker(&marker_path)? { + None => Ok(()), + Some(existing) => { + if !notebook_template_ids_match(&existing, template_id)? { + return Err("NOTEBOOK_TEMPLATE_INITIALIZATION_IN_PROGRESS".to_string()); + } + fs::remove_file(&marker_path) + .map_err(|error| format!("remove template initialization marker failed: {error}")) + } + } +} + fn begin_notebook_template_initialization( notebook_root: &Path, template_id: &str, ) -> Result { let marker_path = notebook_root.join(NOTEBOOK_TEMPLATE_INIT_MARKER); if let Some(existing_template_id) = read_notebook_template_init_marker(&marker_path)? { - if existing_template_id == template_id { + if notebook_template_ids_match(&existing_template_id, template_id)? { return Ok(marker_path); } return Err("NOTEBOOK_TEMPLATE_INITIALIZATION_IN_PROGRESS".to_string()); @@ -898,7 +934,7 @@ fn begin_notebook_template_initialization( Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { let existing_template_id = read_notebook_template_init_marker(&marker_path)? .ok_or_else(|| "INVALID_NOTEBOOK_TEMPLATE_MARKER".to_string())?; - if existing_template_id != template_id { + if !notebook_template_ids_match(&existing_template_id, template_id)? { return Err("NOTEBOOK_TEMPLATE_INITIALIZATION_IN_PROGRESS".to_string()); } } @@ -930,14 +966,12 @@ pub async fn initialize_notebook_template( false, state.inner(), &app, + None, |_| Ok(()), )?; let index_report = { let memo_file = read_lock(&state.memo_file, "memo_file"); - memo_file - .ensure_notebook_migrations(¬ebook_id) - .map_err(|error| format!("notebook import migration failed: {error}"))?; memo_file .reconcile_note_index(¬ebook_id) .map_err(|error| format!("notebook path reconciliation failed: {error}"))? @@ -955,6 +989,19 @@ pub async fn initialize_notebook_template( .map_err(|error| format!("notebook template initialization task failed: {error}"))? } +struct NotebookTemplateWatchSuspension<'a> { + state: &'a AppState, + app: &'a AppHandle, + notebook_id: &'a str, +} + +impl Drop for NotebookTemplateWatchSuspension<'_> { + fn drop(&mut self) { + crate::commands::helpers::set_notebook_watching_suspended(self.app, self.notebook_id, false); + crate::commands::helpers::refresh_notebook_watcher(self.state, self.app, self.notebook_id); + } +} + pub(crate) fn initialize_notebook_template_for_notebook( notebook_id: &str, template_id: &str, @@ -962,10 +1009,17 @@ pub(crate) fn initialize_notebook_template_for_notebook( overwrite_existing: bool, state: &AppState, app: &AppHandle, + setup_job: Option<&NotebookSetupJob>, after_template_files_written: impl FnOnce(&NotebookSetupReport) -> Result<(), String>, ) -> Result { let watched_notebook_id = notebook_id.to_string(); let started = std::time::Instant::now(); + // Drop restores the watch even when template loading or writing panics. + let watching_suspension = NotebookTemplateWatchSuspension { + state, + app, + notebook_id, + }; // A retried template may already be watched; unbind it before any writes. crate::commands::helpers::set_notebook_watching_suspended(app, notebook_id, true); crate::commands::helpers::refresh_notebook_watcher(state, app, notebook_id); @@ -978,12 +1032,12 @@ pub(crate) fn initialize_notebook_template_for_notebook( _is_new_notebook, state, app, + setup_job, after_template_files_written, ); let write_ms = write_started.elapsed().as_millis(); let resume_started = std::time::Instant::now(); - crate::commands::helpers::set_notebook_watching_suspended(app, notebook_id, false); - crate::commands::helpers::refresh_notebook_watcher(state, app, notebook_id); + drop(watching_suspension); crate::runtime_log::record_event( "info", "notebook.template.timing", format!("notebook={} suspend_ms={} write_ms={} resume_ms={} total_ms={} success={} created={}", @@ -1001,28 +1055,23 @@ fn initialize_notebook_template_inner( _is_new_notebook: bool, state: &AppState, app: &AppHandle, + setup_job: Option<&NotebookSetupJob>, after_template_files_written: impl FnOnce(&NotebookSetupReport) -> Result<(), String>, ) -> Result { if notebook_id.trim().is_empty() { return Err("INVALID_NOTEBOOK_TEMPLATE".to_string()); } - let templates = read_notebook_template_index()?; - let template = templates - .iter() - .find(|template| template.id == template_id) - .or_else(|| { - // Resume setup jobs written by earlier versions before the scene - // IDs were renamed to preset IDs. - template_id - .strip_prefix("scene-") - .and_then(|number| number.parse::().ok()) - .filter(|number| (1..=templates.len()).contains(number)) - .and_then(|number| templates.get(number - 1)) - }) - .cloned() - .ok_or_else(|| "UNKNOWN_NOTEBOOK_TEMPLATE".to_string())?; + let template = resolve_notebook_template(&template_id)?; let template_id = template.id.clone(); + if let Some(setup_job) = setup_job { + let mut preparing = setup_job.clone(); + preparing.stage = NotebookSetupStage::Preparing; + preparing.message = None; + preparing.report = None; + preparing.updated_at = chrono::Utc::now().timestamp_millis(); + super::super::notebook_setup::persist_notebook_setup_job(state, app, preparing)?; + } let (files, source_failures) = load_notebook_template_files(&template)?; let notebook_root = read_lock(&state.memo_file, "memo_file") @@ -1044,99 +1093,109 @@ fn initialize_notebook_template_inner( for (path, reason) in source_failures { record_notebook_template_failure(&mut report, &path, reason); } + if let Some(setup_job) = setup_job { + let mut writing_status = setup_job.clone(); + writing_status.stage = NotebookSetupStage::Writing; + writing_status.message = None; + writing_status.report = Some(report.clone()); + writing_status.updated_at = chrono::Utc::now().timestamp_millis(); + super::super::notebook_setup::persist_notebook_setup_job(state, app, writing_status)?; + } for file in files { - let relative = match validate_notebook_template_path(&file.path) { - Ok(relative) => relative, - Err(error) => { - record_notebook_template_failure(&mut report, &file.path, error); - continue; - } - }; - if !seen_paths.insert(relative.clone()) { - record_notebook_template_failure( - &mut report, - &file.path, - "DUPLICATE_TEMPLATE_PATH", - ); - continue; - } - - let parent_relative = relative - .parent() - .filter(|parent| !parent.as_os_str().is_empty()); - let parent = match ensure_notebook_template_parent(¬ebook_root, parent_relative) { - Ok(parent) => parent, - Err(error) => { - record_notebook_template_failure(&mut report, &file.path, error); - continue; - } - }; - - let Some(filename) = relative.file_name() else { - record_notebook_template_failure(&mut report, &file.path, "INVALID_TEMPLATE_PATH"); - continue; - }; - let target = parent.join(filename); - match fs::symlink_metadata(&target) { - Ok(metadata) if metadata.file_type().is_file() && !overwrite_existing => { - report.skipped_existing_files += 1; - continue; - } - Ok(metadata) if metadata.file_type().is_file() => {} - Ok(_) => { + 'process_file: { + let relative = match validate_notebook_template_path(&file.path) { + Ok(relative) => relative, + Err(error) => { + record_notebook_template_failure(&mut report, &file.path, error); + break 'process_file; + } + }; + if !seen_paths.insert(relative.clone()) { record_notebook_template_failure( &mut report, &file.path, - "template destination exists but is not a regular file", + "DUPLICATE_TEMPLATE_PATH", ); - continue; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - record_notebook_template_failure(&mut report, &file.path, error); - continue; + break 'process_file; } - } - let is_markdown = relative - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("md")); - if is_markdown && !file_management_policy.is_ignored_at(¬ebook_root, &relative) { - let content = match std::str::from_utf8(&file.content) { - Ok(content) => content, + let parent_relative = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()); + let parent = match ensure_notebook_template_parent(¬ebook_root, parent_relative) { + Ok(parent) => parent, Err(error) => { + record_notebook_template_failure(&mut report, &file.path, error); + break 'process_file; + } + }; + + let Some(filename) = relative.file_name() else { + record_notebook_template_failure(&mut report, &file.path, "INVALID_TEMPLATE_PATH"); + break 'process_file; + }; + let target = parent.join(filename); + match fs::symlink_metadata(&target) { + Ok(metadata) if metadata.file_type().is_file() && !overwrite_existing => { + report.skipped_existing_files += 1; + break 'process_file; + } + Ok(metadata) if metadata.file_type().is_file() => {} + Ok(_) => { record_notebook_template_failure( &mut report, &file.path, - format!("invalid markdown template encoding: {error}"), + "template destination exists but is not a regular file", ); - continue; + break 'process_file; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + record_notebook_template_failure(&mut report, &file.path, error); + break 'process_file; } - }; - if let Err(error) = extract_document_metadata(content) { - record_notebook_template_failure(&mut report, &file.path, error); - continue; } - } - // Use an exclusive atomic create for every template file. In - // particular, a user-created file racing this background task must - // never be replaced by atomic_write_bytes. - let write_result = if overwrite_existing { - atomic_write_bytes(&target, &file.content) - } else { - atomic_create_bytes(&target, &file.content) - }; - match write_result { - Ok(()) => { - report.written_files += 1; - mark_self_write_for(app, &target); + let is_markdown = relative + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")); + if is_markdown && !file_management_policy.is_ignored_at(¬ebook_root, &relative) { + let content = match std::str::from_utf8(&file.content) { + Ok(content) => content, + Err(error) => { + record_notebook_template_failure( + &mut report, + &file.path, + format!("invalid markdown template encoding: {error}"), + ); + break 'process_file; + } + }; + if let Err(error) = extract_document_metadata(content) { + record_notebook_template_failure(&mut report, &file.path, error); + break 'process_file; + } } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - report.skipped_existing_files += 1; + + // Use an exclusive atomic create for every template file. In + // particular, a user-created file racing this background task must + // never be replaced by atomic_write_bytes. + let write_result = if overwrite_existing { + atomic_write_bytes(&target, &file.content) + } else { + atomic_create_bytes(&target, &file.content) + }; + match write_result { + Ok(()) => { + report.written_files += 1; + mark_self_write_for(app, &target); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + report.skipped_existing_files += 1; + } + Err(error) => record_notebook_template_failure(&mut report, &file.path, error), } - Err(error) => record_notebook_template_failure(&mut report, &file.path, error), } } @@ -1170,6 +1229,7 @@ fn record_notebook_template_failure( #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct RenameMemoTitleResult { + pub notebook_change: super::super::path_change::NotebookPathMove, pub memo: Option, pub path: String, pub filename: String, @@ -1178,11 +1238,11 @@ pub struct RenameMemoTitleResult { #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct MoveMemoResult { + pub notebook_change: super::super::path_change::NotebookPathMove, pub path: String, } -/// Move a notebook note by relative path. Updating the legacy memo projection -/// is optional and happens only when a compatibility row already exists. +/// Move a notebook note by relative path. #[tauri::command] pub fn move_memo_to_directory( file_path: String, @@ -1190,12 +1250,13 @@ pub fn move_memo_to_directory( parent_relative_path: String, state: State, app: AppHandle, -) -> Result { +) -> Result { let requested_path = Path::new(&file_path); if !requested_path.is_absolute() { return Err("absolute document path required".into()); } - let (moved, before, compatibility_memo, old_relative, old_path) = { + let _watch_move = crate::commands::external_document_watch::begin_path_move(&app, requested_path); + let (moved, old_relative, old_path) = { let memo_file = read_lock(&state.memo_file, "memo_file"); let (source_notebook_id, relative_path) = notebook_note_address(&memo_file, requested_path)?.ok_or_else(|| { @@ -1204,29 +1265,19 @@ pub fn move_memo_to_directory( if source_notebook_id != notebook_id { return Err("note does not belong to the selected notebook".into()); } - let before = memo_file - .find_memo_by_relative_path_for_notebook_id(&source_notebook_id, &relative_path); let mut service = NoteService::new(&memo_file); let original = service .get(&source_notebook_id, &relative_path) - .map_err(|error| error.to_string())?; + .map_err(FileOperationFailure::from)?; mark_self_write_for(&app, &original.path); let moved = service .move_to(&source_notebook_id, &relative_path, &parent_relative_path) - .map_err(|error| error.to_string())?; + .map_err(FileOperationFailure::from)?; drop(service); mark_self_write_for(&app, &moved.path); - let compatibility_memo = before.as_ref().and_then(|_| { - memo_file - .rename_memo_file_for_notebook_id( - &source_notebook_id, - &original.path, - &moved.path, - ) - .ok() - }); - (moved, before, compatibility_memo, relative_path, original.path) + (moved, relative_path, original.path) }; + crate::commands::external_document_watch::rebase_path(&app, requested_path, &moved.path); { let memo_file = read_lock(&state.memo_file, "memo_file"); crate::commands::document_list::rebase_collection_paths( @@ -1247,31 +1298,12 @@ pub fn move_memo_to_directory( "note was moved but agent note references could not be rebased: {error}" ); } - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": notebook_id.clone(), - "relativePath": moved.entry.relative_path.clone(), - "deleted": false, - })); - if let Some(memo) = compatibility_memo.as_ref() { - let derived_changed = MemoDerivedChanged::from_memos(before.as_ref(), memo); - emit_updated_memo_event( - &app, - &memo.id, - moved.path.to_string_lossy().into_owned(), - memo.clone(), - moved.notebook.id.clone(), - derived_changed, - MemoChangeSource::UserEdit, - None, - ); - } else if before.is_some() { - tracing::warn!( - notebook_id = %moved.notebook.id, - relative_path = %moved.entry.relative_path, - "path-based note move succeeded; legacy memo projection could not be updated" - ); - } + let notebook_change = super::super::path_change::NotebookPathMove::new( + notebook_id.clone(), old_relative, moved.entry.relative_path.clone(), false, + ); + notebook_change.emit(&app); Ok(MoveMemoResult { + notebook_change, path: moved.path.to_string_lossy().into_owned(), }) } @@ -1326,6 +1358,7 @@ fn rename_memo_title_blocking( if !requested_path.is_absolute() { return Err("absolute document path required".into()); } + let _watch_move = crate::commands::external_document_watch::begin_path_move(&app, requested_path); let (renamed, old_relative, old_path) = { let memo_file = read_lock(&state.memo_file, "memo_file"); let (notebook_id, relative_path) = notebook_note_address(&memo_file, requested_path)? @@ -1350,6 +1383,7 @@ fn rename_memo_title_blocking( mark_self_write_for(&app, &renamed.path); (renamed, relative_path, original.path) }; + crate::commands::external_document_watch::rebase_path(&app, requested_path, &renamed.path); { let memo_file = read_lock(&state.memo_file, "memo_file"); crate::commands::document_list::rebase_collection_paths( @@ -1371,11 +1405,12 @@ fn rename_memo_title_blocking( ); } let path = renamed.path.to_string_lossy().into_owned(); - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": renamed.notebook.id, - "relativePath": renamed.entry.relative_path, - })); + let notebook_change = super::super::path_change::NotebookPathMove::new( + renamed.notebook.id.clone(), old_relative, renamed.entry.relative_path.clone(), false, + ); + notebook_change.emit(&app); Ok(RenameMemoTitleResult { + notebook_change, memo: None, path, filename: renamed diff --git a/app/flowix-desktop/src/commands/memo/deletes.rs b/app/flowix-desktop/src/commands/memo/deletes.rs index 72f96170f..13f31e9f7 100644 --- a/app/flowix-desktop/src/commands/memo/deletes.rs +++ b/app/flowix-desktop/src/commands/memo/deletes.rs @@ -5,12 +5,10 @@ use std::path::Path; use tauri::{AppHandle, State}; use crate::lock_utils::read_lock; -use crate::memo_events::{self, MemoChangeSource, MemoDerivedChanged, MemoEvent}; use crate::app::state::AppState; use crate::watcher::runtime::mark_self_write_for; -use flowix_core::memo_file::notebook_path_from_relative; -use flowix_core::{MemoService, NoteService}; +use flowix_core::NoteService; #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -26,6 +24,14 @@ pub enum PruneMissingMemoOutcome { MissingCleaned, } +fn emit_deleted_path(app: &AppHandle, notebook_id: &str, relative_path: &str) { + crate::commands::path_change::NotebookPathDeleted::new( + notebook_id.to_string(), + relative_path.to_string(), + ) + .emit(app); +} + fn delete_note_path_internal( file_path: &Path, state: &AppState, @@ -38,7 +44,6 @@ fn delete_note_path_internal( let memo_file = read_lock(&state.memo_file, "memo_file"); let (notebook_id, relative_path) = super::helpers::notebook_note_address(&memo_file, file_path)? .ok_or_else(|| "note path is outside a notebook or is not Markdown".to_string())?; - let before = memo_file.find_memo_by_relative_path_for_notebook_id(¬ebook_id, &relative_path); let artifact_path = match crate::artifact::path_for_note( ¬ebook_id, &relative_path, @@ -78,34 +83,12 @@ fn delete_note_path_internal( &relative_path, ); - let Some(before) = before else { - return Ok(Some(outcome)); - }; - - // The Markdown deletion is authoritative. Clean up optional ID-keyed - // history and projections afterward without making them a prerequisite. - let legacy_cleanup = read_lock(&state.memo_file, "memo_file") - .prune_deleted_memo_for_notebook_id(¬ebook_id, &relative_path, &before.id) - .map_err(|error| error.to_string())?; - if !legacy_cleanup { - return Ok(None); - } if let Some(artifact_path) = artifact_path { if let Err(error) = crate::artifact::remove_path(&artifact_path) { tracing::warn!(path = %artifact_path.display(), "plugin artifact cleanup failed: {error}"); } } - memo_events::emit( - app, - MemoEvent::Deleted { - id: before.id.clone(), - path: absolute_path.display().to_string(), - relative_path: relative_path.clone(), - notebook_id, - derived_changed: MemoDerivedChanged::from_deleted(&before), - source: MemoChangeSource::UserDelete, - }, - ); + emit_deleted_path(app, ¬ebook_id, &relative_path); Ok(Some(outcome)) } diff --git a/app/flowix-desktop/src/commands/memo/helpers.rs b/app/flowix-desktop/src/commands/memo/helpers.rs index 8d10681a7..191378efc 100644 --- a/app/flowix-desktop/src/commands/memo/helpers.rs +++ b/app/flowix-desktop/src/commands/memo/helpers.rs @@ -4,12 +4,8 @@ // `pub(super)` so the sibling sections (`reads`, `creates`, `versions`, // `deletes`) can call them directly without leaking them outside `memo`. -use tauri::AppHandle; - -use crate::document_mutation::DocumentCommit; use crate::lock_utils::read_lock; -use crate::memo_events::{self, MemoChangeSource, MemoDerivedChanged, MemoEvent}; -use flowix_core::memo_file::{extract_body_content, is_system_frontmatter_key, Memo}; +use flowix_core::memo_file::extract_body_content; use crate::app::state::AppState; pub(super) use crate::commands::helpers::notebook_note_address; @@ -20,31 +16,6 @@ pub(super) fn current_notebook_id(state: &AppState) -> String { .unwrap_or_else(|| "nb_default".to_string()) } -pub(super) fn emit_updated_memo_event( - app: &AppHandle, - id: &str, - path: String, - memo: Memo, - notebook_id: String, - derived_changed: MemoDerivedChanged, - source: MemoChangeSource, - origin_window_label: Option<&str>, -) -> Option { - crate::document_derived::schedule(app, id); - memo_events::emit_with_commit_from_window( - app, - MemoEvent::Updated { - id: id.to_string(), - path, - notebook_id, - memo, - derived_changed, - source, - }, - origin_window_label, - ) -} - /// Lightweight CAS fallback normalization. /// /// The fast path stays byte-for-byte equality. This is only used after that @@ -119,20 +90,7 @@ pub(super) fn cas_content_matches(current: &str, expected: &str, incoming: &str) return true; } - let metadata = |content: &str| { - flowix_core::memo_file::extract_document_metadata(content) - .ok() - .map(|mut metadata| { - if let Some(properties) = metadata.properties.as_object_mut() { - for key in properties.keys().cloned().collect::>() { - if is_system_frontmatter_key(&key) { - properties.remove(&key); - } - } - } - metadata - }) - }; + let metadata = |content: &str| flowix_core::memo_file::extract_document_metadata(content).ok(); matches!((metadata(current), metadata(expected)), (Some(current), Some(expected)) if current == expected) && code_content_for_cas(current) == code_content_for_cas(expected) && normalize_markdown_for_cas(current) == normalize_markdown_for_cas(expected) @@ -182,8 +140,8 @@ mod tests { #[test] fn cas_accepts_markdown_serialization_noise() { let current = "---\nflowix_key: abc123\nkey: legacy-current\n---\r\n\r\n# Title\r\n \r\nBody \r\n"; - let expected = "---\nflowix_key: oldkey\nkey: legacy-expected\n---\n\n# Title\n\nBody\n"; - let incoming = "---\nflowix_key: abc123\n---\n\n# Title\n \nBody\n"; + let expected = "---\nflowix_key: abc123\nkey: legacy-current\n---\n\n# Title\n\nBody\n"; + let incoming = "---\nflowix_key: abc123\nkey: legacy-current\n---\n\n# Title\n \nBody\n"; assert!(cas_content_matches(current, expected, incoming)); } diff --git a/app/flowix-desktop/src/commands/memo/mod.rs b/app/flowix-desktop/src/commands/memo/mod.rs index c4f036bd5..fe85cde89 100644 --- a/app/flowix-desktop/src/commands/memo/mod.rs +++ b/app/flowix-desktop/src/commands/memo/mod.rs @@ -1,8 +1,7 @@ //! Memo and document IPC commands. //! -//! Note file CRUD addresses Markdown by notebook-relative path. Internal IDs -//! remain temporarily in compatibility projections, version history, and cloud -//! association while their callers migrate independently. +//! Note file CRUD and identity use notebook-relative paths. The `memo` module +//! name is retained for the existing IPC command namespace and UI terminology. //! //! File layout (split from the original 1414-line `commands/memo.rs` because //! the file carried five distinct sub-domains): diff --git a/app/flowix-desktop/src/commands/memo/reads.rs b/app/flowix-desktop/src/commands/memo/reads.rs index 4667ef8f1..296fc52ee 100644 --- a/app/flowix-desktop/src/commands/memo/reads.rs +++ b/app/flowix-desktop/src/commands/memo/reads.rs @@ -12,10 +12,9 @@ use flowix_core::memo_file::{ NoteEntry, }; use flowix_core::service::NoteSaveOutcome; -use flowix_core::{FlowixError, MemoPage, MemoService, NoteService}; +use flowix_core::{FlowixError, NoteService}; use crate::app::state::AppState; -use crate::commands::helpers::start_security_bookmark_access; use crate::watcher::runtime::mark_self_write_for; use super::helpers::*; @@ -57,7 +56,7 @@ pub fn note_path_status(file_path: String, state: State) -> Result Result { tauri::async_runtime::spawn_blocking(move || { let state = app.state::(); + let memo_file = read_lock(&state.memo_file, "memo_file"); + let id = notebook_id.or_else(|| memo_file.current_notebook_id_value()).unwrap_or_default(); let (used_tag_ids, tag_counts, total_memo_count, agent_memo_count, todo_memo_count) = - MemoService::new(&read_lock(&state.memo_file, "memo_file")) - .tag_usage_summary(notebook_id.as_deref()) - .unwrap_or_default(); - UsedMemoTagIdsResponse { + memo_file.read_tag_usage_summary_for_notebook_id(Some(&id)) + .map_err(|error| crate::app::notebook_background::query_error(state.inner(), &id, error))?; + Ok(UsedMemoTagIdsResponse { used_tag_ids, tag_counts: tag_counts .into_iter() @@ -194,10 +193,10 @@ pub async fn get_used_memo_tag_ids( total_memo_count, agent_memo_count, todo_memo_count, - } + }) }) .await - .map_err(|error| format!("tag summary task failed: {error}")) + .map_err(|error| format!("tag summary task failed: {error}"))? } #[tauri::command] @@ -217,7 +216,6 @@ pub async fn read_document( return None; } let requested_path = Path::new(&file_path); - start_security_bookmark_access(&state, requested_path); let memo_file = read_lock(&state.memo_file, "memo_file"); match notebook_note_address(&memo_file, requested_path) { Ok(Some((notebook_id, relative_path))) => { @@ -257,7 +255,6 @@ pub async fn get_document_modified_at( if !crate::commands::helpers::can_access_document_path(path, window.label(), &state) { return None; } - start_security_bookmark_access(&state, path); fs::metadata(path).ok()?.modified().ok()? .duration_since(std::time::UNIX_EPOCH).ok() .map(|duration| duration.as_millis() as u64) @@ -317,7 +314,6 @@ fn write_document_internal( if !Path::new(file_path).is_absolute() { return Err("absolute document path required".into()); } - start_security_bookmark_access(state.inner(), Path::new(file_path)); let requested_path = Path::new(file_path); let (notebook_id, relative_path) = notebook_note_address(&read_lock(&state.memo_file, "memo_file"), requested_path)? diff --git a/app/flowix-desktop/src/commands/memo/versions.rs b/app/flowix-desktop/src/commands/memo/versions.rs index fece75a8a..dc6db2a9f 100644 --- a/app/flowix-desktop/src/commands/memo/versions.rs +++ b/app/flowix-desktop/src/commands/memo/versions.rs @@ -10,7 +10,6 @@ use crate::lock_utils::read_lock; use flowix_core::memo_file::{atomic_write_bytes, FileLockIntent, IsMd, MemoVersionSource, PathVersionMeta}; use crate::app::state::AppState; -use crate::commands::helpers::start_security_bookmark_access; use crate::watcher::runtime::mark_self_write_for; @@ -35,7 +34,6 @@ pub fn list_path_versions(notebook_id: String, relative_path: String, state: Sta #[tauri::command] pub fn create_path_version(notebook_id: String, relative_path: String, source: Option, state: State) -> Option { let path = path_archive_document(&state, ¬ebook_id, &relative_path)?; - start_security_bookmark_access(&state, &path); let memo_file = read_lock(&state.memo_file, "memo_file"); memo_file.with_file_write(¬ebook_id, &path, FileLockIntent::Existing, "create_path_version", |locked_path| { @@ -49,7 +47,6 @@ pub fn create_path_version(notebook_id: String, relative_path: String, source: O pub fn restore_path_version(notebook_id: String, relative_path: String, version_id: String, expected_content: Option, state: State, app: AppHandle) -> Option { let path = path_archive_document(&state, ¬ebook_id, &relative_path)?; - start_security_bookmark_access(&state, &path); let memo_file = read_lock(&state.memo_file, "memo_file"); let target = memo_file.read_path_version(¬ebook_id, &relative_path, &version_id)?; let restored = memo_file.with_file_write(¬ebook_id, &path, FileLockIntent::Existing, diff --git a/app/flowix-desktop/src/commands/mod.rs b/app/flowix-desktop/src/commands/mod.rs index 063709cfd..e382a2f80 100644 --- a/app/flowix-desktop/src/commands/mod.rs +++ b/app/flowix-desktop/src/commands/mod.rs @@ -28,11 +28,12 @@ // 子模块一�?`pub` —`tauri::generate_handler![commands::::xxx]` �?// `lib.rs::run()` 里走完整�?��, 需�?`pub` �??性。`#[tauri::command]` �?// 生成�?`__cmd__xxx` 兄弟宏也要求子模块是 `pub`, 否则宏解析不到�? pub mod agent; -pub mod agent_access; +pub mod agent_workspace; pub mod artifact; pub mod boot; pub mod cli; pub mod cloud; +pub mod path_change; pub mod dialog; pub mod dsh; pub mod export; @@ -48,10 +49,12 @@ pub mod kv; pub mod media; pub mod memo; pub mod notebook; +pub mod notebook_setup; pub mod plugin; pub mod product; pub mod recovery; -pub mod document_shutdown; +pub mod window_shutdown; +pub mod window_input; pub mod document_operations; pub mod settings; pub mod tag; diff --git a/app/flowix-desktop/src/commands/notebook.rs b/app/flowix-desktop/src/commands/notebook.rs index ca2b22710..36f9a8c55 100644 --- a/app/flowix-desktop/src/commands/notebook.rs +++ b/app/flowix-desktop/src/commands/notebook.rs @@ -1,35 +1,29 @@ -//! Notebook IPC —增删改查 + 切换当前 notebook�?//! -//! `set_current_notebook` �?`switch_notebook_and_rebuild` helper, 触发 -//! watcher rebind + 磁盘对账 + 后台索引 rebuild�?//! -//! �?/ �?/ �?/ 清空 四个写操作都会同步更�?`agent_access` store -//! (`~/.flowix/agent-access.json`), 任何 entry 真改了之�?emit -//! `agent-access-changed` 事件, 其它窗口 React 树收到后从�?盘重�?load�? +//! Notebook lifecycle and device-local registration in db/local.db. use crate::events as dispatcher; use serde::Serialize; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::lock_utils::{read_lock, write_lock}; use flowix_core::memo_file::{ MemoFile, Notebook, NotebookConfig, NotebookSetupJob, NotebookSetupJobStatus, + NotebookSetupStage, }; -use flowix_core::MemoService; use flowix_sync::V2LocalNotebook; -use super::agent_access::AGENT_ACCESS_CHANGED_EVENT; +use super::agent_workspace::AGENT_WORKSPACE_CHANGED_EVENT; use super::helpers::{ refresh_watcher_roots, switch_notebook_trusting_index, watch_created_notebook, }; use crate::app::state::{AppState, NotebookImportStatus, NotebookImportStatusKind}; const NOTEBOOK_IMPORT_COMPLETE_EVENT: &str = "notebook-import-complete"; -const NOTEBOOK_SETUP_STATUS_EVENT: &str = "notebook-setup-status"; /// 笔�?�?��表发生变�?(reorder / create / update / delete) �?emit, 其它窗口 /// store 监听�?reload。前�?TS 类型 `notebooks-changed` 事件 payload �?unit�? pub(crate) const NOTEBOOKS_CHANGED_EVENT: &str = "notebooks-changed"; const NOTEBOOK_IMPORT_STATUS_EVENT: &str = "notebook-import-status"; -fn emit_notebook_import_status( +pub(crate) fn emit_notebook_import_status( state: &AppState, app: &AppHandle, notebook_id: &str, @@ -122,8 +116,12 @@ fn notebook_from_config(config: NotebookConfig) -> Notebook { #[serde(rename_all = "camelCase")] pub struct NotebookListItem { #[serde(flatten)] - notebook: Notebook, - memo_count: usize, + notebook: NotebookConfig, + pinned: bool, + #[serde(skip_serializing_if = "Option::is_none")] + memo_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + missing: Option, } fn create_notebook_registry( @@ -256,7 +254,9 @@ fn create_notebook_registry_with_id_and_template( // 创建顺序: 1) 先�?现有 configs 验证�?��不冲�? 2) �?next sort; // 3) 组�? NotebookConfig 写盘。sort �?MAX(sort)+10 让新行落到末�? // (ORDER BY sort ASC), 不取 len �?���?reorder �?sort �?��疏的�? - let mut configs = memo_file.read_notebook_configs().unwrap_or_default(); + let mut configs = memo_file + .read_notebook_configs() + .map_err(|error| format!("INDEX_READ_FAILED: {error}"))?; if configs .iter() .any(|notebook| comparable_notebook_path(¬ebook.path) == comparable_path) @@ -287,15 +287,17 @@ fn create_notebook_registry_with_id_and_template( let relocated = existing.clone(); let setup_job = template_id.map(|template_id| NotebookSetupJob { notebook_id: relocated.id.clone(), + job_id: uuid::Uuid::now_v7().to_string(), template_id: Some(template_id.to_string()), overwrite_existing, status: NotebookSetupJobStatus::Pending, - stage: "template".to_string(), - completed_files: 0, - total_files: 0, + stage: NotebookSetupStage::Preparing, + revision: 0, + run_id: String::new(), message: None, report: None, updated_at: now, + execution_requested: false, }); memo_file .write_notebook_configs_with_setup_job(&configs, setup_job.as_ref()) @@ -327,15 +329,17 @@ fn create_notebook_registry_with_id_and_template( configs.push(config.clone()); let setup_job = template_id.map(|template_id| NotebookSetupJob { notebook_id: config.id.clone(), + job_id: uuid::Uuid::now_v7().to_string(), template_id: Some(template_id.to_string()), overwrite_existing, status: NotebookSetupJobStatus::Pending, - stage: "template".to_string(), - completed_files: 0, - total_files: 0, + stage: NotebookSetupStage::Preparing, + revision: 0, + run_id: String::new(), message: None, report: None, updated_at: now, + execution_requested: false, }); memo_file .write_notebook_configs_with_setup_job(&configs, setup_job.as_ref()) @@ -358,11 +362,8 @@ fn cloud_restore_directory_is_empty(path: &Path) -> Result { Ok(true) } -fn sync_notebook_agent_access(config: &NotebookConfig, state: &AppState, app: &AppHandle) { - // 同�?往 agent_access 列表里加一�?(默�? enabled), 写盘后才�? // 同�?完成 ── store 内部走原子写, 失败会回滚内存�? - if state.agent_access.add_or_update_notebook(config) { - dispatcher::emit_to(app, AGENT_ACCESS_CHANGED_EVENT, ()); - } +fn notify_notebook_workspace_changed(app: &AppHandle) { + dispatcher::emit_to(app, AGENT_WORKSPACE_CHANGED_EVENT, ()); } fn set_current_notebook_inner( @@ -382,8 +383,8 @@ fn set_current_notebook_inner( // Fast path for ordinary switching: trust memo index and avoid synchronous // disk reconciliation. Search index rebuild is lazy, triggered by search. - if let Err(error) = switch_notebook_trusting_index(state, app, notebook_id.clone()) { - if let Err(restore_error) = switch_notebook_trusting_index(state, app, previous_id) { + if let Err(error) = switch_notebook_trusting_index(state, notebook_id.clone()) { + if let Err(restore_error) = switch_notebook_trusting_index(state, previous_id) { tracing::error!( "[set_current_notebook] failed to restore previous notebook after switch error: {restore_error}" ); @@ -397,7 +398,7 @@ fn set_current_notebook_inner( { // Keep the process-local and persisted selection aligned if the second // half of the switch fails. The original error remains authoritative. - if let Err(restore_error) = switch_notebook_trusting_index(state, app, previous_id) { + if let Err(restore_error) = switch_notebook_trusting_index(state, previous_id) { tracing::error!( "[set_current_notebook] failed to restore previous notebook after persistence error: {restore_error}" ); @@ -406,6 +407,7 @@ fn set_current_notebook_inner( } if let Some(notebook_id) = notebook_id.as_deref() { + state.notebook_background.prioritize(notebook_id); crate::commands::helpers::retry_notebook_watch(state, app, notebook_id); } Ok(()) @@ -424,7 +426,7 @@ fn activate_created_notebook( Ok(()) } -fn run_notebook_import(app: AppHandle, notebook_id: String, seed_onboarding_docs: bool) -> bool { +pub(crate) fn run_notebook_import(app: AppHandle, notebook_id: String, seed_onboarding_docs: bool) -> bool { let started = std::time::Instant::now(); tracing::info!( "[create_notebook] background import start id={}", @@ -437,9 +439,15 @@ fn run_notebook_import(app: AppHandle, notebook_id: String, seed_onboarding_docs let mut onboarding_docs_seeded = false; { let memo_file = read_lock(&app_state.memo_file, "memo_file"); - memo_file - .ensure_notebook_migrations(¬ebook_id) - .map_err(|error| format!("notebook import migration failed: {error}"))?; + match memo_file.has_legacy_notebook_internal_data(¬ebook_id) { + Ok(true) => { + if let Err(error) = memo_file.ensure_notebook_structure_migration(¬ebook_id) { + tracing::warn!(notebook = %notebook_id, %error, "legacy plugin artifact migration deferred"); + } + } + Ok(false) => {} + Err(error) => tracing::warn!(notebook = %notebook_id, %error, "legacy plugin artifact check deferred"), + } tracing::info!( "[create_notebook] import/reconcile start id={}", notebook_id @@ -516,201 +524,75 @@ fn spawn_notebook_import(app: AppHandle, notebook_id: String) { std::thread::spawn(move || run_notebook_import(app, notebook_id, true)); } -fn persist_notebook_setup_job( - state: &AppState, - app: &AppHandle, - job: NotebookSetupJob, -) -> Result { - read_lock(&state.memo_file, "memo_file") - .write_notebook_setup_job(&job) - .map_err(|error| format!("persist notebook setup status failed: {error}"))?; - dispatcher::emit_to(app, NOTEBOOK_SETUP_STATUS_EVENT, job.clone()); - Ok(job) -} - -fn finish_notebook_template_setup(app: AppHandle, mut job: NotebookSetupJob) { - let state = app.state::(); - let mut report = job.report.clone().unwrap_or_default(); - let result = (|| { - if job.stage != "indexing" { - let notebook_id = job.notebook_id.clone(); - let template_id = job - .template_id - .clone() - .ok_or_else(|| "NOTEBOOK_TEMPLATE_NOT_SELECTED".to_string())?; - let mut next_stage_job = job.clone(); - super::memo::creates::initialize_notebook_template_for_notebook( - ¬ebook_id, - &template_id, - true, - job.overwrite_existing, - state.inner(), - &app, - |report| { - next_stage_job.stage = "indexing".to_string(); - next_stage_job.completed_files = report.total_files; - next_stage_job.total_files = report.total_files; - next_stage_job.message = None; - next_stage_job.report = Some(report.clone()); - next_stage_job.updated_at = chrono::Utc::now().timestamp_millis(); - persist_notebook_setup_job(state.inner(), &app, next_stage_job.clone()) - .map(|_| ()) - }, - )?; - report = next_stage_job.report.clone().unwrap_or_default(); - job = next_stage_job; - } - - emit_notebook_import_status( - state.inner(), - &app, - &job.notebook_id, - NotebookImportStatusKind::Started, - None, - ); - if !run_notebook_import(app.clone(), job.notebook_id.clone(), false) { - let message = state - .notebook_imports - .lock() - .ok() - .and_then(|imports| imports.get(&job.notebook_id).and_then(|status| status.message.clone())) - .unwrap_or_else(|| "NOTEBOOK_IMPORT_FAILED".to_string()); - return Err(message); - } - Ok(()) - })(); - - job.updated_at = chrono::Utc::now().timestamp_millis(); - match result { - Ok(()) => { - job.report = Some(report.clone()); - if report.failed_files > 0 { - job.status = NotebookSetupJobStatus::Partial; - job.stage = "template".to_string(); - } else { - job.status = NotebookSetupJobStatus::Completed; - job.stage = "completed".to_string(); - } - job.message = None; - } - Err(message) => { - job.status = NotebookSetupJobStatus::Failed; - if report.failed_files > 0 || job.stage != "indexing" { - job.stage = "template".to_string(); +#[tauri::command] +pub async fn get_notebooks(app: AppHandle) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let memo_file = read_lock(&state.memo_file, "memo_file"); + let configs = memo_file.read_notebook_configs().map_err(|error| error.to_string())?; + let pinned_ids = memo_file.read_notebook_pin_states().map_err(|error| error.to_string())?; + // Metadata-only read: no path probes, count queries or filesystem scans. + Ok::<_, String>(configs.into_iter().map(|config| { + let cached = state.notebook_background.snapshot(&config.id); + NotebookListItem { + pinned: pinned_ids.contains(&config.id), + memo_count: cached.as_ref().and_then(|status| status.memo_count), + missing: cached.as_ref().and_then(|status| status.missing), + notebook: config, } - job.report = Some(report); - job.message = Some(message); - } - } - if let Err(error) = persist_notebook_setup_job(state.inner(), &app, job.clone()) { - tracing::error!("failed to persist notebook setup terminal state: {error}"); - } - if let Ok(mut running) = state.notebook_template_initializations.lock() { - running.remove(&job.notebook_id); - }; + }).collect::>()) + }) + .await + .map_err(|error| format!("notebook list task failed: {error}"))? } #[tauri::command] -pub fn get_notebook_template_setup_status( +pub fn set_notebook_pinned( notebook_id: String, + pinned: bool, state: State, -) -> Result, String> { - read_lock(&state.memo_file, "memo_file") - .get_notebook_setup_job(¬ebook_id) - .map_err(|error| format!("read notebook setup status failed: {error}")) -} - -/// Start or resume template preparation for an opened notebook. Failed and -/// partial jobs only restart when the user explicitly requests a retry. -#[tauri::command] -pub fn start_notebook_template_setup( - notebook_id: String, - retry: Option, app: AppHandle, -) -> Result, String> { - let state = app.state::(); - let mut running = state - .notebook_template_initializations - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let memo_file = read_lock(&state.memo_file, "memo_file"); - let Some(mut job) = memo_file - .get_notebook_setup_job(¬ebook_id) - .map_err(|error| format!("read notebook setup status failed: {error}"))? - else { - return Ok(None); - }; +) -> Result { + let memo_file = write_lock(&state.memo_file, "memo_file"); + let changed = memo_file + .set_notebook_pinned(¬ebook_id, pinned) + .map_err(|error| format!("INDEX_WRITE_FAILED: {error}"))?; drop(memo_file); - - if job.status == NotebookSetupJobStatus::Completed { - return Ok(Some(job)); - } - if matches!(job.status, NotebookSetupJobStatus::Partial | NotebookSetupJobStatus::Failed) - && retry != Some(true) - { - return Ok(Some(job)); - } - if job.status == NotebookSetupJobStatus::Partial && retry == Some(true) { - job.status = NotebookSetupJobStatus::Pending; - job.stage = "template".to_string(); - job.message = None; - job.report = None; - } else if job.status == NotebookSetupJobStatus::Failed && retry == Some(true) { - job.status = NotebookSetupJobStatus::Pending; - job.message = None; - if job.stage != "indexing" { - job.stage = "template".to_string(); - job.report = None; - } + if changed { + dispatcher::emit_to(&app, NOTEBOOKS_CHANGED_EVENT, ()); } + Ok(changed) +} - if !running.insert(notebook_id.clone()) { - return Ok(Some(job)); - } +#[tauri::command] +pub fn get_notebook_background_status(state: State) -> Vec { + state.notebook_background.statuses() +} - job.status = NotebookSetupJobStatus::Running; - job.message = None; - job.updated_at = chrono::Utc::now().timestamp_millis(); - let job = match persist_notebook_setup_job(state.inner(), &app, job) { - Ok(job) => job, - Err(error) => { - running.remove(¬ebook_id); - return Err(error); - } - }; - drop(running); +#[tauri::command] +pub fn get_notebook_media_maintenance_status( + state: State, +) -> Vec { + state.notebook_background.media_statuses() +} - std::thread::spawn({ - let app = app.clone(); - let job = job.clone(); - move || finish_notebook_template_setup(app, job) - }); - Ok(Some(job)) +#[tauri::command] +pub fn request_notebook_media_maintenance( + notebook_id: String, + app: AppHandle, + state: State, +) -> Result<(), String> { + let status = state + .notebook_background + .request_media(¬ebook_id) + .ok_or_else(|| "媒体维护队列已停止".to_string())?; + let _ = app.emit(crate::app::notebook_background::MEDIA_EVENT, status); + Ok(()) } #[tauri::command] -pub async fn get_notebooks(app: AppHandle) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let state = app.state::(); - let memo_file = read_lock(&state.memo_file, "memo_file"); - let configs = memo_file.read_notebook_configs().unwrap_or_default(); - let counts = MemoService::new(&memo_file) - .notebook_note_counts(&configs) - .unwrap_or_default(); - - configs - .into_iter() - .map(|config| { - let memo_count = counts.get(&config.id).copied().unwrap_or(0); - NotebookListItem { - notebook: notebook_from_config(config), - memo_count, - } - }) - .collect::>() - }) - .await - .map_err(|error| format!("notebook list task failed: {error}")) +pub fn request_notebook_refresh(notebook_id: String, state: State) { + state.notebook_background.request(¬ebook_id, true, true); } #[tauri::command] @@ -747,9 +629,6 @@ pub async fn create_notebook( .ok_or_else(|| "PATH_INVALID_UTF8".to_string())? } }; - let has_bookmark_access = state - .security_bookmarks - .start_accessing_for_path(Path::new(trimmed_path)); if !Path::new(trimmed_path).is_dir() { return Err("PATH_MISSING".to_string()); } @@ -772,13 +651,8 @@ pub async fn create_notebook( { return Err("NOTEBOOK_PRESET_OVERWRITE_CONFIRM_REQUIRED".to_string()); } - if !has_bookmark_access { - state - .security_bookmarks - .record_directory(Path::new(trimmed_path)) - .map_err(|e| format!("BOOKMARK_WRITE_FAILED: {e}"))?; - } - + // Also handles dependency deletion while the window stayed open. + state.user_config.ensure_runtime_storage()?; let config = { let memo_file = write_lock(&state.memo_file, "memo_file"); create_notebook_registry_with_id_and_template( @@ -791,7 +665,7 @@ pub async fn create_notebook( &memo_file, )? }; - sync_notebook_agent_access(&config, state.inner(), &app); + notify_notebook_workspace_changed(&app); if activate.unwrap_or(true) { activate_created_notebook(&config, state.inner(), &app)?; } else { @@ -805,6 +679,9 @@ pub async fn create_notebook( }) .await .map_err(|error| format!("notebook creation task failed: {error}"))?; + if let Err(error) = &result { + crate::runtime_log::record_event("error", "notebook.create.failed", error); + } crate::runtime_log::record_event( "info", "notebook.create.timing", @@ -879,6 +756,7 @@ pub fn create_notebook_from_cloud( name: String, path: String, icon: Option, + activate: Option, state: State, app: AppHandle, ) -> Result { @@ -895,26 +773,22 @@ pub fn create_notebook_from_cloud( } let directory = Path::new(trimmed_path); - let has_bookmark_access = state.security_bookmarks.start_accessing_for_path(directory); if !directory.is_dir() { return Err("PATH_MISSING".to_string()); } if !cloud_restore_directory_is_empty(directory)? { return Err("PATH_NOT_EMPTY".to_string()); } - if !has_bookmark_access { - state - .security_bookmarks - .record_directory(directory) - .map_err(|e| format!("BOOKMARK_WRITE_FAILED: {e}"))?; - } - let config = { let memo_file = write_lock(&state.memo_file, "memo_file"); create_notebook_registry_with_id(trimmed_name, trimmed_path, icon, Some(&id), &memo_file)? }; - sync_notebook_agent_access(&config, state.inner(), &app); - activate_created_notebook(&config, state.inner(), &app)?; + notify_notebook_workspace_changed(&app); + if activate.unwrap_or(true) { + activate_created_notebook(&config, state.inner(), &app)?; + } else { + watch_created_notebook(state.inner(), &app, &config); + } dispatcher::emit_to(&app, NOTEBOOKS_CHANGED_EVENT, ()); Ok(notebook_from_config(config)) @@ -946,10 +820,7 @@ pub fn update_notebook( let updated = configs[index].clone(); drop(memo_file); - // 名字 / �?��变更都同步到 agent_access ── store �?��判定�?��真改�? - if state.agent_access.add_or_update_notebook(&updated) { - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - } + notify_notebook_workspace_changed(&app); refresh_watcher_roots(state.inner(), &app); record_notebook_metadata_change(state.inner(), &app, &updated); @@ -1064,10 +935,7 @@ pub fn delete_notebook( crate::commands::cloud::schedule_notebook_sync(app.clone(), id.clone()); } - // 同�?把�?应的 agent_access entry 也删�? 状态栏�?文件权限"子菜�? // 会少一�?── 用户没主动去勾�? 不应该留�??儿在那里�? - if state.agent_access.remove_notebook(&id) { - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - } + notify_notebook_workspace_changed(&app); refresh_watcher_roots(state.inner(), &app); dispatcher::emit_to(&app, NOTEBOOKS_CHANGED_EVENT, ()); Ok(true) @@ -1170,20 +1038,14 @@ pub fn clear_notebooks(state: State, app: AppHandle) -> Result Result { + let persisted = read_lock(&state.memo_file, "memo_file") + .write_notebook_setup_job(&job) + .map_err(|error| format!("persist notebook setup status failed: {error}"))?; + emit_notebook_setup_job(app, persisted.clone()); + Ok(persisted) +} + +pub(crate) fn emit_notebook_setup_job(app: &AppHandle, job: NotebookSetupJob) { + dispatcher::emit_to(app, NOTEBOOK_SETUP_STATUS_EVENT, job); +} + +fn persist_notebook_setup_run_start( + state: &AppState, + app: &AppHandle, + job: &NotebookSetupJob, + expected_run_id: &str, +) -> Result { + let persisted = read_lock(&state.memo_file, "memo_file") + .start_notebook_setup_job(job, expected_run_id) + .map_err(|error| format!("persist notebook setup run start failed: {error}"))?; + emit_notebook_setup_job(app, persisted.clone()); + Ok(persisted) +} + +fn release_notebook_template_setup(state: &AppState, notebook_id: &str) { + state + .notebook_template_initializations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(notebook_id); +} + +fn read_current_notebook_setup_run( + state: &AppState, + notebook_id: &str, + job_id: &str, + run_id: &str, +) -> Option { + let mut delay = std::time::Duration::from_millis(250); + loop { + match read_lock(&state.memo_file, "memo_file").get_notebook_setup_job(notebook_id) { + Ok(Some(job)) if job.job_id == job_id && job.run_id == run_id => return Some(job), + Ok(_) => return None, + Err(error) => { + tracing::error!( + notebook_id = %notebook_id, + error = %error, + "retrying notebook setup checkpoint read" + ); + std::thread::sleep(delay); + delay = (delay * 2).min(std::time::Duration::from_secs(30)); + } + } + } +} + +fn persist_notebook_setup_terminal_until_committed( + state: &AppState, + app: &AppHandle, + job: NotebookSetupJob, +) { + let mut delay = std::time::Duration::from_millis(250); + loop { + match persist_notebook_setup_job(state, app, job.clone()) { + Ok(_) => return, + Err(error) => { + if error.contains("NOTEBOOK_SETUP_JOB_SUPERSEDED") + || error.contains("NOTEBOOK_SETUP_RUN_SUPERSEDED") + || error.contains("NOTEBOOK_SETUP_JOB_MISSING") + { + tracing::warn!( + notebook_id = %job.notebook_id, + job_id = %job.job_id, + "discarding terminal result for a superseded notebook setup job" + ); + return; + } + + let current = read_lock(&state.memo_file, "memo_file") + .get_notebook_setup_job(&job.notebook_id); + match current { + Ok(Some(current)) + if current.job_id != job.job_id || current.run_id != job.run_id => + { + tracing::warn!( + notebook_id = %job.notebook_id, + job_id = %job.job_id, + "discarding terminal result after setup identity changed" + ); + return; + } + Ok(None) => return, + _ => {} + } + + tracing::error!( + notebook_id = %job.notebook_id, + error = %error, + "retrying notebook setup terminal-state persistence" + ); + std::thread::sleep(delay); + delay = (delay * 2).min(std::time::Duration::from_secs(30)); + } + } + } +} + +fn fail_notebook_template_setup_after_panic(app: AppHandle, mut job: NotebookSetupJob) { + let state = app.state::(); + let Some(latest) = read_current_notebook_setup_run( + state.inner(), + &job.notebook_id, + &job.job_id, + &job.run_id, + ) else { + release_notebook_template_setup(state.inner(), &job.notebook_id); + return; + }; + if matches!( + latest.status, + NotebookSetupJobStatus::Completed + | NotebookSetupJobStatus::Partial + | NotebookSetupJobStatus::Failed + ) { + release_notebook_template_setup(state.inner(), &job.notebook_id); + return; + } + + job.stage = latest.stage; + job.report = latest.report; + job.status = NotebookSetupJobStatus::Failed; + job.message = Some("NOTEBOOK_TEMPLATE_WORKER_PANICKED".to_string()); + job.updated_at = chrono::Utc::now().timestamp_millis(); + persist_notebook_setup_terminal_until_committed(state.inner(), &app, job); + release_notebook_template_setup(state.inner(), &latest.notebook_id); +} + +fn finish_notebook_template_setup(app: AppHandle, mut job: NotebookSetupJob) { + let state = app.state::(); + let mut report = job.report.clone().unwrap_or_default(); + let result = (|| { + let template_id = job + .template_id + .as_deref() + .ok_or_else(|| "NOTEBOOK_TEMPLATE_NOT_SELECTED".to_string())?; + if template_id.starts_with("scene-") { + let template = super::memo::creates::resolve_notebook_template(template_id)?; + if template.id != template_id { + job.template_id = Some(template.id); + job.updated_at = chrono::Utc::now().timestamp_millis(); + job = persist_notebook_setup_job(state.inner(), &app, job.clone())?; + } + } + if job.stage != NotebookSetupStage::Indexing { + let notebook_id = job.notebook_id.clone(); + let template_id = job + .template_id + .clone() + .ok_or_else(|| "NOTEBOOK_TEMPLATE_NOT_SELECTED".to_string())?; + let mut next_stage_job = job.clone(); + super::memo::creates::initialize_notebook_template_for_notebook( + ¬ebook_id, + &template_id, + true, + job.overwrite_existing, + state.inner(), + &app, + Some(&job), + |report| { + next_stage_job.stage = NotebookSetupStage::Indexing; + next_stage_job.message = None; + next_stage_job.report = Some(report.clone()); + next_stage_job.updated_at = chrono::Utc::now().timestamp_millis(); + next_stage_job = persist_notebook_setup_job( + state.inner(), + &app, + next_stage_job.clone(), + )?; + Ok(()) + }, + )?; + report = next_stage_job.report.clone().unwrap_or_default(); + job = next_stage_job; + } + + if job.stage == NotebookSetupStage::Indexing { + let template_id = job + .template_id + .as_deref() + .ok_or_else(|| "NOTEBOOK_TEMPLATE_NOT_SELECTED".to_string())?; + super::memo::creates::clear_notebook_template_init_marker( + &job.notebook_id, + template_id, + state.inner(), + )?; + } + + emit_notebook_import_status( + state.inner(), + &app, + &job.notebook_id, + NotebookImportStatusKind::Started, + None, + ); + if !run_notebook_import(app.clone(), job.notebook_id.clone(), false) { + let message = state + .notebook_imports + .lock() + .ok() + .and_then(|imports| imports.get(&job.notebook_id).and_then(|status| status.message.clone())) + .unwrap_or_else(|| "NOTEBOOK_IMPORT_FAILED".to_string()); + return Err(message); + } + Ok(()) + })(); + + if result.is_err() { + let Some(latest) = read_current_notebook_setup_run( + state.inner(), + &job.notebook_id, + &job.job_id, + &job.run_id, + ) else { + release_notebook_template_setup(state.inner(), &job.notebook_id); + return; + }; + job.stage = latest.stage; + if let Some(latest_report) = latest.report { + report = latest_report; + } + } + + job.updated_at = chrono::Utc::now().timestamp_millis(); + match result { + Ok(()) => { + job.report = Some(report.clone()); + if report.failed_files > 0 { + job.status = NotebookSetupJobStatus::Partial; + job.stage = NotebookSetupStage::Writing; + } else { + job.status = NotebookSetupJobStatus::Completed; + job.stage = NotebookSetupStage::Completed; + } + job.message = None; + } + Err(message) => { + job.status = NotebookSetupJobStatus::Failed; + if report.failed_files > 0 && job.stage != NotebookSetupStage::Indexing { + job.stage = NotebookSetupStage::Writing; + } + job.report = Some(report); + job.message = Some(message); + } + } + persist_notebook_setup_terminal_until_committed(state.inner(), &app, job.clone()); + release_notebook_template_setup(state.inner(), &job.notebook_id); +} + +#[tauri::command] +pub fn get_notebook_template_setup_status( + notebook_id: String, + state: State, +) -> Result, String> { + read_lock(&state.memo_file, "memo_file") + .get_notebook_setup_job(¬ebook_id) + .map_err(|error| format!("read notebook setup status failed: {error}")) +} + +#[tauri::command] +pub async fn get_notebook_template_setup_snapshot( + app: AppHandle, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let snapshot = read_lock(&state.memo_file, "memo_file") + .get_notebook_setup_snapshot() + .map_err(|error| format!("read notebook setup snapshot failed: {error}")); + snapshot + }) + .await + .map_err(|error| format!("notebook setup snapshot task failed: {error}"))? +} + +/// Start or resume template preparation for an opened notebook. Failed and +/// partial jobs only restart when the user explicitly requests a retry. +#[tauri::command] +pub fn start_notebook_template_setup( + notebook_id: String, + retry: Option, + app: AppHandle, +) -> Result, String> { + let state = app.state::(); + let mut running = state + .notebook_template_initializations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let memo_file = read_lock(&state.memo_file, "memo_file"); + let Some(mut job) = memo_file + .get_notebook_setup_job(¬ebook_id) + .map_err(|error| format!("read notebook setup status failed: {error}"))? + else { + return Ok(None); + }; + drop(memo_file); + + if job.status == NotebookSetupJobStatus::Completed { + return Ok(Some(job)); + } + if matches!(job.status, NotebookSetupJobStatus::Partial | NotebookSetupJobStatus::Failed) + && retry != Some(true) + { + return Ok(Some(job)); + } + if job.status == NotebookSetupJobStatus::Partial && retry == Some(true) { + job.status = NotebookSetupJobStatus::Pending; + job.stage = NotebookSetupStage::Preparing; + job.message = None; + job.report = None; + } else if job.status == NotebookSetupJobStatus::Failed && retry == Some(true) { + job.status = NotebookSetupJobStatus::Pending; + job.message = None; + if job.stage != NotebookSetupStage::Indexing { + job.stage = NotebookSetupStage::Preparing; + job.report = None; + } + } + + if !running.insert(notebook_id.clone()) { + return Ok(Some(job)); + } + + job.execution_requested = true; + job.status = NotebookSetupJobStatus::Running; + // Fresh/retried template setup starts by resolving and loading files. + // Preserve indexing retries so already-written templates are not applied again. + if job.stage != NotebookSetupStage::Indexing { + job.stage = NotebookSetupStage::Preparing; + } + let expected_run_id = job.run_id.clone(); + job.run_id = uuid::Uuid::now_v7().to_string(); + job.message = None; + job.updated_at = chrono::Utc::now().timestamp_millis(); + let job = match persist_notebook_setup_run_start(state.inner(), &app, &job, &expected_run_id) { + Ok(job) => job, + Err(error) => { + running.remove(¬ebook_id); + return Err(error); + } + }; + drop(running); + + let worker_app = app.clone(); + let worker_job = job.clone(); + let panic_app = app.clone(); + let panic_job = job.clone(); + if let Err(error) = std::thread::Builder::new() + .name(format!("notebook-setup-{}", job.notebook_id)) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + finish_notebook_template_setup(worker_app, worker_job) + })); + if result.is_err() { + fail_notebook_template_setup_after_panic(panic_app, panic_job); + } + }) + { + let mut failed = job.clone(); + failed.status = NotebookSetupJobStatus::Failed; + failed.message = Some(format!("NOTEBOOK_SETUP_WORKER_START_FAILED:{error}")); + failed.updated_at = chrono::Utc::now().timestamp_millis(); + persist_notebook_setup_terminal_until_committed(state.inner(), &app, failed); + release_notebook_template_setup(state.inner(), ¬ebook_id); + return Err(format!("failed to spawn notebook setup worker: {error}")); + } + Ok(Some(job)) +} + +/// Resume requested or interrupted jobs after workspace preparation. Also +/// start a pending job when its notebook is the active, ready workspace. +#[tauri::command] +pub async fn start_or_resume_ready_notebook_template_setups( + active_notebook_id: Option, + app: AppHandle, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let candidates = read_lock(&state.memo_file, "memo_file") + .list_notebook_setup_jobs() + .map_err(|error| format!("read notebook setup jobs failed: {error}"))? + .into_iter() + .filter(|job| { + job.status == NotebookSetupJobStatus::Running + || (job.status == NotebookSetupJobStatus::Pending + && (job.execution_requested + || active_notebook_id.as_deref() == Some(job.notebook_id.as_str()))) + }) + .map(|job| job.notebook_id) + .collect::>(); + let mut failures = Vec::new(); + for notebook_id in candidates { + match start_notebook_template_setup(notebook_id, None, app.clone()) { + Ok(Some(_)) => {} + Ok(None) => {} + Err(error) => { + tracing::warn!("failed to resume notebook template setup: {error}"); + failures.push(error); + } + } + } + if !failures.is_empty() { + return Err(format!( + "failed to resume {} notebook template setup job(s): {}", + failures.len(), + failures.join("; ") + )); + } + Ok(()) + }) + .await + .map_err(|error| format!("notebook setup recovery task failed: {error}"))? +} + +/// Attach a deferred template to an already registered notebook. +/// This is used by retry-safe onboarding when its chosen default path was +/// registered before the current onboarding attempt. +#[tauri::command] +pub async fn ensure_notebook_template_setup( + notebook_id: String, + template_id: String, + overwrite_existing: Option, + app: AppHandle, +) -> Result { + let event_app = app.clone(); + let job = tauri::async_runtime::spawn_blocking(move || { + super::memo::creates::validate_notebook_template_id(&template_id)?; + let state = app.state::(); + let running = state + .notebook_template_initializations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let memo_file = read_lock(&state.memo_file, "memo_file"); + let notebook = memo_file + .get_notebook_config_by_id(¬ebook_id) + .ok_or_else(|| "NOTEBOOK_NOT_FOUND".to_string())?; + let existing = memo_file + .get_notebook_setup_job(¬ebook_id) + .map_err(|error| format!("read notebook setup status failed: {error}"))?; + if let Some(existing) = &existing { + let matches = match existing.template_id.as_deref() { + Some(existing_id) => { + super::memo::creates::notebook_template_ids_match(existing_id, &template_id)? + } + None => false, + }; + if !matches { + return Err("NOTEBOOK_TEMPLATE_SELECTION_CONFLICT".to_string()); + } + } + + let notebook_root = PathBuf::from(¬ebook.path); + if !notebook_root.is_dir() { + return Err("PATH_MISSING".to_string()); + } + let overwrite_existing = overwrite_existing.unwrap_or(false); + if existing.is_none() + && !overwrite_existing + && super::notebook::notebook_folder_has_content(¬ebook_root)? + { + return Err("NOTEBOOK_PRESET_OVERWRITE_CONFIRM_REQUIRED".to_string()); + } + if overwrite_existing + && existing.as_ref().is_some_and(|job| !job.overwrite_existing) + && running.contains(¬ebook_id) + { + return Err("NOTEBOOK_TEMPLATE_SETUP_IN_PROGRESS".to_string()); + } + MemoFile::ensure_notebook_scene_id(¬ebook, &template_id) + .map_err(|error| format!("NOTEBOOK_SCENE_ID_WRITE_FAILED: {error}"))?; + + if let Some(mut existing) = existing { + let normalize_template_id = !running.contains(¬ebook_id) + && existing.template_id.as_deref() != Some(template_id.as_str()); + let enable_overwrite = overwrite_existing && !existing.overwrite_existing; + if normalize_template_id { + existing.template_id = Some(template_id); + } + if enable_overwrite { + existing.overwrite_existing = true; + existing.status = NotebookSetupJobStatus::Pending; + existing.stage = NotebookSetupStage::Preparing; + existing.message = None; + existing.report = None; + } + if normalize_template_id || enable_overwrite { + existing.updated_at = chrono::Utc::now().timestamp_millis(); + existing = memo_file + .write_notebook_setup_job(&existing) + .map_err(|error| format!("persist notebook setup status failed: {error}"))?; + } + return Ok(existing); + } + let job = NotebookSetupJob { + notebook_id, + job_id: uuid::Uuid::now_v7().to_string(), + template_id: Some(template_id), + overwrite_existing, + status: NotebookSetupJobStatus::Pending, + stage: NotebookSetupStage::Preparing, + revision: 0, + run_id: String::new(), + message: None, + report: None, + updated_at: chrono::Utc::now().timestamp_millis(), + execution_requested: false, + }; + let job = memo_file + .write_notebook_setup_job(&job) + .map_err(|error| format!("persist notebook setup status failed: {error}"))?; + Ok(job) + }) + .await + .map_err(|error| format!("notebook template setup registration task failed: {error}"))??; + emit_notebook_setup_job(&event_app, job.clone()); + Ok(job) +} diff --git a/app/flowix-desktop/src/commands/path_change.rs b/app/flowix-desktop/src/commands/path_change.rs new file mode 100644 index 000000000..5b010b220 --- /dev/null +++ b/app/flowix-desktop/src/commands/path_change.rs @@ -0,0 +1,130 @@ +use serde::Serialize; +use tauri::Emitter; + +/// The same confirmed fact is returned by a command and broadcast to windows. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookPathMove { + pub change_id: String, + pub notebook_id: String, + pub previous_relative_path: String, + pub relative_path: String, + pub directory: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookPathCreated { + pub change_id: String, + pub notebook_id: String, + pub relative_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub origin_window_label: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookPathDeleted { + pub change_id: String, + pub notebook_id: String, + pub relative_path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookPathInvalidated { + pub change_id: String, + pub notebook_id: String, + pub relative_path: String, +} + +impl NotebookPathCreated { + pub fn from_window( + notebook_id: String, + relative_path: String, + origin_window_label: String, + ) -> Self { + Self { + change_id: uuid::Uuid::new_v4().to_string(), + notebook_id, + relative_path, + source: None, + origin_window_label: Some(origin_window_label), + } + } + + pub fn from_external_tool(notebook_id: String, relative_path: String) -> Self { + Self { + change_id: uuid::Uuid::new_v4().to_string(), + notebook_id, + relative_path, + source: Some("external_tool"), + origin_window_label: None, + } + } + + pub fn emit(&self, app: &tauri::AppHandle) { + let mut payload = serde_json::json!({ + "changeId": self.change_id, + "notebookId": self.notebook_id, + "relativePath": self.relative_path, + "created": true, + "deleted": false, + }); + if let Some(source) = self.source { + payload["source"] = serde_json::json!(source); + } + if let Some(origin) = &self.origin_window_label { + payload["originWindowLabel"] = serde_json::json!(origin); + } + let _ = app.emit("flowix:path-note-changed", payload); + } +} + +impl NotebookPathDeleted { + pub fn new(notebook_id: String, relative_path: String) -> Self { + Self { change_id: uuid::Uuid::new_v4().to_string(), notebook_id, relative_path } + } + + pub fn emit(&self, app: &tauri::AppHandle) { + let payload = serde_json::json!({ + "changeId": self.change_id, + "notebookId": self.notebook_id, + "relativePath": self.relative_path, + "deleted": true, + }); + let _ = app.emit("flowix:path-note-changed", payload); + } +} + +impl NotebookPathInvalidated { + pub fn new(notebook_id: String, relative_path: String) -> Self { + Self { change_id: uuid::Uuid::new_v4().to_string(), notebook_id, relative_path } + } + + pub fn emit(&self, app: &tauri::AppHandle) { + let _ = app.emit("flowix:path-note-changed", self); + } +} + +impl NotebookPathMove { + pub fn new(notebook_id: String, previous: String, next: String, directory: bool) -> Self { + Self { + change_id: uuid::Uuid::new_v4().to_string(), notebook_id, + previous_relative_path: previous, relative_path: next, directory, + } + } + + pub fn emit(&self, app: &tauri::AppHandle) { + let _ = app.emit("flowix:path-note-changed", self); + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PathMutationResult { + pub path: String, + pub notebook_change: Option, +} diff --git a/app/flowix-desktop/src/commands/product.rs b/app/flowix-desktop/src/commands/product.rs index 265c9de98..c05f66e17 100644 --- a/app/flowix-desktop/src/commands/product.rs +++ b/app/flowix-desktop/src/commands/product.rs @@ -6,7 +6,7 @@ use tauri_plugin_opener::OpenerExt; use crate::app::state::AppState; use crate::commands::helpers::{ - can_access_document_path, can_access_scoped_file, start_security_bookmark_access, + can_access_document_path, can_access_scoped_file, }; use crate::runtime_log; @@ -20,9 +20,15 @@ pub struct ProductInfo { pub log_dir: String, pub os: &'static str, pub arch: &'static str, + pub is_dev_build: bool, + pub cloud_environment: &'static str, } -fn product_info() -> ProductInfo { +pub(crate) fn is_dev_build() -> bool { + option_env!("FLOWIX_DEV_MODE") == Some("1") +} + +fn product_info(cloud_environment: flowix_sync::CloudEnvironment) -> ProductInfo { ProductInfo { product_name: runtime_log::PRODUCT_NAME, version: runtime_log::APP_VERSION, @@ -31,12 +37,37 @@ fn product_info() -> ProductInfo { log_dir: runtime_log::log_dir().display().to_string(), os: std::env::consts::OS, arch: std::env::consts::ARCH, + is_dev_build: is_dev_build(), + cloud_environment: cloud_environment.name(), } } #[tauri::command] -pub fn get_product_info() -> ProductInfo { - product_info() +pub fn get_product_info(state: State) -> ProductInfo { + product_info(state.cloud_environment) +} + +#[tauri::command] +pub fn set_cloud_environment( + environment: String, + state: State, +) -> Result<(), String> { + if !is_dev_build() { + return Err("Cloud environment can only be changed in a development build".to_string()); + } + let Some(target) = flowix_sync::CloudEnvironment::parse(&environment) else { + return Err(format!("unsupported Cloud environment: {environment}")); + }; + + let saved_environment = state.user_config.get_preference().cloud_environment; + if saved_environment == target.name() { + return Ok(()); + } + + state + .user_config + .patch_preference(serde_json::json!({ "cloudEnvironment": target.name() })) + .map_err(|error| error.to_string()) } #[tauri::command] @@ -70,7 +101,6 @@ pub fn open_file_with_default_app( return Err("File is outside its authorized scope".to_string()); } - start_security_bookmark_access(state.inner(), &path); let mut command = if cfg!(target_os = "macos") { Command::new("open") } else if cfg!(target_os = "windows") { diff --git a/app/flowix-desktop/src/commands/settings.rs b/app/flowix-desktop/src/commands/settings.rs index bdb53fe0c..e3fa83aec 100644 --- a/app/flowix-desktop/src/commands/settings.rs +++ b/app/flowix-desktop/src/commands/settings.rs @@ -14,7 +14,7 @@ use crate::app::state::AppState; /// 跨窗口同步事�?—任一窗口成功写入偏好 / AI 配置�?emit, 其它窗口 /// (主窗�?/ 偏好窗口 / �?��的�?窗口) 收到后从磁盘重新 load�?/// 解决: 两个 Tauri 窗口各跑�?�� React �?+ �?�� zustand store, 一�?/// 改动另一边看不到的问题�? -pub(super) const USER_CONFIG_CHANGED_EVENT: &str = "user-config-changed"; +pub(crate) const USER_CONFIG_CHANGED_EVENT: &str = "user-config-changed"; /// 用户偏好 (preference.json) —�?~/.flowix/boot/preference.json #[tauri::command] @@ -62,7 +62,7 @@ pub async fn get_deepseek_harness_config( state: State<'_, AppState>, ) -> Result { let mut configs = load_dsh_model_configs(&state).await?; - hydrate_dsh_credentials_without_blocking(&state, &mut configs).await; + hydrate_dsh_credential_statuses_without_blocking(&state, &mut configs).await; Ok(configs.into_iter().next().unwrap_or_default()) } @@ -71,7 +71,7 @@ pub async fn get_deepseek_harness_configs( state: State<'_, AppState>, ) -> Result, String> { let mut configs = load_dsh_model_configs(&state).await?; - hydrate_dsh_credentials_without_blocking(&state, &mut configs).await; + hydrate_dsh_credential_statuses_without_blocking(&state, &mut configs).await; Ok(configs) } @@ -79,21 +79,12 @@ async fn load_dsh_model_configs(state: &State<'_, AppState>) -> Result, configs: &mut [AiConfigFile], ) { - if let Err(error) = state - .deepseek_harness - .migrate_legacy_credentials(configs) - .await - { - tracing::warn!(%error, "could not migrate legacy Flowix credentials to DSH"); - } if let Err(error) = state .deepseek_harness .hydrate_credential_statuses(configs) @@ -222,6 +213,11 @@ pub fn save_pi_features(features: crate::agent_external::pi::config::PiFeatures) pi_model_config::save_features(features) } +#[tauri::command] +pub fn clear_pi_legacy_code_mode() -> Result<(), String> { + pi_model_config::clear_legacy_code_mode() +} + #[tauri::command] pub fn get_pi_model_catalog( app: AppHandle, diff --git a/app/flowix-desktop/src/commands/shutdown_watchdog.rs b/app/flowix-desktop/src/commands/shutdown_watchdog.rs new file mode 100644 index 000000000..8157142a6 --- /dev/null +++ b/app/flowix-desktop/src/commands/shutdown_watchdog.rs @@ -0,0 +1,26 @@ +//! Lock-free deadline enforcement. Callbacks must not depend on the UI/runtime. +use std::time::{Duration, Instant}; + +/// Total close/quit budget measured from native request receipt, not save start. +pub(super) const CLOSE_BUDGET: Duration = Duration::from_secs(1); + +pub(super) fn arm(deadline: Instant, force: impl FnOnce() + Send + 'static) { + std::thread::spawn(move || { + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + force(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + #[test] + fn expired_deadline_is_not_restarted_when_work_is_delayed() { + let (sender, receiver) = mpsc::channel(); + arm(Instant::now() - Duration::from_secs(1), move || { + let _ = sender.send(()); + }); + receiver.recv_timeout(Duration::from_millis(100)).unwrap(); + } +} diff --git a/app/flowix-desktop/src/commands/tag.rs b/app/flowix-desktop/src/commands/tag.rs index 6af97e35a..b549283d9 100644 --- a/app/flowix-desktop/src/commands/tag.rs +++ b/app/flowix-desktop/src/commands/tag.rs @@ -37,22 +37,18 @@ pub struct TagWithId { pub name: String, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateTagReport { - pub path: String, -} - #[tauri::command] pub async fn get_all_tags( notebook_id: Option, app: AppHandle, ) -> Result { - tauri::async_runtime::spawn_blocking(move || { + tauri::async_runtime::spawn_blocking(move || -> Result { let state = app.state::(); - let tags = read_lock(&state.memo_file, "memo_file") - .derived_tags_for_notebook_id(notebook_id.as_deref()); - GetAllTagsResponse { + let memo_file = read_lock(&state.memo_file, "memo_file"); + let id = notebook_id.or_else(|| memo_file.current_notebook_id_value()).unwrap_or_default(); + let tags = memo_file.derived_tags_for_notebook_id(Some(&id)) + .map_err(|error| crate::app::notebook_background::query_error(state.inner(), &id, error))?; + Ok(GetAllTagsResponse { tags: tags .into_iter() .map(|t| TagWithId { @@ -60,22 +56,10 @@ pub async fn get_all_tags( name: t.name, }) .collect(), - } + }) }) .await - .map_err(|error| format!("tag list task failed: {error}")) -} - -#[tauri::command] -pub fn create_notebook_tag( - notebook_id: String, - path: String, - state: State, -) -> Result { - let path = read_lock(&state.memo_file, "memo_file") - .create_notebook_tag(¬ebook_id, &path) - .map_err(|error| error.to_string())?; - Ok(CreateTagReport { path }) + .map_err(|error| format!("tag list task failed: {error}"))? } /// 移动 subtag: �?`old_path` 整棵子树重命�?(�?prefix 替换), @@ -111,10 +95,6 @@ pub fn move_memo_tag( }; let sync_notebook_id = notebook_id.as_deref().unwrap_or("nb_default"); - // read lock 已释放: 逐条 emit MemoEvent::Updated, 让前端 memo 卡片 / - // 标签树刷新 (derived_changed.tags -> refreshSelectedNotebookMetadata)。 - for path in &affected_relative_paths { - } mark_tagged_memos_cloud_dirty(state.inner(), sync_notebook_id, &affected_relative_paths); if !affected_relative_paths.is_empty() { crate::commands::cloud::schedule_notebook_sync(app.clone(), sync_notebook_id.to_string()); @@ -158,11 +138,6 @@ pub fn delete_memo_tag( }; let sync_notebook_id = notebook_id.as_deref().unwrap_or("nb_default"); - // read lock released: - // 1) refresh search index per affected note (tag removed from body, - // search index must follow -- otherwise stale hits leak through). - for path in &affected_relative_paths { - } mark_tagged_memos_cloud_dirty(state.inner(), sync_notebook_id, &affected_relative_paths); if !affected_relative_paths.is_empty() { crate::commands::cloud::schedule_notebook_sync(app.clone(), sync_notebook_id.to_string()); @@ -195,8 +170,11 @@ pub async fn get_tag_prefix_counts( }); memo_file .read_tag_prefix_counts_for_notebook_id(notebook_id.as_deref()) - .unwrap_or_default() + .map_err(|error| { + let id = notebook_id.clone().or_else(|| memo_file.current_notebook_id_value()).unwrap_or_default(); + crate::app::notebook_background::query_error(state.inner(), &id, error) + }) }) .await - .map_err(|error| format!("tag count task failed: {error}")) + .map_err(|error| format!("tag count task failed: {error}"))? } diff --git a/app/flowix-desktop/src/commands/thread.rs b/app/flowix-desktop/src/commands/thread.rs index cbc4ea776..e45a94ad7 100644 --- a/app/flowix-desktop/src/commands/thread.rs +++ b/app/flowix-desktop/src/commands/thread.rs @@ -256,20 +256,28 @@ pub async fn agent_conversation_list( #[tauri::command] pub async fn agent_conversation_list_page( - notebook_id: Option, + notebook_id: String, agent_type: Option, cursor: Option, limit: usize, state: State<'_, AppState>, ) -> Result { let page_size = limit.clamp(1, 100); - let notebook_id = notebook_id.filter(|value| !value.trim().is_empty()); + let notebook_id = notebook_id.trim(); + if notebook_id.is_empty() { + return Err("NOTEBOOK_ID_REQUIRED".to_string()); + } let agent_type = agent_type .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()); let manager = &state.thread_manager; let items = manager - .list_agent_conversation_instances_page(notebook_id, agent_type, cursor, page_size + 1) + .list_agent_conversation_instances_page( + Some(notebook_id.to_string()), + agent_type, + cursor, + page_size + 1, + ) .await .map_err(|e| e.to_string())?; let has_more = items.len() > page_size; @@ -287,24 +295,32 @@ pub async fn agent_conversation_list_page( #[tauri::command] pub async fn agent_conversation_count_by_notebook( - notebook_id: Option, + notebook_id: String, state: State<'_, AppState>, ) -> Result { + let notebook_id = notebook_id.trim(); + if notebook_id.is_empty() { + return Err("NOTEBOOK_ID_REQUIRED".to_string()); + } let manager = &state.thread_manager; manager - .count_agent_conversation_instances_by_notebook(notebook_id) + .count_agent_conversation_instances_by_notebook(Some(notebook_id.to_string())) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn agent_conversation_type_counts_by_notebook( - notebook_id: Option, + notebook_id: String, state: State<'_, AppState>, ) -> Result, String> { + let notebook_id = notebook_id.trim(); + if notebook_id.is_empty() { + return Err("NOTEBOOK_ID_REQUIRED".to_string()); + } state .thread_manager - .list_agent_conversation_type_counts_by_notebook(notebook_id) + .list_agent_conversation_type_counts_by_notebook(Some(notebook_id.to_string())) .await .map_err(|e| e.to_string()) } @@ -507,7 +523,7 @@ pub async fn claude_thread_session_id( let manager = &state.thread_manager; manager - .get_external_session(&thread_id, "claude") + .get_external_session(&thread_id, "claudecode") .await .map_err(|e| e.to_string()) } @@ -617,7 +633,7 @@ pub async fn deepseek_harness_thread_session_id( ) -> Result, String> { state .thread_manager - .get_external_session(&thread_id, "deepseek-harness") + .get_external_session(&thread_id, "dsh") .await .map_err(|error| error.to_string()) } @@ -637,7 +653,7 @@ pub async fn deepseek_harness_thread_fork( let child = state .thread_manager .create_thread( - crate::agent_types::AgentId("deepseek-harness".to_string()), + crate::agent_types::AgentId("dsh".to_string()), format!("{} (fork)", source.info.title), ) .await diff --git a/app/flowix-desktop/src/commands/window.rs b/app/flowix-desktop/src/commands/window.rs index 289a26327..6a660edd3 100644 --- a/app/flowix-desktop/src/commands/window.rs +++ b/app/flowix-desktop/src/commands/window.rs @@ -40,6 +40,9 @@ mod navigation_tests { #[tauri::command] pub fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { + if crate::commands::window_shutdown::is_exiting() { + return Ok(()); + } let Some(window) = app.get_webview_window("main") else { return Ok(()); }; @@ -55,8 +58,19 @@ pub async fn open_preferences_window( app: tauri::AppHandle, state: tauri::State<'_, AppState>, tab: Option, +) -> Result<(), String> { + open_preferences_window_inner(&app, &state, tab.as_deref()) +} + +pub(crate) fn open_preferences_window_inner( + app: &tauri::AppHandle, + state: &AppState, + tab: Option<&str>, ) -> Result<(), String> { use tauri::WebviewWindowBuilder; + if crate::commands::window_shutdown::is_exiting() { + return Err("Application is closing".into()); + } let boot_theme = match state.user_config.get_preference().theme { Theme::System => None, @@ -69,27 +83,26 @@ pub async fn open_preferences_window( Some(theme) => format!("index.html?bootTheme={theme}"), None => "index.html".to_string(), }; - let url = match tab { - Some(tab) => format!("{base}#preferences/{tab}"), - None => format!("{base}#preferences"), + let hash = match tab { + Some(tab) => format!("preferences/{tab}"), + None => "preferences".to_string(), }; + let url = format!("{base}#{hash}"); if let Some(window) = app.get_webview_window("preferences") { - window.set_focus().ok(); window - .eval(preferences_navigation_script( - url.split_once('#').map(|(_, hash)| hash).unwrap_or(""), - )?) - .ok(); + .eval(preferences_navigation_script(&hash)?) + .map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; return Ok(()); } - let builder = - WebviewWindowBuilder::new(&app, "preferences", tauri::WebviewUrl::App(url.into())) - .title("Preferences") - .inner_size(800.0, 600.0) - .center() - .devtools(cfg!(debug_assertions)); + let builder = WebviewWindowBuilder::new(app, "preferences", tauri::WebviewUrl::App(url.into())) + .title("Preferences") + .visible(false) + .inner_size(800.0, 600.0) + .center() + .devtools(cfg!(debug_assertions)); #[cfg(target_os = "macos")] let builder = builder @@ -102,9 +115,11 @@ pub async fn open_preferences_window( let builder = builder.decorations(false); let window = builder.build().map_err(|e| e.to_string())?; + crate::commands::window_shutdown::install_window(&window); // 新窗口即刻�?齐主题背�?�� (与主窗口�?��一�?, 避免冷启动白�?�? let theme = state.user_config.get_preference().theme; crate::window_chrome::apply_window_chrome_theme(&window, theme); + window.show().map_err(|error| error.to_string())?; Ok(()) } diff --git a/app/flowix-desktop/src/commands/window_input.rs b/app/flowix-desktop/src/commands/window_input.rs new file mode 100644 index 000000000..e0637f1b1 --- /dev/null +++ b/app/flowix-desktop/src/commands/window_input.rs @@ -0,0 +1,44 @@ +//! Thin IPC adapter for native input routing. +/// Publish visual button geometry; routing itself lives outside the WebView. +#[tauri::command] +pub async fn set_window_close_region( + window: tauri::WebviewWindow, + revision: u64, + region: Option, +) -> Result<(), String> { + if region.is_some_and(|region| !region.valid()) { + return Err("Invalid close region".into()); + } + #[cfg(windows)] + { + let hwnd = window.hwnd().map_err(|error| error.to_string())?.0 as usize; + let route = crate::commands::window_shutdown::close_route(&window)?; + let (sender, receiver) = tokio::sync::oneshot::channel(); + window + .run_on_main_thread(move || { + let _ = sender.send(crate::native_close_region::set( + hwnd, route, revision, region, + )); + }) + .map_err(|error| error.to_string())?; + return receiver.await.map_err(|error| error.to_string())?; + } + #[cfg(not(windows))] + { + let _ = (window, revision, region); + Ok(()) + } +} + +/// Actual receiver health, not merely whether initialization was attempted. +#[tauri::command] +pub fn native_close_routing_ready() -> bool { + #[cfg(windows)] + { + crate::native_close_region::routing_ready() + } + #[cfg(not(windows))] + { + true + } +} diff --git a/app/flowix-desktop/src/commands/window_lifetime.rs b/app/flowix-desktop/src/commands/window_lifetime.rs new file mode 100644 index 000000000..d11db8000 --- /dev/null +++ b/app/flowix-desktop/src/commands/window_lifetime.rs @@ -0,0 +1,93 @@ +//! Identity and liveness belong to a native window instance, never its label. +use std::sync::{ + atomic::{AtomicU64, AtomicU8, Ordering}, + Arc, +}; + +const ALIVE: u8 = 0; +const DESTROYED: u8 = 1; +const FORCE_COMMITTED: u8 = 2; + +pub(super) struct WindowLifetime { + id: u64, + state: AtomicU8, +} +impl WindowLifetime { + pub(super) fn new() -> Arc { + static NEXT: AtomicU64 = AtomicU64::new(1); + Arc::new(Self { + id: NEXT.fetch_add(1, Ordering::Relaxed), + state: AtomicU8::new(ALIVE), + }) + } + pub(super) fn id(&self) -> u64 { + self.id + } + pub(super) fn is_alive(&self) -> bool { + self.state.load(Ordering::Acquire) == ALIVE + } + pub(super) fn destroyed(&self) { + let _ = self + .state + .compare_exchange(ALIVE, DESTROYED, Ordering::AcqRel, Ordering::Acquire); + } + /// Destruction and forced termination race through one atomic transition. + /// A stale callback cannot force after Destroyed wins, nor can a late task + /// target a replacement window with the same label. + pub(super) fn force_if_alive(&self, force: impl FnOnce()) { + if self + .state + .compare_exchange(ALIVE, FORCE_COMMITTED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + force(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + sync::{mpsc, Barrier}, + time::Duration, + }; + #[test] + fn worker_resuming_after_destroy_cannot_force_a_replacement() { + let old = WindowLifetime::new(); + let replacement = WindowLifetime::new(); + let paused = Arc::new(Barrier::new(2)); + let (sender, receiver) = mpsc::channel(); + let worker = old.clone(); + let gate = paused.clone(); + let thread = std::thread::spawn(move || { + gate.wait(); + worker.force_if_alive(|| { + sender.send(()).unwrap(); + }); + }); + // This is the old race: dispatch already captured its target, but + // Destroyed is processed before dispatch registers/arms a close. + old.destroyed(); + paused.wait(); + thread.join().unwrap(); + assert!(receiver.recv_timeout(Duration::from_millis(10)).is_err()); + assert_ne!(old.id(), replacement.id()); + assert!(replacement.is_alive()); + } + #[test] + fn every_duplicate_observes_the_same_destroyed_flag() { + let first = WindowLifetime::new(); + let duplicate = first.clone(); + first.destroyed(); + duplicate.force_if_alive(|| panic!("late force callback ran")); + } + #[test] + fn an_unclosed_window_still_requires_force() { + let lifetime = WindowLifetime::new(); + let mut forced = false; + lifetime.force_if_alive(|| forced = true); + assert!(forced); + assert!(!lifetime.is_alive()); + } +} diff --git a/app/flowix-desktop/src/commands/window_shutdown.rs b/app/flowix-desktop/src/commands/window_shutdown.rs new file mode 100644 index 000000000..d1e6f64bb --- /dev/null +++ b/app/flowix-desktop/src/commands/window_shutdown.rs @@ -0,0 +1,539 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, OnceLock, + }, +}; +use tauri::{AppHandle, Emitter, Manager}; + +#[path = "window_lifetime.rs"] +mod lifetime; +#[path = "window_shutdown_state.rs"] +mod shutdown_state; +#[path = "shutdown_watchdog.rs"] +mod watchdog; +use lifetime::WindowLifetime; +use shutdown_state::ExitRequest; +use std::time::Instant; +use watchdog::CLOSE_BUDGET; + +#[derive(Default)] +struct Shutdown { + windows: HashSet, + waiting: Option<(u64, HashSet, Completion)>, + approved: bool, + targets: HashMap>, +} +pub(crate) struct CloseTarget { + lifetime: Arc, + window: tauri::WebviewWindow, + closing: OnceLock, +} +struct CloseAttempt { + request: u64, + started: Instant, +} + +/// A resource in the native Window's own table survives label reuse and JS +/// reloads. It contains no Window/App handle, so there is no ownership cycle. +struct WindowIdentity(Arc); +impl tauri::Resource for WindowIdentity {} +fn window_lifetime(window: &tauri::Window) -> Arc { + let mut resources = window.resources_table(); + let existing = resources + .names() + .find(|(_, name)| name.as_ref() == std::any::type_name::()) + .and_then(|(id, _)| resources.get::(id).ok()); + if let Some(identity) = existing { + return identity.0.clone(); + } + let lifetime = WindowLifetime::new(); + resources.add(WindowIdentity(lifetime.clone())); + lifetime +} +fn native_window(window: &tauri::WebviewWindow) -> tauri::Window { + let webview: &tauri::Webview = window.as_ref(); + webview.window() +} +fn next_request() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +enum Completion { + Exit(i32), + #[cfg(windows)] + Prepared(tokio::sync::oneshot::Sender), +} +fn state() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(Mutex::default) +} + +// These deadlines run on OS threads, independently of WebView/Tokio/UI progress. +const UPDATE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(8); + +static EXITING: AtomicBool = AtomicBool::new(false); + +pub(crate) fn is_exiting() -> bool { + EXITING.load(Ordering::Acquire) +} + +fn arm_exit_watchdog(code: i32, started: Instant) { + if EXITING.swap(true, Ordering::AcqRel) { + return; + } + watchdog::arm(started + CLOSE_BUDGET, move || hard_exit(code)); +} + +#[cfg(test)] +fn spawn_hard_exit(code: i32, deadline: std::time::Duration) { + watchdog::arm(Instant::now() + deadline, move || hard_exit(code)); +} + +fn hard_exit(code: i32) -> ! { + // Bypass atexit handlers and Rust destructors: they may hold the very locks + // that prevented orderly shutdown. + #[cfg(unix)] + unsafe { + libc::_exit(code); + } + #[cfg(windows)] + unsafe { + use windows::Win32::System::Threading::{GetCurrentProcess, TerminateProcess}; + let _ = TerminateProcess(GetCurrentProcess(), code as u32); + } + #[allow(unreachable_code)] + std::process::abort() +} + +/// Call during native construction, before showing a window. Repeated calls +/// for the same instance are harmless; a dead instance is never reinstalled. +pub(crate) fn install_window(window: &tauri::WebviewWindow) -> Option> { + let lifetime = window_lifetime(&native_window(window)); + if !lifetime.is_alive() { + return None; + } + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + if !lifetime.is_alive() { + return None; + } + if let Some(target) = state.targets.get(&lifetime.id()) { + return Some(target.clone()); + } + let target = Arc::new(CloseTarget { + lifetime, + window: window.clone(), + closing: OnceLock::new(), + }); + state.targets.insert(target.lifetime.id(), target.clone()); + Some(target) +} + +/// This global native listener receives the actual Window instance, not only +/// its reusable label. Destruction updates the shared flag even before a late +/// constructor/input worker manages to register its coordinator record. +pub(crate) fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { + match event { + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + let lifetime = window_lifetime(window); + if !lifetime.is_alive() { + return; + } + let target = state() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .targets + .get(&lifetime.id()) + .cloned(); + let target = target.or_else(|| { + window + .get_webview_window(window.label()) + .and_then(|webview| { + let target = install_window(&webview)?; + (target.lifetime.id() == lifetime.id()).then_some(target) + }) + }); + if let Some(target) = target { + request_target(target, Instant::now()); + } + } + tauri::WindowEvent::Destroyed => { + let lifetime = window_lifetime(window); + lifetime.destroyed(); // atomic; all stale input/deadline clones see it + forget_instance(window.app_handle(), &lifetime); + } + _ => {} + } +} + +fn request_target(target: Arc, started: Instant) { + if !target.lifetime.is_alive() { + return; + } + let mut first = false; + let closing = target.closing.get_or_init(|| { + first = true; + CloseAttempt { + request: next_request(), + started, + } + }); + if !first { + return; + } + // Arm once per native close attempt, before taking the coordinator lock. + // Every callback captures this instance's liveness, never its reusable label. + let lifetime = target.lifetime.clone(); + watchdog::arm(started + CLOSE_BUDGET, move || { + lifetime.force_if_alive(|| hard_exit(0)) + }); + if !target.lifetime.is_alive() { + return; + } + let request = closing.request; + let app = target.window.app_handle(); + let label = target.window.label(); + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + if !target.lifetime.is_alive() { + return; + } + let cancelled_update = if state + .waiting + .as_ref() + .is_some_and(|(_, _, completion)| !matches!(completion, Completion::Exit(_))) + { + state.waiting.take().map(|(request, _, _)| request) + } else { + None + }; + let needs_save = state.windows.contains(&target.lifetime.id()); + drop(state); + if let Some(request) = cancelled_update { + let _ = app.emit("document:exit-cancelled", request); + } + if !needs_save + || app + .emit_to(label, "document:prepare-close", request) + .is_err() + { + complete_close(&target, request); + } +} + +fn complete_close(target: &CloseTarget, request: u64) { + if target.lifetime.is_alive() + && target + .closing + .get() + .is_some_and(|closing| closing.request == request) + { + let _ = target.window.destroy(); // success only when Destroyed is observed + } +} + +/// Input routes capture a specific instance. No label lookup happens later on +/// the native input worker, so an old click cannot close a replacement window. +#[cfg(windows)] +pub(crate) fn close_route( + window: &tauri::WebviewWindow, +) -> Result { + let target = install_window(window).ok_or("Window has already been destroyed")?; + let lifetime = target.lifetime.clone(); + Ok(crate::native_close_region::CloseRoute::new( + target.lifetime.id(), + move || lifetime.is_alive(), + move |started| { + request_target(target.clone(), started); + }, + )) +} +#[cfg(windows)] +pub(crate) fn clear_native_region(window: &tauri::Window) { + crate::native_close_region::remove(window_lifetime(window).id()); +} + +#[tauri::command] +pub fn finish_document_close(window: tauri::WebviewWindow, request: u64, ready: bool) { + if let Some(target) = install_window(&window) { + complete_close(&target, request); + } + if !ready { + tracing::warn!("closing window despite incomplete document persistence"); + } +} + +#[tauri::command] +pub fn register_document_window(window: tauri::WebviewWindow, app: AppHandle) { + let Some(target) = install_window(&window) else { + return; + }; + if is_exiting() { + let _ = window.destroy(); + return; + } + let label = window.label().to_string(); + let pending_request = { + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + if !target.lifetime.is_alive() { + return; + } + state.windows.insert(target.lifetime.id()); + state.waiting.as_mut().map(|(request, windows, _)| { + windows.insert(target.lifetime.id()); + *request + }) + }; + if let Some(request) = pending_request { + let _ = app.emit_to(&label, "document:prepare-exit", request); + } +} + +fn forget_instance(app: &AppHandle, lifetime: &WindowLifetime) { + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + let Some(target) = state.targets.remove(&lifetime.id()) else { + return; + }; + state.windows.remove(&lifetime.id()); + if !state + .targets + .values() + .any(|other| other.lifetime.is_alive()) + { + if let Some(closing) = target.closing.get() { + // Only actual destruction of the last registered instance promotes + // its original close deadline to an unconditional process deadline. + arm_exit_watchdog(0, closing.started); + } + } + let require_saved = state + .waiting + .as_ref() + .is_some_and(|(_, _, completion)| !matches!(completion, Completion::Exit(_))); + let outcome = shutdown_state::destroyed(&mut state.waiting, lifetime.id(), require_saved); + #[cfg(windows)] + crate::native_close_region::remove(lifetime.id()); + resolve_outcome(app, state, outcome); +} + +/// Briefly defer exit for best-effort saving; failure can never veto user exit. +pub fn request_exit(app: &AppHandle, code: i32) -> bool { + arm_exit_watchdog(code, Instant::now()); + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + let cancelled = match shutdown_state::prepare_user_exit(&mut state.waiting, |completion| { + matches!(completion, Completion::Exit(_)) + }) { + ExitRequest::AlreadyPending => return false, + ExitRequest::Start { cancelled } => cancelled, + }; + drop(state); + if let Some((request, completion)) = cancelled { + drop(completion); + let _ = app.emit("document:exit-cancelled", request); + } + begin_request(app, Completion::Exit(code)) +} + +/// Tauri's Windows updater uses process::exit, so saving must finish before +/// handing it the installation payload. Cancellation never exits the app. +#[cfg(windows)] +pub async fn prepare_for_update(app: &AppHandle) -> Result { + if is_exiting() { + return Err("Application is closing".into()); + } + let (sender, receiver) = tokio::sync::oneshot::channel(); + begin_request(app, Completion::Prepared(sender)); + receiver.await.map_err(|_| { + "Update cancelled: documents could not finish saving. Save your documents and retry." + .to_string() + }) +} + +/// Unfreeze the editor if launching installation fails or the task is cancelled. +#[cfg(windows)] +pub struct UpdatePreparation { + app: AppHandle, + request: u64, +} +#[cfg(windows)] +impl Drop for UpdatePreparation { + fn drop(&mut self) { + let _ = self.app.emit("document:exit-cancelled", self.request); + } +} + +fn begin_request(app: &AppHandle, completion: Completion) -> bool { + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + if (state.approved && matches!(&completion, Completion::Exit(_))) || state.windows.is_empty() { + #[cfg(windows)] + if let Completion::Prepared(sender) = completion { + let _ = sender.send(UpdatePreparation { + app: app.clone(), + request: 0, + }); + } + return true; + } + if state.waiting.is_some() { + return false; + } + let request = next_request(); + let windows = state.windows.clone(); + let targets = windows + .iter() + .map(|window_id| { + let label = state + .targets + .get(window_id) + .filter(|target| target.lifetime.is_alive()) + .map(|target| target.window.label().to_string()); + (*window_id, label) + }) + .collect::>(); + let budget = if matches!(&completion, Completion::Exit(_)) { + CLOSE_BUDGET + } else { + UPDATE_SAVE_DEADLINE + }; + state.waiting = Some((request, windows.clone(), completion)); + drop(state); + let timeout_app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(budget); + expire_request(&timeout_app, request); + }); + for (window_id, label) in targets { + let Some(label) = label else { + finish_failed_window(app, window_id, request); + continue; + }; + if let Err(error) = app.emit_to(&label, "document:prepare-exit", request) { + tracing::error!("cannot prepare document window for exit: {error}"); + finish_failed_window(app, window_id, request); + } + } + false +} + +fn expire_request(app: &AppHandle, request: u64) { + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + let Some((_, completion)) = shutdown_state::expire(&mut state.waiting, request) else { + return; + }; + state.approved = matches!(&completion, Completion::Exit(_)); + drop(state); + match completion { + Completion::Exit(code) => app.exit(code), + #[cfg(windows)] + Completion::Prepared(_) => { + let _ = app.emit("document:exit-cancelled", request); + } + } +} + +fn finish_failed_window(app: &AppHandle, window_id: shutdown_state::WindowId, request: u64) { + report_window(app, window_id, request, false); +} + +fn report_window(app: &AppHandle, window_id: shutdown_state::WindowId, request: u64, ready: bool) { + let mut state = state().lock().unwrap_or_else(|e| e.into_inner()); + let Some((id, _, completion)) = state.waiting.as_ref() else { + return; + }; + if *id != request { + return; + } + let require_saved = !matches!(completion, Completion::Exit(_)); + let outcome = shutdown_state::report(&mut state.waiting, window_id, ready, require_saved); + resolve_outcome(app, state, outcome); +} + +fn resolve_outcome( + app: &AppHandle, + mut state: std::sync::MutexGuard<'_, Shutdown>, + outcome: shutdown_state::Report, +) { + match outcome { + shutdown_state::Report::Pending => {} + shutdown_state::Report::Cancelled(request) => { + drop(state); + let _ = app.emit("document:exit-cancelled", request); + } + shutdown_state::Report::Complete(request, completion) => { + state.approved = matches!(&completion, Completion::Exit(_)); + drop(state); + complete_request(app, request, completion); + } + } +} + +#[tauri::command] +pub fn finish_document_shutdown( + window: tauri::WebviewWindow, + app: AppHandle, + request: u64, + ready: bool, +) { + if !ready { + tracing::warn!("document persistence incomplete during shutdown"); + } + let window_id = window_lifetime(&native_window(&window)).id(); + report_window(&app, window_id, request, ready); +} + +fn complete_request(app: &AppHandle, _request: u64, completion: Completion) { + match completion { + Completion::Exit(code) => app.exit(code), + #[cfg(windows)] + Completion::Prepared(sender) => { + // An unread queued guard is also dropped when an update is cancelled. + let _ = sender.send(UpdatePreparation { + app: app.clone(), + request: _request, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hard_deadline_ignores_blocked_cleanup() { + const CHILD: &str = "FLOWIX_SHUTDOWN_WATCHDOG_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + spawn_hard_exit(73, CLOSE_BUDGET); + let mutex = Mutex::new(()); + let _held = mutex.lock().unwrap(); + // Deliberately deadlock this process; only the native watchdog can + // finish it. No Tauri event loop or Tokio runtime is present. + let _blocked = mutex.lock().unwrap(); + unreachable!(); + } + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "commands::window_shutdown::tests::hard_deadline_ignores_blocked_cleanup", + ]) + .env(CHILD, "1") + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert_eq!(status.code(), Some(73)); + break; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("watchdog failed to terminate a blocked process"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } +} diff --git a/app/flowix-desktop/src/commands/window_shutdown_state.rs b/app/flowix-desktop/src/commands/window_shutdown_state.rs new file mode 100644 index 000000000..6e0524d77 --- /dev/null +++ b/app/flowix-desktop/src/commands/window_shutdown_state.rs @@ -0,0 +1,243 @@ +use std::collections::HashSet; + +pub(super) type WindowId = u64; + +pub(super) enum ExitRequest { + AlreadyPending, + Start { cancelled: Option<(u64, C)> }, +} + +/// A repeated user quit must remain deferred while the existing save pass runs. +/// A user quit may supersede updater preparation, but never an earlier user quit. +pub(super) fn prepare_user_exit( + waiting: &mut Option<(u64, HashSet, C)>, + is_exit: impl FnOnce(&C) -> bool, +) -> ExitRequest { + if waiting + .as_ref() + .is_some_and(|(_, _, completion)| is_exit(completion)) + { + return ExitRequest::AlreadyPending; + } + ExitRequest::Start { + cancelled: waiting + .take() + .map(|(request, _, completion)| (request, completion)), + } +} + +/// Saving is mandatory for an installer, but only best effort for user exit. +pub(super) enum Report { + Pending, + Complete(u64, C), + Cancelled(u64), +} + +/// Release a window after a successful save acknowledgement. +fn finish_window( + waiting: &mut Option<(u64, HashSet, C)>, + window_id: WindowId, +) -> Option<(u64, C)> { + if let Some((_, windows, _)) = waiting.as_mut() { + windows.remove(&window_id); + } + if waiting + .as_ref() + .is_some_and(|(_, windows, _)| windows.is_empty()) + { + waiting + .take() + .map(|(request, _, completion)| (request, completion)) + } else { + None + } +} + +/// Destruction is acceptable for best-effort user exit, but it cannot satisfy +/// the installer's durable-save requirement. +pub(super) fn destroyed( + waiting: &mut Option<(u64, HashSet, C)>, + window_id: WindowId, + require_saved: bool, +) -> Report { + let Some((request, windows, _)) = waiting.as_ref() else { + return Report::Pending; + }; + if !windows.contains(&window_id) { + return Report::Pending; + } + if require_saved { + let request = *request; + waiting.take(); + return Report::Cancelled(request); + } + match finish_window(waiting, window_id) { + Some((request, completion)) => Report::Complete(request, completion), + None => Report::Pending, + } +} + +pub(super) fn report( + waiting: &mut Option<(u64, HashSet, C)>, + window_id: WindowId, + ready: bool, + require_saved: bool, +) -> Report { + let Some((request, windows, _)) = waiting.as_ref() else { + return Report::Pending; + }; + if !windows.contains(&window_id) { + return Report::Pending; + } + if !ready && require_saved { + let request = *request; + waiting.take(); + return Report::Cancelled(request); + } + match finish_window(waiting, window_id) { + Some((request, completion)) => Report::Complete(request, completion), + None => Report::Pending, + } +} + +pub(super) fn expire( + waiting: &mut Option<(u64, HashSet, C)>, + request: u64, +) -> Option<(u64, C)> { + if waiting.as_ref().is_some_and(|(id, _, _)| *id == request) { + waiting.take().map(|(id, _, completion)| (id, completion)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pending() -> Option<(u64, HashSet, i32)> { + Some((7, [11, 12].into(), 42)) + } + + #[test] + fn acknowledgements_complete_only_after_last_window() { + let mut waiting = pending(); + assert!(matches!( + report(&mut waiting, 11, true, false), + Report::Pending + )); + assert!(matches!( + report(&mut waiting, 12, true, false), + Report::Complete(7, 42) + )); + assert!(waiting.is_none()); + assert!(matches!( + report(&mut waiting, 12, true, false), + Report::Pending + )); + } + + #[test] + fn destruction_completes_only_after_last_pending_window() { + let mut waiting = pending(); + assert!(matches!( + destroyed(&mut waiting, 99, false), + Report::Pending + )); + assert!(matches!( + destroyed(&mut waiting, 12, false), + Report::Pending + )); + assert!(matches!( + destroyed(&mut waiting, 12, false), + Report::Pending + )); + assert!(matches!( + destroyed(&mut waiting, 11, false), + Report::Complete(7, 42) + )); + } + + #[test] + fn cancellation_does_not_produce_completion() { + let mut waiting: Option<(u64, HashSet, i32)> = None; + assert!(matches!( + destroyed(&mut waiting, 11, false), + Report::Pending + )); + } + #[test] + fn failed_save_cannot_veto_user_exit() { + let mut waiting = pending(); + assert!(matches!( + report(&mut waiting, 11, false, false), + Report::Pending + )); + assert!(matches!( + report(&mut waiting, 12, false, false), + Report::Complete(7, 42) + )); + assert!(waiting.is_none()); + } + + #[test] + fn updater_still_requires_durable_documents() { + let mut waiting = pending(); + assert!(matches!( + report(&mut waiting, 11, false, true), + Report::Cancelled(7) + )); + assert!(waiting.is_none()); + } + + #[test] + fn destroyed_updater_participant_cancels_instead_of_approving_update() { + let mut waiting = pending(); + assert!(matches!( + destroyed(&mut waiting, 11, true), + Report::Cancelled(7) + )); + assert!(waiting.is_none()); + } + + #[test] + fn stale_timeout_cannot_end_new_request() { + let mut waiting = pending(); + assert_eq!(expire(&mut waiting, 6), None); + assert!(waiting.is_some()); + assert_eq!(expire(&mut waiting, 7), Some((7, 42))); + assert_eq!(expire(&mut waiting, 7), None); + } + + #[test] + fn unregistered_report_cannot_cancel_update() { + let mut waiting = pending(); + assert!(matches!( + report(&mut waiting, 99, false, true), + Report::Pending + )); + assert!(waiting.is_some()); + } + + #[test] + fn repeated_user_exit_keeps_original_save_request_pending() { + let mut waiting = Some((7, [11].into(), true)); + assert!(matches!( + prepare_user_exit(&mut waiting, |is_exit| *is_exit), + ExitRequest::AlreadyPending + )); + assert_eq!(waiting, Some((7, [11].into(), true))); + } + + #[test] + fn user_exit_supersedes_updater_request_without_losing_its_identity() { + let mut waiting = Some((7, [11].into(), false)); + match prepare_user_exit(&mut waiting, |is_exit| *is_exit) { + ExitRequest::Start { cancelled } => { + assert_eq!(cancelled, Some((7, false))) + } + ExitRequest::AlreadyPending => panic!("updater request should be superseded"), + } + assert!(waiting.is_none()); + } +} diff --git a/app/flowix-desktop/src/config/access.rs b/app/flowix-desktop/src/config/access.rs deleted file mode 100644 index 4dbbf862d..000000000 --- a/app/flowix-desktop/src/config/access.rs +++ /dev/null @@ -1,420 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::RwLock; - -use flowix_core::memo_file::{MemoFile, NotebookConfig}; -use serde::{Deserialize, Serialize}; - -use crate::config::user::{atomic_write_json, UserConfigError}; - -pub const AGENT_ACCESS_FILE_NAME: &str = "agent-access.json"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgentAccessKind { - Notebook, - Folder, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAccessEntry { - pub id: String, - pub kind: AgentAccessKind, - pub path: String, - pub name: String, - pub enabled: bool, - /// Legacy field; notebook workspace ownership now lives in `.flowix`. - #[serde(default, skip_serializing)] - pub workspace: bool, - pub added_at: i64, - pub updated_at: i64, - #[serde(default, skip_deserializing)] - pub missing: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAccessConfig { - pub version: u32, - #[serde(default)] - pub entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub defaults: Option, -} - -impl Default for AgentAccessConfig { - fn default() -> Self { - Self { - version: 1, - entries: Vec::new(), - defaults: None, - } - } -} - -pub struct AgentAccessStore { - config_dir: PathBuf, - inner: RwLock, -} - -impl AgentAccessStore { - pub fn new(config_dir: PathBuf, memo_file: &MemoFile) -> Self { - let mut config: AgentAccessConfig = read_from_disk(&config_dir) - .ok() - .flatten() - .unwrap_or_default(); - - match config.version { - 1 => {} - other => tracing::warn!( - "agent-access.json version {} is unknown, treating as v1", - other - ), - } - - let notebook_configs = memo_file.read_notebook_configs().unwrap_or_default(); - let pre_reconcile_entries = config.entries.clone(); - reconcile_with_notebook_configs(&mut config.entries, ¬ebook_configs); - let is_dirty = !entries_semantically_equal(&pre_reconcile_entries, &config.entries); - - let store = Self { - config_dir, - inner: RwLock::new(config), - }; - if is_dirty { - if let Err(e) = store.persist() { - tracing::warn!("failed to persist agent_access on startup: {e}"); - } else { - tracing::debug!( - "agent_access reconciled on startup; entries count changed: {} -> {}", - pre_reconcile_entries.len(), - store.get_config().entries.len(), - ); - } - } - store - } - - pub fn get_config(&self) -> AgentAccessConfig { - let guard = self.inner.read().unwrap_or_else(|p| p.into_inner()); - let mut config = guard.clone(); - refresh_missing_flags(&mut config.entries); - config - } - - pub fn replace_config( - &self, - mut config: AgentAccessConfig, - ) -> Result { - // `missing` is derived from disk and must never trust a stale Webview - // snapshot. Recompute before both persistence and the in-memory swap. - refresh_missing_flags(&mut config.entries); - let content = serde_json::to_string_pretty(&config)?; - let path = self.config_dir.join(AGENT_ACCESS_FILE_NAME); - atomic_write_json(&path, &content)?; - let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner()); - *guard = config.clone(); - Ok(config) - } - - pub fn add_or_update_notebook(&self, nb: &NotebookConfig) -> bool { - let now = chrono::Utc::now().timestamp_millis(); - let trimmed_path = trim_path(&nb.path); - let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner()); - let mut changed = false; - - match guard - .entries - .iter_mut() - .find(|e| e.kind == AgentAccessKind::Notebook && e.id == nb.id) - { - Some(entry) => { - if entry.path != trimmed_path || entry.name != nb.name { - entry.path = trimmed_path; - entry.name = nb.name.clone(); - entry.updated_at = now; - changed = true; - } - } - None => { - guard.entries.push(AgentAccessEntry { - id: nb.id.clone(), - kind: AgentAccessKind::Notebook, - path: trimmed_path, - name: nb.name.clone(), - enabled: true, - workspace: false, - added_at: now, - updated_at: now, - missing: false, - }); - changed = true; - } - } - - if changed { - let _ = self.persist_locked(&guard); - } - changed - } - - pub fn remove_notebook(&self, notebook_id: &str) -> bool { - let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner()); - let before = guard.entries.len(); - guard - .entries - .retain(|e| !(e.kind == AgentAccessKind::Notebook && e.id == notebook_id)); - let removed = guard.entries.len() != before; - if removed { - let _ = self.persist_locked(&guard); - } - removed - } - - fn persist(&self) -> Result<(), UserConfigError> { - let guard = self.inner.read().unwrap_or_else(|p| p.into_inner()); - self.persist_locked(&guard) - } - - fn persist_locked(&self, config: &AgentAccessConfig) -> Result<(), UserConfigError> { - let content = serde_json::to_string_pretty(config)?; - let path = self.config_dir.join(AGENT_ACCESS_FILE_NAME); - Ok(atomic_write_json(&path, &content)?) - } -} - -fn read_from_disk(config_dir: &Path) -> std::io::Result> { - let path = config_dir.join(AGENT_ACCESS_FILE_NAME); - if !path.exists() { - return Ok(None); - } - let content = std::fs::read_to_string(&path)?; - if content.trim().is_empty() { - return Ok(None); - } - let cfg: AgentAccessConfig = serde_json::from_str(&content).unwrap_or_default(); - Ok(Some(cfg)) -} - -fn reconcile_with_notebook_configs( - entries: &mut Vec, - notebooks: &[NotebookConfig], -) { - let now = chrono::Utc::now().timestamp_millis(); - let registry_ids: std::collections::HashSet<&str> = - notebooks.iter().map(|n| n.id.as_str()).collect(); - - entries.retain(|e| { - !(e.kind == AgentAccessKind::Notebook && !registry_ids.contains(e.id.as_str())) - }); - - for nb in notebooks { - let trimmed_path = trim_path(&nb.path); - match entries - .iter_mut() - .find(|e| e.kind == AgentAccessKind::Notebook && e.id == nb.id) - { - Some(entry) => { - if entry.path != trimmed_path { - entry.path = trimmed_path.clone(); - entry.updated_at = now; - } - if entry.name != nb.name { - entry.name = nb.name.clone(); - entry.updated_at = now; - } - } - None => { - entries.push(AgentAccessEntry { - id: nb.id.clone(), - kind: AgentAccessKind::Notebook, - path: trimmed_path, - name: nb.name.clone(), - enabled: true, - workspace: false, - added_at: now, - updated_at: now, - missing: false, - }); - } - } - } -} - -fn entries_semantically_equal(a: &[AgentAccessEntry], b: &[AgentAccessEntry]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter().zip(b.iter()).all(|(x, y)| { - x.id == y.id - && x.kind == y.kind - && x.path == y.path - && x.name == y.name - && x.enabled == y.enabled - }) -} - -fn trim_path(path: &str) -> String { - path.trim_end_matches(|c| c == '/' || c == '\\').to_string() -} - -fn refresh_missing_flags(entries: &mut [AgentAccessEntry]) { - for entry in entries { - entry.missing = !Path::new(&entry.path).is_dir(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn nb(id: &str, name: &str, path: &str) -> NotebookConfig { - NotebookConfig { - id: id.to_string(), - name: name.to_string(), - icon: Some("馃摌".to_string()), - path: path.to_string(), - is_default: false, - sort: 0, - created_at: 0, - updated_at: 0, - } - } - - fn entry( - id: &str, - kind: AgentAccessKind, - path: &str, - name: &str, - enabled: bool, - updated_at: i64, - ) -> AgentAccessEntry { - AgentAccessEntry { - id: id.to_string(), - kind, - path: path.to_string(), - name: name.to_string(), - enabled, - workspace: false, - added_at: 1, - updated_at, - missing: false, - } - } - - #[test] - fn seed_populates_from_notebook_configs() { - let mut entries = Vec::new(); - let notebooks = vec![ - nb("nb_1", "First", "/tmp/a/"), - nb("nb_2", "Second", "/tmp/b/"), - ]; - - reconcile_with_notebook_configs(&mut entries, ¬ebooks); - - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].path, "/tmp/a"); - assert!(entries.iter().all(|entry| entry.enabled)); - } - - #[test] - fn reconcile_removes_deleted_notebooks() { - let mut entries = vec![ - entry("nb_1", AgentAccessKind::Notebook, "/a", "A", true, 1), - entry("nb_2", AgentAccessKind::Notebook, "/b", "B", true, 1), - entry("fld_1", AgentAccessKind::Folder, "/c", "C", true, 1), - ]; - let notebooks = vec![nb("nb_1", "A", "/a")]; - - reconcile_with_notebook_configs(&mut entries, ¬ebooks); - - assert_eq!(entries.len(), 2); - assert!(entries.iter().any(|entry| entry.id == "nb_1")); - assert!(entries.iter().any(|entry| entry.id == "fld_1")); - } - - #[test] - fn entries_semantically_equal_ignores_updated_at_and_workspace() { - let a = vec![entry( - "nb_1", - AgentAccessKind::Notebook, - "/a", - "A", - true, - 100, - )]; - let mut b = a.clone(); - b[0].updated_at = 200; - b[0].workspace = true; - - assert!(entries_semantically_equal(&a, &b)); - } - - #[test] - fn entries_semantically_equal_detects_path_and_enabled_changes() { - let a = vec![entry( - "nb_1", - AgentAccessKind::Notebook, - "/old", - "A", - true, - 100, - )]; - let mut b = a.clone(); - b[0].path = "/new".to_string(); - assert!(!entries_semantically_equal(&a, &b)); - - let mut c = a.clone(); - c[0].enabled = false; - assert!(!entries_semantically_equal(&a, &c)); - } - - #[test] - fn config_defaults_round_trip_shape() { - let defaults = serde_json::json!({ - "runtime": { "deepseek-harness": { "model": "gpt-5" } }, - "files": { "workspacePaths": ["/tmp"] } - }); - let cfg = AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(defaults.clone()), - }; - - let encoded = serde_json::to_string(&cfg).unwrap(); - let decoded: AgentAccessConfig = serde_json::from_str(&encoded).unwrap(); - - assert_eq!(decoded.defaults, Some(defaults)); - } - - #[test] - fn refresh_missing_flags_tracks_directories_on_disk() { - let temp = tempfile::tempdir().unwrap(); - let present = temp.path().join("present"); - std::fs::create_dir_all(&present).unwrap(); - let absent = temp.path().join("absent"); - let mut entries = vec![ - entry( - "present", - AgentAccessKind::Folder, - &present.to_string_lossy(), - "Present", - true, - 1, - ), - entry( - "absent", - AgentAccessKind::Folder, - &absent.to_string_lossy(), - "Absent", - true, - 1, - ), - ]; - - refresh_missing_flags(&mut entries); - - assert!(!entries[0].missing); - assert!(entries[1].missing); - } -} diff --git a/app/flowix-desktop/src/config/mod.rs b/app/flowix-desktop/src/config/mod.rs index d4c2a557e..1e2e7cb45 100644 --- a/app/flowix-desktop/src/config/mod.rs +++ b/app/flowix-desktop/src/config/mod.rs @@ -1,43 +1,20 @@ -//! Configuration & access-control layer for the agent runtime. +//! Configuration and access-control layer for the agent runtime. //! -//! Four closely related concerns live here: -//! -//! - [`user`] 鈥?user-level settings: legacy AI migration data, preferences -//! (json), theme, agent persona. Legacy data is persisted at -//! `~/.flowix/agent-config.toml`; active DSH settings use its own YAML file. -//! Owns the `atomic_write_json` -//! helper used by sibling stores. -//! - [`access`] 鈥?agent-access registry: which folders + notebooks the AI -//! agent is allowed to see. Persisted at `~/.flowix/agent-access.json`. -//! Distinct from the notebook registry so users can grant/revoke AI access -//! without touching the notebook registry itself. -//! - [`path_scope`] 鈥?tiny pure helper: is `path` inside `root`? Used by -//! access checks, dialog code, and security-bookmark filtering. -//! - [`security_bookmark`] 鈥?macOS security-scoped bookmarks for -//! user-selected directories that survive across launches. Sibling of -//! `user`/`access` but only meaningful on macOS; on other platforms the -//! store is still constructed but never gains entries. -//! -//! All four write through `user::atomic_write_json` so disk failures never -//! leave the in-memory state ahead of disk. +//! `user` stores user-level preferences; `db/local.db` stores notebook mounts, +//! and `path_scope` provides the shared path containment checks. -pub mod access; pub mod path_scope; pub mod property_presets; pub mod security_bookmark; pub mod user; -// Re-export the public surface at the `config::` namespace so callers can -// write `crate::config::UserConfigStore` without dropping into `user`. -pub use access::{AgentAccessConfig, AgentAccessEntry, AgentAccessKind, AgentAccessStore}; pub use path_scope::{path_is_inside, path_is_inside_reserved_directory}; pub use property_presets::{PropertyPresetChange, PropertyPresetRegistry, PropertyPresetStore}; -#[cfg(target_os = "macos")] -pub use security_bookmark::pick_directory_with_bookmark; pub use security_bookmark::SecurityBookmarkStore; pub use user::{ - dsh_credential_ref_for_route, AiConfigFile, AiModelConfig, AiModelEntry, PreferenceFile, Theme, - UserConfigError, UserConfigStore, DSH_SETTINGS_FILE_NAME, + dsh_credential_ref_for_route, AiConfigFile, AiModelConfig, AiModelEntry, + CloudDevRefreshTokenStore, PreferenceFile, Theme, UserConfigError, UserConfigStore, + DSH_SETTINGS_FILE_NAME, }; pub(crate) use user::{ merge_harness_provider, DeepSeekHarnessProviderSettings, DeepSeekHarnessSettingsFile, diff --git a/app/flowix-desktop/src/config/property_presets.rs b/app/flowix-desktop/src/config/property_presets.rs index 48d61178d..572fd4ac1 100644 --- a/app/flowix-desktop/src/config/property_presets.rs +++ b/app/flowix-desktop/src/config/property_presets.rs @@ -149,7 +149,7 @@ impl PropertyPresetStore { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - let mut connection = Connection::open(&self.path).map_err(|error| error.to_string())?; + let connection = Connection::open(&self.path).map_err(|error| error.to_string())?; connection .busy_timeout(Duration::from_secs(10)) .map_err(|error| error.to_string())?; @@ -158,8 +158,8 @@ impl PropertyPresetStore { "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", ) .map_err(|error| error.to_string())?; - migrate_legacy_schema(&mut connection)?; - connection.execute_batch( + let migration_connection = connection; + migration_connection.execute_batch( "CREATE TABLE IF NOT EXISTS property_presets ( scope TEXT NOT NULL CHECK(scope IN ('system', 'user', 'deleted_user')), id TEXT NOT NULL, @@ -188,25 +188,30 @@ impl PropertyPresetStore { completed_at INTEGER NOT NULL );", ).map_err(|error| error.to_string())?; - let has_position = { - let mut statement = connection - .prepare("PRAGMA table_info(property_presets)") - .map_err(|error| error.to_string())?; - let columns = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(|error| error.to_string())? - .collect::, _>>() - .map_err(|error| error.to_string())?; - columns.iter().any(|column| column == "position") - }; - if !has_position { - connection - .execute_batch( - "ALTER TABLE property_presets ADD COLUMN position INTEGER NOT NULL DEFAULT 0", - ) - .map_err(|error| error.to_string())?; + Ok(migration_connection) + } + + /// Apply legacy SQLite shape changes from the explicit startup + /// initialization phase. Normal reads only open the current schema. + pub fn initialize_legacy_schema(&self) -> Result<(), String> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - Ok(connection) + let mut connection = Connection::open(&self.path).map_err(|error| error.to_string())?; + connection + .busy_timeout(Duration::from_secs(10)) + .map_err(|error| error.to_string())?; + connection + .execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", + ) + .map_err(|error| error.to_string())?; + flowix_core::memo_file::initialize_local_migration_table(&mut connection) + .map_err(|error| error.to_string())?; + migrate_legacy_schema(&mut connection)?; + drop(connection); + let _ = self.open()?; + Ok(()) } pub fn get(&self) -> Result { @@ -405,8 +410,6 @@ impl PropertyPresetStore { if change.field.key.trim().is_empty() || [ "flowix_property_types", - "flowix_key", - "key", "flowix_plugin", ] .contains(&change.field.key.as_str()) @@ -471,6 +474,7 @@ impl PropertyPresetStore { /// Import the former preference.json registry once. Existing DB data wins. /// The marker and imported rows are committed atomically. pub fn migrate_legacy_once(&self, registry: &PropertyPresetRegistry) -> Result { + self.initialize_legacy_schema()?; let mut connection = self.open()?; let transaction = connection .transaction_with_behavior(TransactionBehavior::Immediate) diff --git a/app/flowix-desktop/src/config/security_bookmark.rs b/app/flowix-desktop/src/config/security_bookmark.rs index 37c01ee94..7a3858afc 100644 --- a/app/flowix-desktop/src/config/security_bookmark.rs +++ b/app/flowix-desktop/src/config/security_bookmark.rs @@ -1,7 +1,7 @@ -//! macOS security-scoped bookmarks for user-selected directories. +//! macOS security-scoped bookmarks for authorized directories and selected files. //! -//! The app stores bookmark data separately from notebook / agent-access config -//! because both features can point at the same directory. +//! OS grants are persisted separately from Flowix notebook and document grants. +//! A file bookmark never authorizes its parent directory. use std::collections::BTreeMap; use std::io; @@ -13,7 +13,14 @@ use serde::{Deserialize, Serialize}; use crate::config::user::{atomic_write_json, UserConfigError}; const BOOT_DIR_NAME: &str = "boot"; -const BOOKMARKS_FILE_NAME: &str = "security-bookmarks.json"; + +fn bookmarks_file_name() -> &'static str { + if option_env!("FLOWIX_DEV_MODE") == Some("1") { + "bookmark.dev.json" + } else { + "bookmark.json" + } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -21,9 +28,11 @@ struct BookmarkEntry { path: String, bookmark: String, updated_at: i64, + #[serde(default = "directory_default")] + directory: bool, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct BookmarkConfig { #[serde(default = "default_version")] @@ -32,14 +41,33 @@ struct BookmarkConfig { entries: BTreeMap, } +impl Default for BookmarkConfig { + fn default() -> Self { + Self { + version: 1, + entries: BTreeMap::new(), + } + } +} + +fn directory_default() -> bool { + true +} + fn default_version() -> u32 { 1 } fn normalize_path(path: &Path) -> String { - path.to_string_lossy() - .trim_end_matches(|c| c == '/' || c == '\\') - .to_string() + let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let normalized = path.to_string_lossy().to_string(); + if normalized.len() > 1 { + normalized + .trim_end_matches(|c| c == '/' || c == '\\') + .to_string() + } else { + normalized + } } pub struct SecurityBookmarkStore { @@ -56,7 +84,6 @@ impl SecurityBookmarkStore { .ok() .flatten() .unwrap_or_default(); - migrate_legacy_bookmarks_if_needed(&config_dir, &config); let store = Self { config_dir, inner: RwLock::new(config), @@ -86,12 +113,82 @@ impl SecurityBookmarkStore { Ok(()) } + pub fn record_file(&self, path: &Path) -> Result<(), UserConfigError> { + #[cfg(target_os = "macos")] + { + let url = objc2_foundation::NSURL::from_file_path(path).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid selected file URL") + })?; + let bookmark = macos::bookmark_for_url(&url).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "cannot bookmark selected file", + ) + })?; + self.upsert(path, bookmark, false)?; + self.start_accessing_path(path); + } + #[cfg(not(target_os = "macos"))] + let _ = path; + Ok(()) + } + + /// Called only for a directory already authorized by Flowix. Successful + /// enumeration means macOS has allowed access (including a TCC prompt). + pub fn record_accessible_directory(&self, path: &Path) { + #[cfg(target_os = "macos")] + { + if self.start_accessing_for_path(path) { + return; + } + if std::fs::read_dir(path).is_ok() { + if let Err(error) = self.record_directory(path) { + tracing::warn!(%error, path = %path.display(), "cannot persist granted directory access"); + } + } + } + #[cfg(not(target_os = "macos"))] + let _ = path; + } + + #[cfg(target_os = "macos")] + fn refresh_stale(&self, old_path: &str, directory: bool, access: &macos::ScopedAccess) { + if !access.is_stale() { + return; + } + let Some(bookmark) = access.refreshed_bookmark() else { + return; + }; + let mut current = self.inner.write().unwrap_or_else(|p| p.into_inner()); + let mut next = current.clone(); + next.entries.remove(old_path); + let path = access.path_key().to_string(); + next.entries.insert( + path.clone(), + BookmarkEntry { + path, + bookmark, + directory, + updated_at: chrono::Utc::now().timestamp_millis(), + }, + ); + match serde_json::to_string_pretty(&next) { + Ok(content) => { + match atomic_write_json(&bookmarks_file_path(&self.config_dir), &content) { + Ok(()) => *current = next, + Err(error) => tracing::warn!(%error, "cannot refresh stale bookmark"), + } + } + Err(error) => tracing::warn!(%error, "cannot serialize refreshed bookmark"), + } + } + pub fn record_directory_bookmark( &self, path: &Path, bookmark: String, ) -> Result<(), UserConfigError> { - self.upsert(path, bookmark)?; + self.upsert(path, bookmark, true)?; self.start_accessing_path(path); Ok(()) } @@ -111,7 +208,10 @@ impl SecurityBookmarkStore { active.clear(); for entry in entries { match macos::resolve_bookmark(&entry.bookmark) { - Some(access) => active.push(access), + Some(access) => { + self.refresh_stale(&entry.path, entry.directory, &access); + active.push(access); + } None => tracing::warn!( "[security_bookmark] failed to resolve bookmark for {}", entry.path @@ -158,10 +258,27 @@ impl SecurityBookmarkStore { #[cfg(target_os = "macos")] fn activate_bookmark(&self, key: String, bookmark: String, requested_path: &Path) -> bool { + if self + .active + .read() + .is_ok_and(|active| active.iter().any(|existing| existing.path_key() == key)) + { + return true; + } if let Some(access) = macos::resolve_bookmark(&bookmark) { + let directory = self + .inner + .read() + .unwrap_or_else(|p| p.into_inner()) + .entries + .get(&key) + .map(|entry| entry.directory) + .unwrap_or(true); + self.refresh_stale(&key, directory, &access); let mut active = self.active.write().unwrap_or_else(|p| p.into_inner()); - active.retain(|existing| existing.path_key() != key); - active.push(access); + if !active.iter().any(|existing| existing.path_key() == key) { + active.push(access); + } return true; } tracing::warn!( @@ -179,64 +296,48 @@ impl SecurityBookmarkStore { .unwrap_or_else(|p| p.into_inner()) .entries .iter() - .filter(|(_, entry)| super::path_scope::path_is_inside(path, Path::new(&entry.path))) + .filter(|(_, entry)| { + if entry.directory { + super::path_scope::path_is_inside(path, Path::new(&entry.path)) + } else { + normalize_path(path) == entry.path + } + }) .max_by_key(|(key, _)| key.len()) .map(|(key, entry)| (key.clone(), entry.bookmark.clone())) } - fn upsert(&self, path: &Path, bookmark: String) -> Result<(), UserConfigError> { + fn upsert( + &self, + path: &Path, + bookmark: String, + directory: bool, + ) -> Result<(), UserConfigError> { let key = normalize_path(path); - let mut next = self.inner.read().unwrap_or_else(|p| p.into_inner()).clone(); + let mut current = self.inner.write().unwrap_or_else(|p| p.into_inner()); + let mut next = current.clone(); next.entries.insert( key, BookmarkEntry { path: normalize_path(path), bookmark, updated_at: chrono::Utc::now().timestamp_millis(), + directory, }, ); let content = serde_json::to_string_pretty(&next)?; atomic_write_json(&bookmarks_file_path(&self.config_dir), &content)?; - *self.inner.write().unwrap_or_else(|p| p.into_inner()) = next; + *current = next; Ok(()) } } fn bookmarks_file_path(config_dir: &Path) -> PathBuf { - config_dir.join(BOOT_DIR_NAME).join(BOOKMARKS_FILE_NAME) -} - -fn legacy_bookmarks_file_path(config_dir: &Path) -> PathBuf { - config_dir.join(BOOKMARKS_FILE_NAME) -} - -fn migrate_legacy_bookmarks_if_needed(config_dir: &Path, config: &BookmarkConfig) { - let path = bookmarks_file_path(config_dir); - if path.exists() || !legacy_bookmarks_file_path(config_dir).exists() { - return; - } - match serde_json::to_string_pretty(config) { - Ok(content) => { - if let Err(e) = atomic_write_json(&path, &content) { - tracing::warn!( - "[security_bookmark] failed to migrate bookmarks to {}: {e}", - path.display() - ); - } - } - Err(e) => tracing::warn!("[security_bookmark] failed to serialize bookmarks: {e}"), - } + config_dir.join(BOOT_DIR_NAME).join(bookmarks_file_name()) } fn read_from_disk(config_dir: &Path) -> std::io::Result> { - let path = bookmarks_file_path(config_dir); - if !path.exists() { - let legacy_path = legacy_bookmarks_file_path(config_dir); - if legacy_path.exists() { - return read_bookmark_config_file(&legacy_path); - } - } - read_bookmark_config_file(&path) + read_bookmark_config_file(&bookmarks_file_path(config_dir)) } fn read_bookmark_config_file(path: &Path) -> std::io::Result> { @@ -266,12 +367,21 @@ mod macos { url: Retained, path_key: String, active: bool, + stale: bool, } impl ScopedAccess { pub fn path_key(&self) -> &str { &self.path_key } + + pub fn refreshed_bookmark(&self) -> Option { + bookmark_for_url(&self.url) + } + + pub fn is_stale(&self) -> bool { + self.stale + } } impl Drop for ScopedAccess { @@ -284,6 +394,10 @@ mod macos { pub fn bookmark_for_directory(path: &Path) -> Option { let url = NSURL::from_directory_path(path)?; + bookmark_for_url(&url) + } + + pub fn bookmark_for_url(url: &NSURL) -> Option { let data = url .bookmarkDataWithOptions_includingResourceValuesForKeys_relativeToURL_error( NSURLBookmarkCreationOptions::WithSecurityScope, @@ -292,8 +406,8 @@ mod macos { ) .map_err(|e| { tracing::warn!( - "[security_bookmark] failed to create bookmark for {}: {:?}", - path.display(), + "[security_bookmark] failed to create bookmark for {:?}: {:?}", + url, e ); }) @@ -323,38 +437,20 @@ mod macos { tracing::warn!("[security_bookmark] resolved stale bookmark"); } let active = unsafe { url.startAccessingSecurityScopedResource() }; + if !active { + tracing::warn!("[security_bookmark] macOS refused scoped access for resolved bookmark"); + return None; + } let path = url.to_file_path().unwrap_or_else(PathBuf::new); Some(ScopedAccess { path_key: normalize_path(&path), url, active, + stale: stale.as_bool(), }) } } -#[cfg(target_os = "macos")] -pub fn pick_directory_with_bookmark(title: &str) -> Option<(String, String)> { - use objc2::MainThreadMarker; - use objc2_app_kit::{NSModalResponseOK, NSOpenPanel}; - - let mtm = MainThreadMarker::new()?; - let panel = NSOpenPanel::openPanel(mtm); - panel.setCanChooseDirectories(true); - panel.setCanChooseFiles(false); - panel.setCanCreateDirectories(true); - panel.setAllowsMultipleSelection(false); - let title = objc2_foundation::NSString::from_str(title); - panel.setTitle(Some(&title)); - let ok = panel.runModal(); - if ok != NSModalResponseOK { - return None; - } - let url = panel.URL()?; - let path = url.to_file_path()?; - let bookmark = macos::bookmark_for_directory(&path)?; - Some((path.to_string_lossy().to_string(), bookmark)) -} - #[cfg(test)] mod tests { use super::*; @@ -367,6 +463,7 @@ mod tests { path: "/tmp/flowix-bookmark-test".to_string(), bookmark: "bookmark-data".to_string(), updated_at: 1, + directory: true, }, ); config @@ -384,32 +481,4 @@ mod tests { assert_eq!(loaded.entries.len(), 1); assert!(loaded.entries.contains_key("/tmp/flowix-bookmark-test")); } - - #[test] - fn migrates_legacy_bookmarks_to_boot_dir() { - let tmp = tempfile::tempdir().unwrap(); - let config = sample_config(); - let content = serde_json::to_string_pretty(&config).unwrap(); - atomic_write_json(&legacy_bookmarks_file_path(tmp.path()), &content).unwrap(); - - migrate_legacy_bookmarks_if_needed(tmp.path(), &config); - - assert!(bookmarks_file_path(tmp.path()).exists()); - let migrated = read_bookmark_config_file(&bookmarks_file_path(tmp.path())) - .unwrap() - .unwrap(); - assert_eq!(migrated.entries.len(), 1); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_bookmark_round_trips() { - let tmp = tempfile::tempdir().unwrap(); - let bookmark = macos::bookmark_for_directory(tmp.path()) - .expect("a temporary directory should produce bookmark data"); - let access = macos::resolve_bookmark(&bookmark) - .expect("newly created bookmark data should resolve immediately"); - let canonical = std::fs::canonicalize(tmp.path()).unwrap(); - assert_eq!(access.path_key(), normalize_path(&canonical)); - } } diff --git a/app/flowix-desktop/src/config/user.rs b/app/flowix-desktop/src/config/user.rs index 2ab5934ec..187b6e6f0 100644 --- a/app/flowix-desktop/src/config/user.rs +++ b/app/flowix-desktop/src/config/user.rs @@ -3,21 +3,31 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::RwLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; +use super::property_presets::{PropertyPresetChange, PropertyPresetRegistry, PropertyPresetStore}; use crate::USER_CONFIG_DIR_NAME; use flowix_core::secret::{entry_name, SecretStore}; -use super::property_presets::{PropertyPresetChange, PropertyPresetRegistry, PropertyPresetStore}; /// AI 模型配置文件�?── TOML 格式, 便于人手编辑与注释�?/// /// TOML 格式便于用户手改磁盘配置时写注释 (TOML 原生 `# ...`), 避免�?��字�?�?/// �?Flowix 的其它配�?���?(`boot/preference.json` / -/// `boot/system.json` / `index.db`) 鍖哄垎寰楁洿鏄剧溂 +/// `boot/system.json` / `local.db`) 鍖哄垎寰楁洿鏄剧溂 /// (TOML 格式 + 显式 `agent-` 前缀, 不会出现"�?��文件该用 JSON"的�?�? pub const AI_CONFIG_FILE_NAME: &str = "agent-config.toml"; /// DeepSeek Harness uses its official user-global home (`~/.dsh`). Flowix /// owns a profile below that home, but does not introduce a second harness /// root below `~/.flowix`. pub const DSH_DIR_NAME: &str = ".dsh"; + +/// Resolve the official DSH home for both profile installation and runtime IO. +pub(crate) fn resolve_dsh_home(home: &Path) -> PathBuf { + std::env::var_os("DSH_HOME") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(DSH_DIR_NAME)) +} + pub const DSH_SESSIONS_DIR_NAME: &str = "sessions"; pub const DSH_SETTINGS_FILE_NAME: &str = "settings.yaml"; pub const DSH_PLUGIN_SETTINGS_FILE_NAME: &str = "plugin-settings.json"; @@ -27,6 +37,8 @@ const PREFERENCE_FILE_NAME: &str = "preference.json"; const DEFAULT_SECRET_DB_NAME: &str = "default.db"; const SECRET_ACCOUNT_NAME: &str = "default"; const CLOUD_SECRET_PROVIDER: &str = "flowix_cloud_refresh"; +const CLOUD_DEV_STAGING_SECRET_PROVIDER: &str = "flowix_cloud_dev_staging_refresh"; +const CLOUD_DEV_PRODUCTION_SECRET_PROVIDER: &str = "flowix_cloud_dev_production_refresh"; /// ~/.flowix/boot/preference.json —用户偏好设置 /// 瀛楁鍏ㄩ儴 #[serde(default)], 鏂囦欢鎹熷潖鎴栫己澶辨椂鍥為€€鍒伴粯璁ゅ€笺€? @@ -179,6 +191,13 @@ pub struct PreferenceFile { /// Installation region detected by the frontend on first launch. #[serde(default)] pub region: String, + /// Explicit path to the user's DSH CLI, when automatic discovery is insufficient. + #[serde(default)] + pub dsh_cli_path: Option, + /// Saved Cloud target name for development builds. Unknown legacy values + /// fall back to the build default; release builds always use production. + #[serde(default)] + pub cloud_environment: String, /// Memo list card presentation ("detailed" | "compact"). #[serde(default)] pub memo_card_variant: String, @@ -219,6 +238,8 @@ impl Default for PreferenceFile { theme: Theme::default(), language: String::default(), region: String::default(), + dsh_cli_path: None, + cloud_environment: String::default(), memo_card_variant: String::default(), memo_list_view: String::default(), auto_open_created_notes_in_browser: true, @@ -586,7 +607,7 @@ pub struct UserConfigStore { ai_config: RwLock, secrets: SecretStore, property_presets: PropertyPresetStore, - property_presets_migration_complete: bool, + property_presets_migration_complete: Arc, } /// 用户配置 (boot/preference.json / agent-config.toml) 写盘错�?。`Io` �?���?/// `std::io::Error` �? `Json` �?`serde_json::Error` �?(preference.json @@ -640,10 +661,11 @@ impl UserConfigStore { } pub fn new(home_dir: PathBuf) -> Self { - // 鍑嵁 db 钀藉湪 config_dir/default.db (鐢熶骇鐜 ~/.flowix/default.db), - // �?index.db 同目�?── �?0o700 �?�� + 0o600 文件权限保护�? + // 凭据数据库位于 config_dir/db/default.db (生产环境 ~/.flowix/db/default.db), + // �?local.db 同目�?── �?0o700 �?�� + 0o600 文件权限保护�? let db_path = home_dir .join(USER_CONFIG_DIR_NAME) + .join("db") .join(DEFAULT_SECRET_DB_NAME); Self::new_with_secret_store(home_dir, SecretStore::new(db_path)) } @@ -653,39 +675,19 @@ impl UserConfigStore { let _ = fs::create_dir_all(&config_dir); // Restrict the configuration directory to its owner. set_dir_owner_only_perms(&config_dir); - migrate_dsh_layout(&config_dir); - - let mut preference = Self::read_preference_from_disk(&config_dir).unwrap_or_default(); - let had_legacy_property_registry = !preference.properties.system_fields.is_empty() - || !preference.properties.fields.is_empty() - || !preference.properties.deleted_fields.is_empty(); - let property_presets = PropertyPresetStore::new(config_dir.join("local.db")); - let property_presets_migration_complete = match property_presets.migrate_legacy_once(&PropertyPresetRegistry { - system_fields: preference.properties.system_fields.clone(), - fields: preference.properties.fields.clone(), - deleted_fields: preference.properties.deleted_fields.clone(), - }).and_then(|migrated| property_presets.initialize_system_presets().map(|_| migrated)) { - Ok(_) if had_legacy_property_registry => { - preference.properties = PropertiesConfig::default(); - match preference_json_without_property_registry(&preference) { - Ok(content) => if let Err(error) = atomic_write_json(&preference_file_path(&config_dir), &content) { - tracing::warn!(%error, "property presets were migrated, but legacy preference data could not be removed"); - }, - Err(error) => tracing::warn!(%error, "property presets were migrated, but legacy preference data could not be serialized"), - } - true - } - Ok(_) => { preference.properties = PropertiesConfig::default(); true } - Err(error) => { - tracing::error!(%error, "property preset migration failed; legacy preference data was retained on disk"); - false - } - }; + let preference = flowix_core::memo_file::read_or_create_json( + &preference_file_path(&config_dir), + PreferenceFile::default, + ) + .unwrap_or_else(|error| { + tracing::warn!(%error, "preferences unavailable; using defaults"); + PreferenceFile::default() + }); + let property_presets = PropertyPresetStore::new(config_dir.join("db").join("local.db")); // Once the official DSH settings document exists, do not even load - // the legacy TOML into the process. This makes the source-of-truth - // boundary explicit: agent-config.toml is read only during the - // one-time migration performed by - // read_or_migrate_deepseek_harness_settings(). + // the legacy TOML into the compatibility accessor. If it does not + // exist, this accessor may still expose the old standalone AI config; + // DSH settings reads themselves never migrate from it. let ai_config = if dsh_settings_path(&config_dir).is_file() { AiConfigFile::default() } else { @@ -697,8 +699,92 @@ impl UserConfigStore { ai_config: RwLock::new(ai_config), secrets, property_presets, - property_presets_migration_complete, + property_presets_migration_complete: Arc::new(AtomicBool::new(false)), + } + } + + /// Run local configuration imports during the explicit version + /// initialization phase, before settings IPC is enabled. + pub fn initialize_legacy_config_migrations(&self) -> Result<(), String> { + self.with_runtime_storage_rebuild(|| self.initialize_legacy_config_migrations_inner()) + } + + fn initialize_legacy_config_migrations_inner(&self) -> Result<(), String> { + let mut preference = self.write_preference(); + let had_legacy_properties = !preference.properties.system_fields.is_empty() + || !preference.properties.fields.is_empty() + || !preference.properties.deleted_fields.is_empty(); + let registry = PropertyPresetRegistry { + system_fields: preference.properties.system_fields.clone(), + fields: preference.properties.fields.clone(), + deleted_fields: preference.properties.deleted_fields.clone(), + }; + self.property_presets + .migrate_legacy_once(®istry) + .and_then(|_| self.property_presets.initialize_system_presets()) + .map_err(|error| format!("initialize property presets failed: {error}"))?; + if had_legacy_properties { + preference.properties = PropertiesConfig::default(); + let content = preference_json_without_property_registry(&preference) + .map_err(|error| format!("serialize cleaned preferences failed: {error}"))?; + atomic_write_json(&preference_file_path(&self.config_dir), &content) + .map_err(|error| format!("write cleaned preferences failed: {error}"))?; } + preference.properties = PropertiesConfig::default(); + Ok(()) + } + + /// Ensure current runtime defaults even when one-time migrations already ran. + /// Opening the store recreates a missing database/tables; existing presets + /// retain their identities and user values. + pub fn ensure_runtime_storage(&self) -> Result<(), String> { + self.with_runtime_storage_rebuild(|| { + self.property_presets.initialize_legacy_schema()?; + self.property_presets.initialize_system_presets() + }) + } + + fn with_runtime_storage_rebuild( + &self, + mut operation: impl FnMut() -> Result<(), String>, + ) -> Result<(), String> { + flowix_core::memo_file::with_database_rebuild(&self.config_dir.join("db/local.db"), || { + operation().map_err(|message| { + let invalid_data = [ + "no such", + "has no column", + "malformed", + "not a database", + "Invalid column", + "invalid column", + "datatype mismatch", + ] + .iter() + .any(|pattern| message.contains(pattern)); + std::io::Error::new( + if invalid_data { + std::io::ErrorKind::InvalidData + } else { + std::io::ErrorKind::Other + }, + message, + ) + }) + }) + .map_err(|error| error.to_string()) + } + + /// Restore process-local state after persistent migration completion. + /// This runs on every startup, including when migration jobs are skipped. + pub fn prepare_initialized_runtime(&self) { + self.write_preference().properties = PropertiesConfig::default(); + self.property_presets_migration_complete + .store(true, Ordering::Release); + } + + /// Migrate the old Flowix DSH directory layout in its own retryable step. + pub fn initialize_legacy_dsh_layout(&self) -> Result<(), String> { + migrate_dsh_layout(&self.config_dir) } #[allow(dead_code)] @@ -710,10 +796,11 @@ impl UserConfigStore { /// `profiles/flowix` namespace inside it and leaves plugin/profile state /// to the official DSH CLI. pub fn dsh_dir(&self) -> PathBuf { - self.config_dir - .parent() - .unwrap_or(self.config_dir.as_path()) - .join(DSH_DIR_NAME) + resolve_dsh_home( + self.config_dir + .parent() + .unwrap_or(self.config_dir.as_path()), + ) } pub fn dsh_settings_path(&self) -> PathBuf { @@ -766,17 +853,34 @@ impl UserConfigStore { self.read_preference().clone() } + pub fn has_legacy_watcher_exclusions(&self) -> bool { + let preference = self.read_preference(); + let defaults = crate::watcher::WhitelistConfig::default(); + preference.watcher.skip_dirs != defaults.skip_dirs + || preference.watcher.skip_files != defaults.skip_files + } + pub fn get_property_presets(&self) -> Result { - self.property_presets.get().map_err(UserConfigError::InvalidConfig) + self.property_presets + .get() + .map_err(UserConfigError::InvalidConfig) } - pub fn patch_property_presets(&self, changes: &[PropertyPresetChange]) -> Result { - self.property_presets.patch(changes).map_err(UserConfigError::InvalidConfig) + pub fn patch_property_presets( + &self, + changes: &[PropertyPresetChange], + ) -> Result { + self.property_presets + .patch(changes) + .map_err(UserConfigError::InvalidConfig) } /// 先把 JSON 落盘 (tmp + fsync + rename, 0o600), 成功后才更新内存�? /// 任一写�?骤失�?�?内存保持旧�? 磁盘保持旧文�? 不出�?内存新�?盘旧"�? /// "半写�?��"的损坏状态�? pub fn set_preference(&self, mut p: PreferenceFile) -> Result<(), UserConfigError> { - let content = if self.property_presets_migration_complete { + let content = if self + .property_presets_migration_complete + .load(Ordering::Acquire) + { p.properties = PropertiesConfig::default(); preference_json_without_property_registry(&p)? } else { @@ -789,18 +893,81 @@ impl UserConfigStore { Ok(()) } + /// Stage-2 defaults are process-local. Persisting them is background work. + pub fn prepare_runtime_watcher_exclusions(&self) { + let defaults = crate::watcher::WhitelistConfig::default(); + let mut preference = self.write_preference(); + preference.watcher.skip_dirs = defaults.skip_dirs; + preference.watcher.skip_files = defaults.skip_files; + } + + /// Clear the legacy global watcher name exclusions. New file-management + /// rules are notebook-local and are initialized from their current defaults. + pub fn clear_legacy_watcher_exclusions(&self) -> Result<(), UserConfigError> { + let mut preference = self.write_preference(); + let defaults = crate::watcher::WhitelistConfig::default(); + // Patch the raw document so unknown or newer preference fields survive + // this one-time cleanup as well. + let path = preference_file_path(&self.config_dir); + let mut document = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + serde_json::to_value(&*preference)? + } + Err(error) => return Err(error.into()), + }; + let root = document.as_object_mut().ok_or_else(|| { + UserConfigError::InvalidConfig("preference.json must contain a JSON object".into()) + })?; + let watcher = root + .entry("watcher") + .or_insert_with(|| serde_json::json!({})); + let watcher = watcher.as_object_mut().ok_or_else(|| { + UserConfigError::InvalidConfig( + "preference.json watcher must contain a JSON object".into(), + ) + })?; + if watcher.get("skip_dirs") == Some(&serde_json::to_value(&defaults.skip_dirs)?) + && watcher.get("skip_files") == Some(&serde_json::to_value(&defaults.skip_files)?) + { + preference.watcher.skip_dirs = defaults.skip_dirs; + preference.watcher.skip_files = defaults.skip_files; + return Ok(()); + } + watcher.insert( + "skip_dirs".into(), + serde_json::to_value(&defaults.skip_dirs)?, + ); + watcher.insert( + "skip_files".into(), + serde_json::to_value(&defaults.skip_files)?, + ); + let content = serde_json::to_string_pretty(&document)?; + atomic_write_json(&path, &content)?; + + preference.watcher.skip_dirs = defaults.skip_dirs; + preference.watcher.skip_files = defaults.skip_files; + Ok(()) + } + /// Merge a partial JSON preference patch with the current value under the /// preference lock, so separate windows cannot overwrite unrelated fields. pub fn patch_preference(&self, patch: serde_json::Value) -> Result<(), UserConfigError> { let mut preference = self.write_preference(); let legacy_properties = preference.properties.clone(); let mut value = serde_json::to_value(&*preference)?; - if self.property_presets_migration_complete { + if self + .property_presets_migration_complete + .load(Ordering::Acquire) + { value["properties"] = serde_json::to_value(PropertiesConfig::default())?; } merge_preference_patch(&mut value, patch); let mut next: PreferenceFile = serde_json::from_value(value)?; - let content = if self.property_presets_migration_complete { + let content = if self + .property_presets_migration_complete + .load(Ordering::Acquire) + { next.properties = PropertiesConfig::default(); preference_json_without_property_registry(&next)? } else { @@ -838,12 +1005,10 @@ impl UserConfigStore { config } - /// Load the DeepSeek Harness model configuration from the llm-pi-ai - /// settings document. Existing installations are migrated lazily from - /// agent-config.toml on first access; after that, the legacy document is - /// not consulted again. + /// Load DeepSeek Harness model configuration from the official settings + /// document. Missing settings start empty; retired Flowix TOML is ignored. pub fn get_deepseek_harness_config(&self) -> Result { - let settings = self.read_or_migrate_deepseek_harness_settings()?; + let settings = self.read_deepseek_harness_settings()?.unwrap_or_default(); Ok(settings.to_ai_config()) } @@ -852,7 +1017,7 @@ impl UserConfigStore { /// independent routes and their model cards carry different endpoints /// and credentials. pub fn get_deepseek_harness_configs(&self) -> Result, UserConfigError> { - let settings = self.read_or_migrate_deepseek_harness_settings()?; + let settings = self.read_deepseek_harness_settings()?.unwrap_or_default(); Ok(settings.to_ai_configs()) } @@ -918,24 +1083,6 @@ impl UserConfigStore { } } - /// Read the official DSH settings document, or create it once from the - /// legacy Flowix TOML when upgrading an older installation. After this - /// write succeeds, subsequent reads never consult `agent-config.toml`. - fn read_or_migrate_deepseek_harness_settings( - &self, - ) -> Result { - if let Some(settings) = self.read_deepseek_harness_settings()? { - return Ok(settings); - } - - let mut legacy = self.read_ai_config().clone(); - self.hydrate_ai_config_secrets(&mut legacy); - let settings = DeepSeekHarnessSettingsFile::from_ai_config(&legacy); - let content = serde_yaml::to_string(&settings)?; - atomic_write_yaml(&self.dsh_settings_path(), &content)?; - Ok(settings) - } - /// Render a standalone Harness settings document for a one-shot probe. /// /// A probe must be able to validate a model that has not been saved yet. @@ -1025,23 +1172,6 @@ impl UserConfigStore { .map_err(|err| UserConfigError::SecretStore(err.to_string())) } - /// Read a pre-DSH-owned credential for one route during the one-time - /// migration. New DSH settings code must not use this as a runtime source. - pub(crate) fn legacy_dsh_secret( - &self, - provider: &str, - ) -> Result, UserConfigError> { - let account = entry_name(provider, SECRET_ACCOUNT_NAME); - self.secrets - .load(&account) - .map(|value| value.map(|secret| secret.into_inner())) - .map_err(|err| UserConfigError::SecretStore(err.to_string())) - } - - pub(crate) fn delete_legacy_dsh_secret(&self, provider: &str) -> Result<(), UserConfigError> { - self.delete_provider_secret(provider) - } - pub fn save_cloud_refresh_token(&self, token: &str) -> Result<(), UserConfigError> { self.save_provider_secret(CLOUD_SECRET_PROVIDER, token) } @@ -1058,24 +1188,59 @@ impl UserConfigStore { self.delete_provider_secret(CLOUD_SECRET_PROVIDER) } - fn read_preference_from_disk(dir: &PathBuf) -> Option { - let path = preference_file_path(dir); - if !path.exists() { - return None; + fn cloud_dev_secret_provider(environment: flowix_sync::CloudEnvironment) -> &'static str { + match environment { + flowix_sync::CloudEnvironment::Staging => CLOUD_DEV_STAGING_SECRET_PROVIDER, + flowix_sync::CloudEnvironment::Prod => CLOUD_DEV_PRODUCTION_SECRET_PROVIDER, } - fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) + } + + pub fn save_cloud_dev_refresh_token( + &self, + environment: flowix_sync::CloudEnvironment, + token: &str, + ) -> Result<(), UserConfigError> { + self.save_provider_secret(Self::cloud_dev_secret_provider(environment), token) + } + + pub fn load_cloud_dev_refresh_token( + &self, + environment: flowix_sync::CloudEnvironment, + ) -> Result, UserConfigError> { + let provider = Self::cloud_dev_secret_provider(environment); + let account = entry_name(provider, SECRET_ACCOUNT_NAME); + self.secrets + .load(&account) + .map(|value| value.map(|secret| secret.into_inner())) + .map_err(|err| UserConfigError::SecretStore(err.to_string())) + } + + pub fn delete_cloud_dev_refresh_token( + &self, + environment: flowix_sync::CloudEnvironment, + ) -> Result<(), UserConfigError> { + self.delete_provider_secret(Self::cloud_dev_secret_provider(environment)) } fn read_ai_config_from_disk(dir: &PathBuf) -> Option { let path = dir.join(AI_CONFIG_FILE_NAME); - if !path.exists() { - return None; + match fs::read(&path) { + Ok(bytes) => match std::str::from_utf8(&bytes) + .ok() + .and_then(|text| toml::from_str(text).ok()) + { + Some(config) => return Some(config), + None => { + flowix_core::memo_file::backup_storage_file(&path).ok()?; + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return None, } - fs::read_to_string(&path) - .ok() - .and_then(|s| toml::from_str(&s).ok()) + let config = AiConfigFile::default(); + let text = toml::to_string_pretty(&config).ok()?; + flowix_core::memo_file::atomic_write_bytes(&path, text.as_bytes()).ok()?; + Some(config) } } @@ -1096,6 +1261,43 @@ impl flowix_sync::RefreshTokenStore for UserConfigStore { } } +pub struct CloudDevRefreshTokenStore { + user_config: std::sync::Arc, + environment: flowix_sync::CloudEnvironment, +} + +impl CloudDevRefreshTokenStore { + pub fn new( + user_config: std::sync::Arc, + environment: flowix_sync::CloudEnvironment, + ) -> Self { + Self { + user_config, + environment, + } + } +} + +impl flowix_sync::RefreshTokenStore for CloudDevRefreshTokenStore { + fn load(&self) -> Result, String> { + self.user_config + .load_cloud_dev_refresh_token(self.environment) + .map_err(|error| error.to_string()) + } + + fn save(&self, token: &str) -> Result<(), String> { + self.user_config + .save_cloud_dev_refresh_token(self.environment, token) + .map_err(|error| error.to_string()) + } + + fn delete(&self) -> Result<(), String> { + self.user_config + .delete_cloud_dev_refresh_token(self.environment) + .map_err(|error| error.to_string()) + } +} + fn merge_preference_patch(target: &mut serde_json::Value, patch: serde_json::Value) { match patch { serde_json::Value::Object(patch_fields) => { @@ -1155,7 +1357,9 @@ fn preference_file_path(config_dir: &Path) -> PathBuf { config_dir.join(BOOT_DIR_NAME).join(PREFERENCE_FILE_NAME) } -fn preference_json_without_property_registry(preference: &PreferenceFile) -> Result { +fn preference_json_without_property_registry( + preference: &PreferenceFile, +) -> Result { let mut value = serde_json::to_value(preference)?; if let Some(object) = value.as_object_mut() { object.remove("properties"); @@ -1172,7 +1376,7 @@ fn dsh_settings_path(config_dir: &Path) -> PathBuf { } /// 原子�?JSON: �?.tmp �?fsync �?0o600 �?rename 到目标�?/// 失败�?.tmp 残留由下次启动�?�? 不影响主文件�?/// -/// `pub(crate)` —`agent_access` 等同形态的 JSON 配置文件 (�?boot/preference.json) +/// `pub(crate)` —其他同形态的 JSON 配置文件 (�?boot/preference.json) /// 同目�? 复用这个落盘逻辑, 不�?制�?二份�? pub(crate) fn atomic_write_json(path: &Path, content: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { @@ -1241,89 +1445,134 @@ pub(crate) fn atomic_write_yaml(path: &Path, content: &str) -> std::io::Result<( /// `~/.dsh/` home without overwriting anything already created there. The /// old `~/.flowix/dsh/` directory is moved as one unit when possible; the /// older flat files are handled individually. Nothing is deleted. -fn migrate_dsh_layout(config_dir: &Path) { +fn migrate_dsh_layout(config_dir: &Path) -> Result<(), String> { let old_dsh_dir = config_dir.join("dsh"); let dsh_dir = config_dir.parent().unwrap_or(config_dir).join(DSH_DIR_NAME); let legacy_settings = config_dir.join("dsh-settings.yaml"); let legacy_plugin_settings = config_dir.join("dsh-plugin-settings.json"); let legacy_sessions = config_dir.join("dsh-sessions"); - if old_dsh_dir.is_dir() && !dsh_dir.exists() { - if fs::rename(&old_dsh_dir, &dsh_dir).is_ok() { - set_dir_owner_only_perms(&dsh_dir); - } + if path_is_directory(&old_dsh_dir)? && !path_exists(&dsh_dir)? { + fs::rename(&old_dsh_dir, &dsh_dir) + .map_err(|error| format!("move legacy DSH directory failed: {error}"))?; + set_dir_owner_only_perms(&dsh_dir); } - if legacy_settings.exists() || legacy_plugin_settings.exists() || legacy_sessions.exists() { - let _ = fs::create_dir_all(&dsh_dir); + if path_exists(&legacy_settings)? + || path_exists(&legacy_plugin_settings)? + || path_exists(&legacy_sessions)? + { + fs::create_dir_all(&dsh_dir) + .map_err(|error| format!("create DSH directory failed: {error}"))?; set_dir_owner_only_perms(&dsh_dir); } - migrate_into(&legacy_settings, &dsh_dir.join(DSH_SETTINGS_FILE_NAME)); + migrate_into(&legacy_settings, &dsh_dir.join(DSH_SETTINGS_FILE_NAME))?; migrate_into( &legacy_plugin_settings, &dsh_dir.join(DSH_PLUGIN_SETTINGS_FILE_NAME), - ); - migrate_into(&legacy_sessions, &dsh_dir.join(DSH_SESSIONS_DIR_NAME)); - flatten_dsh_session_wrappers(&dsh_dir.join(DSH_SESSIONS_DIR_NAME)); + )?; + migrate_into(&legacy_sessions, &dsh_dir.join(DSH_SESSIONS_DIR_NAME))?; + flatten_dsh_session_wrappers(&dsh_dir.join(DSH_SESSIONS_DIR_NAME))?; + Ok(()) +} + +fn path_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("inspect {} failed: {error}", path.display())), + } +} + +fn path_is_directory(path: &Path) -> Result { + match fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_dir()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("inspect {} failed: {error}", path.display())), + } } /// Rename `source` to `target` when the source exists and the target is /// absent, then reapply owner-only permissions. Never overwrites an existing /// target; the directory case keeps the execute bit so it stays traversable. -fn migrate_into(source: &Path, target: &Path) { - if !source.exists() || target.exists() { - return; +fn migrate_into(source: &Path, target: &Path) -> Result<(), String> { + if !path_exists(source)? || path_exists(target)? { + return Ok(()); } if let Some(parent) = target.parent() { - let _ = fs::create_dir_all(parent); + fs::create_dir_all(parent) + .map_err(|error| format!("create DSH migration target directory failed: {error}"))?; set_dir_owner_only_perms(parent); } - if fs::rename(source, target).is_ok() { - if target.is_dir() { - set_dir_owner_only_perms(target); - } else { - set_file_owner_only_perms(target); - } + fs::rename(source, target) + .map_err(|error| format!("move DSH migration source failed: {error}"))?; + if path_is_directory(target)? { + set_dir_owner_only_perms(target); + } else { + set_file_owner_only_perms(target); } + Ok(()) } /// Remove the temporary Flowix-owned `/` wrapper that was used /// before the persistence root matched Harness' official layout. Only /// directories that clearly contain project directories are flattened; other /// entries such as `.runtime` are left untouched. -fn flatten_dsh_session_wrappers(sessions_dir: &Path) { - let Ok(entries) = fs::read_dir(sessions_dir) else { - return; +fn flatten_dsh_session_wrappers(sessions_dir: &Path) -> Result<(), String> { + let entries = match fs::read_dir(sessions_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("read DSH sessions directory failed: {error}")), }; - for entry in entries.flatten() { + for entry in entries { + let entry = entry.map_err(|error| format!("read DSH session entry failed: {error}"))?; let wrapper = entry.path(); - if !wrapper.is_dir() || entry.file_name() == ".runtime" { + let file_type = entry + .file_type() + .map_err(|error| format!("inspect DSH session entry failed: {error}"))?; + if !file_type.is_dir() || entry.file_name() == ".runtime" { continue; } - let Ok(projects) = fs::read_dir(&wrapper) else { - continue; - }; - let projects = projects - .flatten() - .filter(|project| { - let project_name = project.file_name().to_string_lossy().into_owned(); - project.path().is_dir() - && (project_name == "_no-cwd" - || (project_name.starts_with("--") && project_name.ends_with("--"))) - }) - .collect::>(); + let candidates = fs::read_dir(&wrapper) + .map_err(|error| format!("read DSH session wrapper failed: {error}"))? + .collect::, _>>() + .map_err(|error| format!("read DSH wrapped session entry failed: {error}"))?; + let mut projects = Vec::new(); + for project in candidates { + let project_name = project.file_name().to_string_lossy().into_owned(); + if (project_name == "_no-cwd" + || (project_name.starts_with("--") && project_name.ends_with("--"))) + && path_is_directory(&project.path())? + { + projects.push(project); + } + } if projects.is_empty() { continue; } for project in projects { let target = sessions_dir.join(project.file_name()); - if !target.exists() { - let _ = fs::rename(project.path(), target); + if !path_exists(&target)? { + fs::rename(project.path(), &target) + .map_err(|error| format!("flatten DSH session wrapper failed: {error}"))?; + } + } + let mut remaining = fs::read_dir(&wrapper) + .map_err(|error| format!("inspect DSH session wrapper failed: {error}"))?; + let is_empty = match remaining.next() { + None => true, + Some(Ok(_)) => false, + Some(Err(error)) => { + return Err(format!("inspect DSH session wrapper entry failed: {error}")); } + }; + if is_empty { + fs::remove_dir(&wrapper) + .map_err(|error| format!("remove empty DSH session wrapper failed: {error}"))?; } - let _ = fs::remove_dir(&wrapper); } + Ok(()) } #[cfg(unix)] @@ -1405,6 +1654,43 @@ mod tests { ) } + #[test] + fn storage_rebuild_replaces_invalid_preferences_and_toml() { + let home = tempfile::tempdir().unwrap(); + let config = home.path().join(USER_CONFIG_DIR_NAME); + std::fs::create_dir_all(config.join("boot")).unwrap(); + std::fs::write(config.join("boot/preference.json"), b"invalid json").unwrap(); + std::fs::write(config.join(AI_CONFIG_FILE_NAME), [0xff, 0xfe]).unwrap(); + let _store = test_user_config_store(home.path().to_path_buf()); + let json = std::fs::read(config.join("boot/preference.json")).unwrap(); + serde_json::from_slice::(&json).unwrap(); + let toml = std::fs::read_to_string(config.join(AI_CONFIG_FILE_NAME)).unwrap(); + toml::from_str::(&toml).unwrap(); + assert!(config.join("boot/rebuild-backups").exists()); + assert!(config.join("rebuild-backups").exists()); + } + + #[test] + fn missing_runtime_dependencies_recreate_defaults_after_initialization() { + let home = tempfile::tempdir().unwrap(); + let store = test_user_config_store(home.path().to_path_buf()); + store.initialize_legacy_config_migrations().unwrap(); + store.prepare_initialized_runtime(); + let expected = store.get_property_presets().unwrap().system_fields.len(); + assert!(expected > 0); + std::fs::remove_dir_all(store.config_dir().join("db")).unwrap(); + store.ensure_runtime_storage().unwrap(); + assert_eq!( + store.get_property_presets().unwrap().system_fields.len(), + expected + ); + store.ensure_runtime_storage().unwrap(); + assert_eq!( + store.get_property_presets().unwrap().system_fields.len(), + expected + ); + } + #[test] fn legacy_json_property_presets_migrate_to_local_database() { let home = tempfile::tempdir().unwrap(); @@ -1417,18 +1703,32 @@ mod tests { r#type: "Tag".into(), ..PropertyFieldConfig::default() }); - atomic_write_json(&preference_file_path(&config_dir), &serde_json::to_string(&legacy).unwrap()).unwrap(); + atomic_write_json( + &preference_file_path(&config_dir), + &serde_json::to_string(&legacy).unwrap(), + ) + .unwrap(); let store = test_user_config_store(home.path().to_path_buf()); let migrated = store.get_property_presets().unwrap(); assert_eq!(migrated.fields.len(), 1); - assert!(migrated.fields[0].id.as_deref().is_some_and(|id| id.starts_with("fld_"))); - assert!(config_dir.join("local.db").is_file()); + assert!(migrated.fields[0] + .id + .as_deref() + .is_some_and(|id| id.starts_with("fld_"))); + assert!(config_dir.join("db/local.db").is_file()); - store.patch_preference(serde_json::json!({"language": "zh-CN"})).unwrap(); - let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(preference_file_path(&config_dir)).unwrap()).unwrap(); + store + .patch_preference(serde_json::json!({"language": "zh-CN"})) + .unwrap(); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(preference_file_path(&config_dir)).unwrap()) + .unwrap(); assert!(saved.get("properties").is_none()); - assert_eq!(store.get_property_presets().unwrap().fields[0].key, "topics"); + assert_eq!( + store.get_property_presets().unwrap().fields[0].key, + "topics" + ); } #[test] @@ -1538,7 +1838,7 @@ mod tests { } #[test] - fn first_dsh_read_migrates_legacy_toml_once() { + fn dsh_read_ignores_legacy_toml() { let home = tempfile::tempdir().unwrap(); let config_dir = home.path().join(USER_CONFIG_DIR_NAME); std::fs::create_dir_all(&config_dir).unwrap(); @@ -1558,31 +1858,13 @@ mod tests { let store = test_user_config_store(home.path().to_path_buf()); let loaded = store.get_deepseek_harness_config().unwrap(); - assert_eq!(loaded.model.provider, "legacy-provider"); - assert_eq!(loaded.model.model, "legacy-model"); - - // A later change to the old file must not affect the already-created - // DSH settings document. - let replacement = AiConfigFile { - model: AiModelConfig { - provider: "replacement-provider".into(), - model: "replacement-model".into(), - api_url: "https://replacement.example/v1".into(), - ..AiModelConfig::default() - }, - }; - std::fs::write( - config_dir.join(AI_CONFIG_FILE_NAME), - toml::to_string_pretty(&replacement).unwrap(), - ) - .unwrap(); - let loaded_again = store.get_deepseek_harness_config().unwrap(); - assert_eq!(loaded_again.model.provider, "legacy-provider"); - assert!(home + assert!(loaded.model.provider.is_empty()); + assert!(loaded.model.model.is_empty()); + assert!(!home .path() .join(DSH_DIR_NAME) .join(DSH_SETTINGS_FILE_NAME) - .is_file()); + .exists()); } #[test] @@ -1692,6 +1974,28 @@ apiKey = "k" assert_eq!(cfg.model.model, "gpt-4o"); } + #[test] + fn initialization_runtime_preparation_survives_process_restart() { + let home = tempfile::tempdir().unwrap(); + { + let store = test_user_config_store(home.path().to_path_buf()); + store.initialize_legacy_config_migrations().unwrap(); + } + // The persistent migration is already complete; the new process only + // restores runtime state before saving ordinary preferences. + let store = test_user_config_store(home.path().to_path_buf()); + store.prepare_initialized_runtime(); + store + .patch_preference(serde_json::json!({"language": "en"})) + .unwrap(); + let document: serde_json::Value = serde_json::from_slice( + &std::fs::read(preference_file_path(&store.config_dir)).unwrap(), + ) + .unwrap(); + assert!(document.get("properties").is_none()); + assert_eq!(document["language"], "en"); + } + #[test] fn set_preference_writes_to_boot_dir() { let home = tempfile::tempdir().unwrap(); diff --git a/app/flowix-desktop/src/device_registration.rs b/app/flowix-desktop/src/device_registration.rs index fc1f724de..733e35994 100644 --- a/app/flowix-desktop/src/device_registration.rs +++ b/app/flowix-desktop/src/device_registration.rs @@ -43,20 +43,35 @@ const DEFAULT_SUPABASE_ANON_KEY: &str = "sb_publishable_l6AmH0K0Uq8_roThQHSnnQ_2 pub struct BootFile { pub schema_version: u32, #[serde(default)] + pub version_initialization: VersionInitializationState, + /// Most recent app version whose one-time version initialization completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_successful_app_version: Option, + /// Version of the one-time on-disk storage reset already completed. + /// This is separate from app-version initialization so later releases do + /// not repeatedly discard derived indexes or user-created plugin files. + #[serde(default)] + pub notebook_storage_reset_version: u32, + #[serde(default)] pub experimental: bool, /// Whether the first-run DeepSeek Harness introduction has been shown. /// Keep the on-disk key in snake_case for the boot.json contract. #[serde(default, rename = "is_introduct_displayed")] pub is_introduct_displayed: bool, - /// Whether the first-run workspace onboarding has been completed. - /// Development builds intentionally keep this false so the flow can be - /// exercised on every launch. - #[serde(default, rename = "is_onboarding_completed")] - pub is_onboarding_completed: bool, #[serde(default)] pub user_info: UserInfo, } +/// Completion state for the one-time initialization associated with an app version. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionInitializationState { + #[serde(default)] + pub app_version: Option, + #[serde(default)] + pub completed: bool, +} + /// 设备登记子对象:保存随机设备 ID 和登记尝试状态。 #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -91,20 +106,32 @@ impl DeviceRegistry { let app_version = app_version.into(); if let Some(parent) = path.parent() { // best-effort, 文件已存在就能�?常�?�? 不存在时 `read_from_disk` 返回 None - let _ = std::fs::create_dir_all(parent); + let _ = flowix_core::memo_file::ensure_storage_directory(parent); } - let boot = Self::read_from_disk(&path).unwrap_or_else(|| { + let loaded = Self::read_from_disk(&path); + let mut save_defaults = loaded.is_none(); + let boot = loaded.unwrap_or_else(|| { + if let Err(error) = flowix_core::memo_file::backup_storage_file(&path) { + save_defaults = false; + tracing::warn!(%error, "could not preserve invalid boot configuration"); + } tracing::info!( "[device-reg] no boot.json at {}; creating a fresh registration record", path.display() ); Self::fresh() }); - Self { + let registry = Self { path, app_version, inner: RwLock::new(boot), + }; + if save_defaults { + if let Err(error) = registry.flush(®istry.read()) { + tracing::warn!(%error, "could not persist boot configuration"); + } } + registry } /// 异�?上报入口�?Fire-and-forget: �?�� `tauri::async_runtime::spawn`, @@ -122,33 +149,82 @@ impl DeviceRegistry { self.read().experimental } - /// Whether the DeepSeek Harness introduction has already been displayed. - pub fn is_introduct_displayed(&self) -> bool { - self.read().is_introduct_displayed + /// App version whose one-time version initialization most recently completed. + pub fn last_successful_app_version(&self) -> Option { + self.read().last_successful_app_version.clone() } - /// Persist the DeepSeek Harness introduction display state. - pub fn set_introduct_displayed(&self, displayed: bool) -> Result<(), String> { + /// Whether the current app version has completed its one-time initialization. + pub fn is_version_initialization_complete(&self) -> bool { + let boot = self.read(); + // `last_successful_app_version` is the durable fallback if the in-flight + // marker was restored from an older backup or otherwise became stale. + // Once this version has completed successfully, do not replay one-time + // initialization because the secondary progress flag disagrees. + boot.last_successful_app_version.as_deref() == Some(self.app_version.as_str()) + || (boot.version_initialization.app_version.as_deref() + == Some(self.app_version.as_str()) + && boot.version_initialization.completed) + } + + /// Update the current app version's initialization flag from dev settings. + pub fn set_version_initialization_completed(&self, completed: bool) -> Result<(), String> { let mut boot = self.write(); - boot.is_introduct_displayed = displayed; + boot.version_initialization.app_version = Some(self.app_version.clone()); + boot.version_initialization.completed = completed; + if completed { + boot.last_successful_app_version = Some(self.app_version.clone()); + } else if boot.last_successful_app_version.as_deref() == Some(self.app_version.as_str()) { + // Preserve the explicit development setting that requests a rerun. + boot.last_successful_app_version = None; + } + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + + /// Persist an incomplete marker before running version-specific migrations. + pub fn begin_version_initialization(&self) -> Result<(), String> { + let mut boot = self.write(); + boot.version_initialization.app_version = Some(self.app_version.clone()); + boot.version_initialization.completed = false; + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + + /// Mark the current app version initialized after all one-time steps succeed. + /// The same atomic boot.json write advances the previous successful version + /// so it remains available until this initialization has fully completed. + pub fn complete_version_initialization(&self) -> Result<(), String> { + let mut boot = self.write(); + if boot.version_initialization.app_version.as_deref() != Some(self.app_version.as_str()) { + return Err("version initialization marker changed while running".to_string()); + } + boot.version_initialization.completed = true; + boot.last_successful_app_version = Some(self.app_version.clone()); self.flush(&boot) .map_err(|error| format!("persist boot.json: {error}")) } - /// Whether the first-run workspace onboarding has been completed. - /// - /// 与线上行为一致: 直接读 `boot.json`, 开发构建不再强制返回 false —— - /// 否则每次 `tauri:dev` 都会重走引导, 无法验证"完成一次后不再展示"。 - /// 开发期想重现首次启动, 删掉 `~/.flowix/boot/boot.json` 里的 - /// `is_onboarding_completed`(或整个文件) 即可。 - pub fn is_onboarding_completed(&self) -> bool { - self.read().is_onboarding_completed + pub fn notebook_storage_reset_version(&self) -> u32 { + self.read().notebook_storage_reset_version } - /// Persist onboarding completion. - pub fn set_onboarding_completed(&self, completed: bool) -> Result<(), String> { + pub fn complete_notebook_storage_reset_version(&self, version: u32) -> Result<(), String> { let mut boot = self.write(); - boot.is_onboarding_completed = completed; + boot.notebook_storage_reset_version = boot.notebook_storage_reset_version.max(version); + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + + /// Whether the DeepSeek Harness introduction has already been displayed. + pub fn is_introduct_displayed(&self) -> bool { + self.read().is_introduct_displayed + } + + /// Persist the DeepSeek Harness introduction display state. + pub fn set_introduct_displayed(&self, displayed: bool) -> Result<(), String> { + let mut boot = self.write(); + boot.is_introduct_displayed = displayed; self.flush(&boot) .map_err(|error| format!("persist boot.json: {error}")) } @@ -296,9 +372,11 @@ impl DeviceRegistry { fn fresh() -> BootFile { BootFile { schema_version: BOOT_SCHEMA_VERSION, + version_initialization: VersionInitializationState::default(), + last_successful_app_version: None, + notebook_storage_reset_version: 0, experimental: false, is_introduct_displayed: false, - is_onboarding_completed: false, user_info: UserInfo { device_id: Uuid::new_v4(), installed_at: Utc::now(), @@ -431,7 +509,6 @@ mod tests { assert_eq!(b.schema_version, BOOT_SCHEMA_VERSION); assert!(!b.experimental); assert!(!b.is_introduct_displayed); - assert!(!b.is_onboarding_completed); assert!(!b.user_info.registered); assert_eq!(b.user_info.attempts, 0); assert!(b.user_info.registered_at.is_none()); @@ -444,11 +521,9 @@ mod tests { b.experimental = true; let s = serde_json::to_string(&b).unwrap(); assert!(s.contains("\"is_introduct_displayed\":false")); - assert!(s.contains("\"is_onboarding_completed\":false")); let v: BootFile = serde_json::from_str(&s).unwrap(); assert!(v.experimental); assert!(!v.is_introduct_displayed); - assert!(!v.is_onboarding_completed); assert_eq!(b.user_info.device_id, v.user_info.device_id); assert_eq!(b.user_info.installed_at, v.user_info.installed_at); assert_eq!( @@ -476,49 +551,9 @@ mod tests { assert!(!boot.is_introduct_displayed); } - #[test] - fn missing_onboarding_completed_defaults_to_false() { - let mut value = serde_json::to_value(fresh_boot()).unwrap(); - value - .as_object_mut() - .unwrap() - .remove("is_onboarding_completed"); - let boot: BootFile = serde_json::from_value(value).unwrap(); - assert!(!boot.is_onboarding_completed); - } - - #[test] - fn onboarding_completion_survives_reload() { - // 线上行为: 完成一次就落盘, 重新加载后不再展示引导。 - // (回归保护: 曾经 debug 构建读写两端都强制 false, 导致 dev 每次重走引导。) - // - // 注意用 `tempfile::tempdir()` 而不是 `tempdir_path()`: 后者返回的裸路径 - // 既不当文件建也不当目录建, 而 `flush()` 会在其下再建 `boot/` —— 一旦与 - // 其它用例的时间戳撞车 (macOS 上 `timestamp_nanos` 分辨率不足以区分并发 - // 用例), 就会出现 "文件已存在" / "Not a directory" 的偶发失败。 - let temp = tempfile::tempdir().unwrap(); - let dir = temp.path(); - let registry = DeviceRegistry::load(dir, "test-version"); - assert!(!registry.is_onboarding_completed()); - - registry.set_onboarding_completed(true).unwrap(); - assert!(registry.is_onboarding_completed()); - - let reloaded = DeviceRegistry::load(dir, "test-version"); - assert!( - reloaded.is_onboarding_completed(), - "completion must be read back from boot.json, not reset per launch" - ); - - // 反向同样生效 (便于开发期手动重置)。 - reloaded.set_onboarding_completed(false).unwrap(); - assert!(!DeviceRegistry::load(dir, "test-version").is_onboarding_completed()); - } - #[test] fn intro_displayed_survives_reload() { - // 与 onboarding 同一条持久化路径, 一并锁住, 避免将来又被构建类型短路。 - // 同上: 必须用独立临时目录, 不能复用 `tempdir_path()`。 + // 必须用独立临时目录, 不能复用 `tempdir_path()`。 let temp = tempfile::tempdir().unwrap(); let dir = temp.path(); let registry = DeviceRegistry::load(dir, "test-version"); diff --git a/app/flowix-desktop/src/document_derived.rs b/app/flowix-desktop/src/document_derived.rs deleted file mode 100644 index e9ba72ece..000000000 --- a/app/flowix-desktop/src/document_derived.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! One coalescing worker for derived search work, outside save receipts. -use crate::app::state::AppState; -use std::{ - collections::BTreeMap, - sync::{Mutex, OnceLock}, -}; -use tauri::{AppHandle, Manager}; - -struct Job { - app: AppHandle, - memo_id: String, -} -#[derive(Default)] -struct Pending { - running: bool, - jobs: BTreeMap, -} -fn pending() -> &'static Mutex { - static PENDING: OnceLock> = OnceLock::new(); - PENDING.get_or_init(Mutex::default) -} - -pub fn schedule(app: &AppHandle, memo_id: &str) { - let mut state = pending().lock().unwrap_or_else(|e| e.into_inner()); - state.jobs.entry(memo_id.into()).or_insert_with(|| Job { - app: app.clone(), - memo_id: memo_id.into(), - }); - if state.running { - return; - } - state.running = true; - drop(state); - tauri::async_runtime::spawn(async { - loop { - let job = { - let mut state = pending().lock().unwrap_or_else(|e| e.into_inner()); - match state.jobs.pop_first() { - Some((_, job)) => job, - None => { - state.running = false; - break; - } - } - }; - let result = crate::document_io::run("derive", move || { - let state = job.app.state::(); - // A delayed local snapshot must not overwrite a newer external - // search update, or resurrect an entry deleted meanwhile. - // 旧 memo id → notebook + relative_path,再刷新 search index。 - let target = crate::lock_utils::read_lock(&state.memo_file, "memo_file") - .resolve_memo_location(&job.memo_id) - .ok() - .flatten() - .map(|location| (location.notebook.id, location.memo.relative_path)); - if let Some((notebook_id, relative_path)) = target { - } - Ok::<(), flowix_core::FlowixError>(()) - }) - .await; - match result { - Err(error) => tracing::error!("document derived worker failed: {error}"), - Ok(Err(error)) => tracing::error!("document derived work failed: {error}"), - Ok(Ok(())) => {} - } - } - }); -} - -pub async fn flush() -> bool { - for _ in 0..100 { - if !pending().lock().unwrap_or_else(|e| e.into_inner()).running { - return true; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - false -} diff --git a/app/flowix-desktop/src/dsh.rs b/app/flowix-desktop/src/dsh.rs index 1c71ddb91..900c1ebf0 100644 --- a/app/flowix-desktop/src/dsh.rs +++ b/app/flowix-desktop/src/dsh.rs @@ -20,6 +20,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; +#[cfg(not(debug_assertions))] +use tauri::Manager; use tauri::{AppHandle, Emitter}; use crate::app::paths::get_app_data_path; @@ -27,6 +29,14 @@ use crate::app::paths::get_app_data_path; mod activation; mod archive; mod cleanup; +pub(crate) mod cli; +pub(crate) use cli::resolve_cli_launch; +pub(crate) fn set_user_selected_cli(path: Option) { + cli::set_user_selected_dsh_cli(path); +} +pub(crate) fn is_valid_cli_path(path: &Path) -> bool { + cli::is_valid_dsh_cli_path(path) +} mod download; mod environment; mod manifest; @@ -66,6 +76,10 @@ static DSH_DOWNLOAD_PROGRESS: OnceLock>> = Onc #[serde(rename_all = "camelCase")] pub struct DshStatus { pub installed: bool, + pub local_installed: bool, + pub managed_installed: bool, + pub app_server_profile_installed: bool, + pub app_server_profile_update_available: bool, pub executable_path: Option, pub version: Option, pub harness_version: Option, @@ -208,33 +222,55 @@ pub struct ManagedDshLaunch { } pub fn status() -> DshStatus { - if let Some(result) = development_bundle_launch_spec() { - return match result { - Ok(launch) => DshStatus { - installed: true, - executable_path: Some(launch.executable.display().to_string()), - version: Some("dev".to_string()), - harness_version: harness_version_from_root(&launch.root), - source: Some("flowix-dev-bundle".to_string()), - profile: DEFAULT_PROFILE.to_string(), - message: None, - archive_size: None, - }, - Err(message) => DshStatus { - installed: false, - executable_path: None, - version: None, - harness_version: None, - source: Some("flowix-dev-bundle".to_string()), - profile: DEFAULT_PROFILE.to_string(), - message: Some(message), - archive_size: None, - }, + let development = development_bundle_launch_spec(); + let local = local_dsh_executable(); + let managed = current_installation(); + let dsh_home = dsh_home_path(); + let profile_owned = flowix_profile_owned(&dsh_home); + let app_server_profile_installed = flowix_profile_installed(&dsh_home); + let app_server_profile_update_available = profile_owned && !app_server_profile_installed; + if let Some(executable) = local.as_ref() { + let version = local_dsh_version(executable); + return DshStatus { + installed: true, + local_installed: true, + managed_installed: managed.is_some() + || development + .as_ref() + .map(|result| result.is_ok()) + .unwrap_or(false), + executable_path: Some(executable.display().to_string()), + version, + harness_version: None, + source: Some("local-cli".to_string()), + profile: DEFAULT_PROFILE.to_string(), + message: None, + archive_size: None, + app_server_profile_installed, + app_server_profile_update_available, }; } - match current_installation() { + if let Some(Ok(launch)) = development.as_ref() { + return DshStatus { + installed: true, + local_installed: false, + managed_installed: true, + executable_path: Some(launch.executable.display().to_string()), + version: Some("dev".to_string()), + harness_version: harness_version_from_root(&launch.root), + source: Some("flowix-dev-bundle".to_string()), + profile: DEFAULT_PROFILE.to_string(), + message: None, + archive_size: None, + app_server_profile_installed, + app_server_profile_update_available, + }; + } + match managed { Some(installation) => DshStatus { installed: true, + local_installed: false, + managed_installed: true, executable_path: Some(installation.host_path.display().to_string()), version: Some(installation.version.clone()), harness_version: installation.harness_version.clone(), @@ -242,20 +278,422 @@ pub fn status() -> DshStatus { profile: DEFAULT_PROFILE.to_string(), message: None, archive_size: installation.archive_size, + app_server_profile_installed, + app_server_profile_update_available, }, None => DshStatus { installed: false, + local_installed: false, + managed_installed: false, executable_path: None, version: None, harness_version: None, source: None, profile: DEFAULT_PROFILE.to_string(), - message: Some("DeepSeek Harness is not installed".to_string()), + message: development + .and_then(Result::err) + .or_else(|| Some("DeepSeek Harness is not installed".to_string())), archive_size: None, + app_server_profile_installed: false, + app_server_profile_update_available: false, }, } } +fn dsh_home_path() -> PathBuf { + crate::config::user::resolve_dsh_home(&dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))) +} + +pub(crate) fn flowix_profile_installed(dsh_home: &Path) -> bool { + flowix_profile_present(dsh_home) + && flowix_profile_version(dsh_home).as_deref() == Some(env!("CARGO_PKG_VERSION")) +} + +fn flowix_profile_present(dsh_home: &Path) -> bool { + let profile = dsh_home.join("profiles").join(DEFAULT_PROFILE); + if !profile + .join("node_modules/dsh-appserver/src/cordis-plugin.js") + .is_file() + || !profile + .join("node_modules/dsh-flowix-memory/launcher.mjs") + .is_file() + || !profile.join("cordis.patch.yml").is_file() + { + return false; + } + let Ok(package_bytes) = fs::read(profile.join("package.json")) else { + return false; + }; + let Ok(package) = serde_json::from_slice::(&package_bytes) else { + return false; + }; + package + .pointer("/dsh/profile/bundles") + .and_then(serde_json::Value::as_array) + .is_some_and(|bundles| { + ["dsh-appserver", "dsh-flowix-memory"] + .iter() + .all(|required| { + bundles + .iter() + .any(|bundle| bundle.as_str() == Some(required)) + }) + }) +} + +fn flowix_profile_owned(dsh_home: &Path) -> bool { + let package = fs::read( + dsh_home + .join("profiles") + .join(DEFAULT_PROFILE) + .join("package.json"), + ) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + package + .as_ref() + .and_then(|package| package.get("name")) + .and_then(serde_json::Value::as_str) + == Some("dsh-profile-flowix") +} + +fn flowix_profile_version(dsh_home: &Path) -> Option { + let package = fs::read( + dsh_home + .join("profiles") + .join(DEFAULT_PROFILE) + .join("package.json"), + ) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok())?; + package + .pointer("/flowix/profileVersion") + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +fn local_dsh_executable() -> Option { + cli::local_dsh_executable() +} + +fn local_dsh_version(cli: &Path) -> Option { + let canonical = fs::canonicalize(cli).unwrap_or_else(|_| cli.to_path_buf()); + for executable in [canonical.as_path(), cli] { + let mut directory = executable.parent(); + while let Some(current) = directory { + let manifest = current.join("package.json"); + if let Ok(bytes) = fs::read(&manifest) { + if let Ok(package) = serde_json::from_slice::(&bytes) { + if package.get("name").and_then(serde_json::Value::as_str) + == Some("@deepseek-ai/dsh") + { + if let Some(version) = + package.get("version").and_then(serde_json::Value::as_str) + { + return Some(version.to_string()); + } + } + } + } + for relative in [ + Path::new("node_modules/@deepseek-ai/dsh/package.json"), + Path::new("lib/node_modules/@deepseek-ai/dsh/package.json"), + ] { + let manifest = current.join(relative); + if let Ok(bytes) = fs::read(manifest) { + if let Ok(package) = serde_json::from_slice::(&bytes) { + if package.get("name").and_then(serde_json::Value::as_str) + == Some("@deepseek-ai/dsh") + { + if let Some(version) = + package.get("version").and_then(serde_json::Value::as_str) + { + return Some(version.to_string()); + } + } + } + } + } + directory = current.parent(); + } + } + None +} + +/// Install the Flowix App Server profile after an explicit user action. +/// Startup only probes this state; it never calls this function implicitly. +pub fn install_appserver_profile(app: &AppHandle) -> Result { + let _operation_guard = try_acquire_operation_lock()?; + let current_status = status(); + if !current_status.installed { + return Err("请先安装 DSH,再安装 Flowix App Server 插件".to_string()); + } + + let home = dsh_home_path(); + let source = appserver_profile_resource(app)?; + + install_profile_from_source(&home, &source)?; + Ok(status()) +} + +pub(crate) fn install_profile_from_source(home: &Path, source: &Path) -> Result<(), String> { + let target = home.join("profiles").join(DEFAULT_PROFILE); + let had_flowix_profile = target.exists(); + if had_flowix_profile { + let package_path = target.join("package.json"); + let package = fs::read(&package_path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if package + .as_ref() + .and_then(|value| value.get("name")) + .and_then(serde_json::Value::as_str) + != Some("dsh-profile-flowix") + { + return Err(format!( + "DSH profile '{}' 已存在且不属于 Flowix;为避免覆盖用户配置,请先在 DSH 中更换 profile 名称", + DEFAULT_PROFILE + )); + } + } + + let parent = target.parent().ok_or("invalid DSH profile path")?; + fs::create_dir_all(parent) + .map_err(|error| format!("create DSH profiles directory: {error}"))?; + let transaction_id = uuid::Uuid::new_v4(); + let staging = parent.join(format!(".flowix-profile-install-{transaction_id}")); + let backup = parent.join(format!(".flowix-profile-backup-{transaction_id}")); + let staged = if had_flowix_profile { + copy_profile_tree(&target, &staging).and_then(|()| { + for bundle in ["dsh-appserver", "dsh-flowix-memory"] { + let installed = staging.join("node_modules").join(bundle); + if installed.exists() { + fs::remove_dir_all(&installed) + .map_err(|error| format!("replace Flowix DSH bundle {bundle}: {error}"))?; + } + copy_profile_tree(&source.join("node_modules").join(bundle), &installed)?; + } + merge_flowix_profile_package( + &staging.join("package.json"), + &source.join("package.json"), + )?; + repair_flowix_profile_patch( + &staging.join("cordis.patch.yml"), + &source.join("cordis.patch.yml"), + ) + .and_then(|()| stamp_flowix_profile_version(&staging)) + }) + } else { + copy_profile_tree(&source, &staging).and_then(|()| stamp_flowix_profile_version(&staging)) + }; + if let Err(error) = staged { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + if target.exists() { + fs::rename(&target, &backup) + .map_err(|error| format!("preserve existing Flowix DSH profile: {error}"))?; + } + if let Err(error) = fs::rename(&staging, &target) { + let _ = fs::remove_dir_all(&staging); + if backup.exists() { + let _ = fs::rename(&backup, &target); + } + return Err(format!("publish Flowix DSH profile: {error}")); + } + if backup.exists() { + let _ = fs::remove_dir_all(&backup); + } + Ok(()) +} + +/// Add missing Flowix patch entries while preserving existing user settings. +fn repair_flowix_profile_patch(target: &Path, source: &Path) -> Result<(), String> { + if !target.exists() { + fs::copy(source, target) + .map_err(|error| format!("restore Flowix profile patch: {error}"))?; + return Ok(()); + } + let read_patch = |path: &Path| -> Result, String> { + let bytes = fs::read(path).map_err(|error| format!("read DSH profile patch: {error}"))?; + serde_yaml::from_slice(&bytes).map_err(|error| format!("parse DSH profile patch: {error}")) + }; + let mut patch = read_patch(target)?; + let required = read_patch(source)?; + let mut changed = false; + for entry in required { + let id = entry.get("id").and_then(serde_yaml::Value::as_str); + if let Some(id) = id { + if let Some(existing) = patch + .iter_mut() + .find(|existing| existing.get("id").and_then(serde_yaml::Value::as_str) == Some(id)) + { + changed |= merge_required_patch_fields(existing, &entry); + continue; + } + } + // Entries without an id (e.g. insert operations) use structural equality. + if id.is_none() && patch.contains(&entry) { + continue; + } + patch.push(entry); + changed = true; + } + if changed { + let yaml = serde_yaml::to_string(&patch) + .map_err(|error| format!("serialize DSH profile patch: {error}"))?; + fs::write(target, yaml).map_err(|error| format!("repair DSH profile patch: {error}"))?; + } + Ok(()) +} + +/// Refresh Flowix-owned patch fields while retaining user-added fields and +/// entries. Required values from the shipped patch take precedence. +fn merge_required_patch_fields( + existing: &mut serde_yaml::Value, + required: &serde_yaml::Value, +) -> bool { + match (existing, required) { + (serde_yaml::Value::Mapping(existing), serde_yaml::Value::Mapping(required)) => { + let mut changed = false; + for (key, required_value) in required { + if let Some(existing_value) = existing.get_mut(key) { + changed |= merge_required_patch_fields(existing_value, required_value); + } else { + existing.insert(key.clone(), required_value.clone()); + changed = true; + } + } + changed + } + (existing, required) if existing != required => { + *existing = required.clone(); + true + } + _ => false, + } +} + +fn merge_flowix_profile_package(target: &Path, source: &Path) -> Result<(), String> { + let mut target_json: serde_json::Value = serde_json::from_slice( + &fs::read(target).map_err(|error| format!("read existing DSH profile package: {error}"))?, + ) + .map_err(|error| format!("parse existing DSH profile package: {error}"))?; + let source_json: serde_json::Value = serde_json::from_slice( + &fs::read(source).map_err(|error| format!("read Flowix DSH profile package: {error}"))?, + ) + .map_err(|error| format!("parse Flowix DSH profile package: {error}"))?; + let bundles = target_json + .pointer_mut("/dsh/profile/bundles") + .and_then(serde_json::Value::as_array_mut) + .ok_or("existing Flowix DSH profile has no bundle list")?; + for required in [ + "@deepseek-ai/dsh-base", + "dsh-appserver", + "dsh-flowix-memory", + ] { + if !bundles + .iter() + .any(|bundle| bundle.as_str() == Some(required)) + { + bundles.push(serde_json::Value::String(required.to_string())); + } + } + if target_json + .get("name") + .and_then(serde_json::Value::as_str) + .is_none() + { + target_json["name"] = source_json["name"].clone(); + } + fs::write( + target, + serde_json::to_vec_pretty(&target_json) + .map_err(|error| format!("serialize Flowix DSH profile package: {error}"))?, + ) + .map_err(|error| format!("write Flowix DSH profile package: {error}")) +} + +fn stamp_flowix_profile_version(profile: &Path) -> Result<(), String> { + let package_path = profile.join("package.json"); + let mut package: serde_json::Value = serde_json::from_slice( + &fs::read(&package_path) + .map_err(|error| format!("read Flowix DSH profile package: {error}"))?, + ) + .map_err(|error| format!("parse Flowix DSH profile package: {error}"))?; + package["flowix"]["profileVersion"] = + serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()); + fs::write( + &package_path, + serde_json::to_vec_pretty(&package) + .map_err(|error| format!("serialize Flowix DSH profile package: {error}"))?, + ) + .map_err(|error| format!("write Flowix DSH profile version: {error}")) +} + +fn appserver_profile_resource(_app: &AppHandle) -> Result { + #[cfg(debug_assertions)] + { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let source = repo.join(".build/dsh-runtime-dev"); + if source.is_dir() { + let platform = if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + let candidate = source.join(format!("node24-{platform}-{arch}/profile/flowix")); + if candidate.is_dir() { + return Ok(candidate); + } + } + let staged = repo.join(".build/dsh-appserver-profile/flowix"); + for bundle in ["dsh-appserver", "dsh-flowix-memory"] { + let plugin_target = staged.join("node_modules").join(bundle); + if plugin_target.exists() { + fs::remove_dir_all(&plugin_target) + .map_err(|error| format!("refresh local DSH bundle {bundle}: {error}"))?; + } + copy_profile_tree(&repo.join(bundle), &plugin_target)?; + } + fs::create_dir_all(&staged) + .map_err(|error| format!("create local DSH profile template: {error}"))?; + fs::copy( + repo.join("scripts/dsh-dev-profile.patch.yml"), + staged.join("cordis.patch.yml"), + ) + .map_err(|error| format!("stage DSH App Server profile patch: {error}"))?; + fs::write( + staged.join("package.json"), + br#"{ + "name": "dsh-profile-flowix", + "private": true, + "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "dsh-appserver", "dsh-flowix-memory"] } } +} +"#, + ) + .map_err(|error| format!("stage DSH App Server profile manifest: {error}"))?; + Ok(staged) + } + #[cfg(not(debug_assertions))] + { + let resource = _app + .path() + .resource_dir() + .ok() + .map(|root| root.join("dsh-profile/flowix")) + .filter(|path| path.is_dir()); + resource.ok_or_else(|| "Flowix App Server installation files are missing".to_string()) + } +} + /// Check the published DSH manifest without downloading or changing the /// installed runtime. Development bundles deliberately skip remote checks. pub fn check_for_update() -> Result { @@ -428,7 +866,6 @@ pub fn run_profile_plugin( package: Option<&str>, ) -> Result { let _operation_guard = try_acquire_operation_lock()?; - let launch = managed_launch_spec().ok_or("DeepSeek Harness runtime is not installed")?; let action = action.trim(); if !matches!(action, "add" | "remove" | "update") { return Err("unsupported DSH plugin action".to_string()); @@ -447,23 +884,18 @@ pub fn run_profile_plugin( return Err("invalid DSH plugin package spec".to_string()); } + let mut args = vec!["plugin", "--profile", DEFAULT_PROFILE, action]; + if let Some(package) = package { + args.push(package); + } + let launch = resolve_cli_launch(&args)?; let mut command = Command::new(&launch.executable); crate::process_window::hide_std_command_window(&mut command); - if let Some(cli) = launch.cli_entrypoint.as_ref() { - command.arg(cli); - } else { - command.env("DSH_EMBEDDED_CLI_MODE", "1"); - } command + .args(&launch.args) .env_clear() - .envs(dsh_plugin_environment()) - .envs(managed_child_environment(&launch.root)) - .env("DSH_HOME", dsh_home) - .env("FLOWIX_DSH_ROOT", &launch.root) - .args(["plugin", "--profile", DEFAULT_PROFILE, action]); - if let Some(package) = package { - command.arg(package); - } + .envs(launch.environment) + .env("DSH_HOME", dsh_home); let output = command .output() .map_err(|error| format!("start official dsh plugin command: {error}"))?; @@ -1376,7 +1808,7 @@ mod tests { use super::{ archive::safe_archive_path, dsh_version_is_at_least, validate_manifest_version, DshArtifact, }; - use std::path::Path; + use std::path::{Path, PathBuf}; #[cfg(unix)] use super::DshManifest; @@ -1402,6 +1834,59 @@ mod tests { assert_eq!(std::fs::read(&stale).unwrap(), b"b"); } + #[test] + fn repairs_appserver_bundle_and_patch_in_an_existing_flowix_profile() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let home = temp.path().join("official-dsh-home"); + let target = home.join("profiles/flowix"); + std::fs::create_dir_all(source.join("node_modules/dsh-appserver/src")).unwrap(); + std::fs::create_dir_all(source.join("node_modules/dsh-flowix-memory/src")).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write( + source.join("node_modules/dsh-appserver/src/cordis-plugin.js"), + "export {};", + ) + .unwrap(); + std::fs::write( + source.join("node_modules/dsh-flowix-memory/src/index.js"), + "export {};", + ) + .unwrap(); + std::fs::write(source.join("package.json"), br#"{"name":"dsh-profile-flowix","private":true,"dsh":{"profile":{"bundles":["@deepseek-ai/dsh-base","dsh-appserver","dsh-flowix-memory"]}}}"#).unwrap(); + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let patch_source = repo.join("scripts/dsh-dev-profile.patch.yml"); + std::fs::copy(&patch_source, source.join("cordis.patch.yml")).unwrap(); + std::fs::write(target.join("package.json"), br#"{"name":"dsh-profile-flowix","dsh":{"profile":{"bundles":["@deepseek-ai/dsh-base"]}}}"#).unwrap(); + std::fs::write( + target.join("cordis.patch.yml"), + "- id: user-custom-plugin\n config:\n enabled: true\n", + ) + .unwrap(); + + super::install_profile_from_source(&home, &source).unwrap(); + assert!(super::flowix_profile_installed(&home)); + let package: serde_json::Value = + serde_json::from_slice(&std::fs::read(target.join("package.json")).unwrap()).unwrap(); + let bundles = package + .pointer("/dsh/profile/bundles") + .unwrap() + .as_array() + .unwrap(); + assert!(bundles.iter().any(|bundle| bundle == "dsh-appserver")); + let patch: Vec = + serde_yaml::from_slice(&std::fs::read(target.join("cordis.patch.yml")).unwrap()) + .unwrap(); + assert!(patch + .iter() + .any(|entry| entry.get("id").and_then(serde_yaml::Value::as_str) + == Some("user-custom-plugin"))); + assert!(patch + .iter() + .any(|entry| entry.get("id").and_then(serde_yaml::Value::as_str) + == Some("dsh-appserver-extension"))); + } + #[test] fn protects_only_flowix_profile_requirements_from_managed_removal() { assert!(super::is_required_flowix_profile_bundle( diff --git a/app/flowix-desktop/src/dsh/cli.rs b/app/flowix-desktop/src/dsh/cli.rs new file mode 100644 index 000000000..1b79e3bde --- /dev/null +++ b/app/flowix-desktop/src/dsh/cli.rs @@ -0,0 +1,194 @@ +//! One CLI resolver and child environment for official and managed DSH. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{OnceLock, RwLock}; + +use crate::agent_external::cli_resolver::{ + is_executable_file, resolve_external_cli_uncached, ExternalCliSpec, +}; + +static USER_SELECTED_DSH_CLI: OnceLock>> = OnceLock::new(); + +pub(crate) fn set_user_selected_dsh_cli(path: Option) { + if let Ok(mut selected) = USER_SELECTED_DSH_CLI + .get_or_init(|| RwLock::new(None)) + .write() + { + *selected = path; + } +} + +pub(crate) fn is_valid_dsh_cli_path(path: &Path) -> bool { + if is_executable_file(path) { + return true; + } + let is_node_script = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| matches!(extension, "js" | "mjs" | "cjs")) + .unwrap_or(false); + is_node_script + && std::fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) +} + +pub(crate) struct DshCliLaunch { + pub executable: PathBuf, + pub args: Vec, + pub environment: HashMap, +} + +fn extra_candidates() -> Vec { + let mut paths = vec![super::dsh_home_path().join("bin/dsh")]; + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".local/share/pnpm/dsh")); + } + paths +} + +#[cfg(windows)] +fn windows_candidates() -> Vec { + let mut directories = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + directories.push(super::dsh_home_path().join("bin")); + if let Some(app_data) = std::env::var_os("APPDATA") { + directories.push(PathBuf::from(app_data).join("npm")); + } + directories + .into_iter() + .flat_map(|directory| { + ["dsh.exe", "dsh.cmd", "dsh.bat"] + .into_iter() + .map(move |name| directory.join(name)) + }) + .collect() +} + +pub(super) fn local_dsh_executable() -> Option { + let selected = USER_SELECTED_DSH_CLI + .get_or_init(|| RwLock::new(None)) + .read() + .ok() + .and_then(|selected| selected.clone()) + .filter(|path| is_valid_dsh_cli_path(path)); + if selected.is_some() { + return selected; + } + let path = resolve_external_cli_uncached(&ExternalCliSpec { + binary_name: "dsh", + #[cfg(windows)] + windows_binary_name: "dsh.cmd", + env_vars: &["FLOWIX_DSH_CLI_PATH"], + extra_unix_candidates: extra_candidates, + #[cfg(windows)] + extra_windows_candidates: windows_candidates, + }); + path.is_absolute() + .then_some(path) + .filter(|path| is_executable_file(path)) +} + +fn local_node(cli: &Path) -> Option { + if let Some(node) = std::env::var_os("FLOWIX_DSH_NODE_PATH") + .map(PathBuf::from) + .filter(|path| is_executable_file(path)) + { + return Some(node); + } + // npm/nvm install their CLI shims alongside the matching Node binary. + let name = if cfg!(windows) { "node.exe" } else { "node" }; + if let Some(node) = cli + .parent() + .map(|parent| parent.join(name)) + .filter(|path| is_executable_file(path)) + { + return Some(node); + } + if let Some(node) = crate::agent_external::node::resolve_node_binary("FLOWIX_DSH_NODE_PATH") { + return Some(node); + } + let node = resolve_external_cli_uncached(&ExternalCliSpec { + binary_name: "node", + #[cfg(windows)] + windows_binary_name: "node.exe", + env_vars: &["FLOWIX_DSH_NODE_PATH"], + extra_unix_candidates: crate::agent_external::cli_resolver::no_extra_candidates, + #[cfg(windows)] + extra_windows_candidates: crate::agent_external::cli_resolver::no_extra_candidates, + }); + is_executable_file(&node).then_some(node) +} + +pub(super) fn local_launch(cli: PathBuf, args: &[&str]) -> Result { + let mut environment = super::dsh_plugin_environment(); + let node = local_node(&cli); + let key = if cfg!(windows) { "Path" } else { "PATH" }; + let mut paths = cli + .parent() + .map(Path::to_path_buf) + .into_iter() + .collect::>(); + if let Some(parent) = node.as_ref().and_then(|node| node.parent()) { + paths.push(parent.to_path_buf()); + } + if let Some(path) = std::env::var_os(key).or_else(|| std::env::var_os("PATH")) { + paths.extend(std::env::split_paths(&path)); + } + let path = + std::env::join_paths(paths).map_err(|error| format!("prepare DSH CLI PATH: {error}"))?; + #[cfg(windows)] + environment.remove("PATH"); + environment.insert(key.into(), path.to_string_lossy().into_owned()); + + let resolved = std::fs::canonicalize(&cli).unwrap_or_else(|_| cli.clone()); + let mut cli_args = Vec::new(); + let executable = match resolved + .extension() + .and_then(|extension| extension.to_str()) + { + Some("js" | "mjs" | "cjs") => { + cli_args.push(resolved.to_string_lossy().into_owned()); + node.ok_or("DSH requires Node.js; install Node.js or set FLOWIX_DSH_NODE_PATH")? + } + _ => cli, + }; + cli_args.extend(args.iter().map(|arg| (*arg).to_string())); + Ok(DshCliLaunch { + executable, + args: cli_args, + environment, + }) +} + +pub(crate) fn resolve_cli_launch(args: &[&str]) -> Result { + if let Some(cli) = local_dsh_executable() { + return local_launch(cli, args); + } + let launch = super::managed_launch_spec().ok_or("DeepSeek Harness runtime is not installed")?; + let mut environment = super::dsh_plugin_environment(); + environment.extend(super::managed_child_environment(&launch.root)); + environment.insert( + "FLOWIX_DSH_ROOT".into(), + launch.root.to_string_lossy().into_owned(), + ); + let mut cli_args = Vec::new(); + if let Some(cli) = launch.cli_entrypoint { + cli_args.push(cli.to_string_lossy().into_owned()); + } else { + environment.insert("DSH_EMBEDDED_CLI_MODE".into(), "1".into()); + } + cli_args.extend(args.iter().map(|arg| (*arg).to_string())); + Ok(DshCliLaunch { + executable: launch.executable, + args: cli_args, + environment, + }) +} + +/// The explicit App Server command override keeps the same environment boundary. +pub(crate) fn child_environment() -> HashMap { + super::dsh_plugin_environment() +} diff --git a/app/flowix-desktop/src/lib.rs b/app/flowix-desktop/src/lib.rs index 9a31f61b7..28d1ece7f 100644 --- a/app/flowix-desktop/src/lib.rs +++ b/app/flowix-desktop/src/lib.rs @@ -18,7 +18,6 @@ mod connection_probe; mod device_registration; mod document_mutation; mod document_io; -mod document_derived; mod dsh; mod events; mod frame_scrollbar; @@ -31,10 +30,12 @@ mod runtime_log; #[cfg(windows)] mod runtime_state; mod system_data; +mod storage_initialization; mod template_store; mod update_security; mod watcher; mod window_chrome; +mod native_close_region; pub use app::{get_app_data_path, get_user_config_dir, APP_DATA_DIR_NAME, USER_CONFIG_DIR_NAME}; diff --git a/app/flowix-desktop/src/maintenance/mod.rs b/app/flowix-desktop/src/maintenance/mod.rs index 48db7493a..879320b44 100644 --- a/app/flowix-desktop/src/maintenance/mod.rs +++ b/app/flowix-desktop/src/maintenance/mod.rs @@ -28,7 +28,7 @@ pub(crate) fn spawn_startup_maintenance( if let Err(error) = debug_dumps::cleanup(&user_config_dir.join("debug")) { tracing::warn!("startup debug maintenance failed: {error}"); } - if let Err(error) = backups::prune(&user_config_dir) { + if let Err(error) = backups::prune(&user_config_dir.join("db")) { tracing::warn!("startup backup maintenance failed: {error}"); } diff --git a/app/flowix-desktop/src/memo_events.rs b/app/flowix-desktop/src/memo_events.rs index 4c5028b67..e3fd96569 100644 --- a/app/flowix-desktop/src/memo_events.rs +++ b/app/flowix-desktop/src/memo_events.rs @@ -1,8 +1,9 @@ -//! Unified memo event bus for UI, Agent, cloud, and external-editor writes. +//! Event bus for writes that still use the legacy Memo-shaped domain model. //! //! Created, updated, and deleted mutations share `MEMO_EVENT`. Content //! commits carry revision/changeId metadata, while in-app editor writes also //! carry originWindowLabel so only sibling windows reload the committed bytes. +//! Path-first notebook writes use `flowix:path-note-changed` instead. use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; @@ -12,8 +13,7 @@ use flowix_core::memo_file::Memo; pub const MEMO_EVENT: &str = "memo-event"; -/// 写者标�?—�?informational, 前�?不用于分�?��由�?/// -/// Plan B �?Agent 不再手动 emit, watcher �?Agent / 外部工具的�?�?/// 变更统一归到 `ExternalTool`。`AgentEdit` / `AgentWrite` 这两�?���?/// 已删�?(历史 comment 提到「前�?��用它分支�? 合并后�?义一�?�? +/// MemoEvent 写者来源,供前端展示行为与诊断使用。 #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "snake_case")] pub enum MemoChangeSource { @@ -27,8 +27,9 @@ pub enum MemoChangeSource { UserEdit, /// User explicitly deleted a note. UserDelete, - /// 外部编辑�?/ 其他 AI / Agent 改�?�? 文件监听器�?察到 ── - /// v3 鍚庢墍鏈夐潪鐢ㄦ埛涓诲姩淇濆瓨鐨勮矾寰勯兘鍚堝埌杩欓噷 + /// Reserved for external-tool writes that still produce a MemoEvent. + /// Path-based Agent/CLI writes instead publish notebook path changes; + /// they do not fabricate a legacy Memo row. ExternalTool, /// Flowix Cloud pull applied this write locally. Automatic push scheduling /// must ignore this source to avoid a sync loop. @@ -76,7 +77,7 @@ impl MemoDerivedChanged { #[derive(Serialize, Clone, Debug)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoEvent { - /// 新笔记落�?(新建 / 拖拽 / 粘贴 / import / Agent write 新文�? + /// A note created through the legacy Memo-shaped command surface. Created { memo: Memo, #[serde(rename = "notebookId")] @@ -89,7 +90,6 @@ pub enum MemoEvent { /// Agent edit / 外部工具改�?�?/ 收藏状态变�?。`path` 用于前�?编辑�? /// path 匹配�? Updated { - id: String, path: String, #[serde(rename = "notebookId")] notebook_id: String, @@ -104,7 +104,6 @@ pub enum MemoEvent { }, /// 笔�?�?���?(用户删除 / `clear_memos` / 外部工具 rm 文件) Deleted { - id: String, path: String, /// Notebook-relative path of the deleted note; the path-keyed /// identity that survives the deletion of the legacy id. @@ -120,7 +119,7 @@ pub enum MemoEvent { /// 之前每个 affected memo 都发一次 Updated 的方案。后端已经批量改写 /// 了所有受影响 memo 的 .md body + 同步了 memo index, 这里告诉前端: /// - 哪些路径被重命名 ([old, new], 可能多个 — move 整棵子树时一并改) - /// - 哪些 memo 的 tags 字段需要被前端局部 patch (affected_memo_ids) + /// - 哪些笔记的 tags 字段需要被前端局部 patch (affected_relative_paths) /// /// 跟 Updated 区别: 这是 metadata 操作, 不是单条 memo 写入。前端不 /// 需要把 memo 整体替换 (body/preview/todos 都未变, 只有 tags 数组 @@ -144,13 +143,13 @@ pub enum MemoEvent { }, /// 整棵 tag 子树删除完成 (delete_memo_tag IPC): 一次性发出, 替代 /// 之前每个 affected memo 都发一次 Updated 的方案。后端已经从 - /// memo_tags + memo index 移除所有相关条目, 也从 .md body 里清理了 + /// notes.tags 投影移除相关条目, 也从 Markdown 中清理了标签来源 /// `#tag` token, 这里告诉前端: /// - 哪些 tag 路径被删除 (deleted_tags, 含 tag_path 自身 + 子树) - /// - 哪些 memo 的 tags 字段需要被前端局部清理 (affected_memo_ids) + /// - 哪些笔记的 tags 字段需要被前端局部清理 (affected_relative_paths) /// /// 跟 TagsRenamed 是对称的姊妹事件, 但语义不同: rename 是改写 token, - /// delete 是移除 token。 前端 dispatch 路径用同一套 memo.ids 收窄, + /// delete 是移除 token。前端按相对路径定位受影响的笔记, /// 但处理逻辑不同 (rename → rebase, delete → filter out)。 TagsDeleted { #[serde(rename = "notebookId")] @@ -167,24 +166,6 @@ pub enum MemoEvent { }, } -impl MemoEvent { - /// 事件关联�?memo id。Deleted 总是�?id; Created �?memo 里拿; Updated - /// 直接读字段。没�?id (例�? unregister_memo_by_path 后的 Deleted) 返回 - /// 当前�?��业务逻辑�?���?���? 保留作内部接口�? - pub(crate) fn memo_id(&self) -> &str { - match self { - MemoEvent::Created { memo, .. } => &memo.id, - MemoEvent::Updated { id, .. } => id, - MemoEvent::Deleted { id, .. } => id, - // TagsRenamed / TagsDeleted 不是单条 memo 事件; 调用方按 - // affected_memo_ids 自行处理。 这里返回空串兜底 (不参与 - // memo-event dedup 的 key)。 - MemoEvent::TagsRenamed { .. } => "", - MemoEvent::TagsDeleted { .. } => "", - } - } -} - /// 触发 emit 的薄包�?。失败不 panic (let _ = 吞掉 emit 错�?, �?`agent-chunk` /// �?emit 风格保持一�?—IPC 通道关闭时不该�?业务逻辑�?�?/// /// v3 改造后物理 rename 不再发生, 不再需�?id 二级兜底�? @@ -237,27 +218,27 @@ pub(crate) fn emit_with_recorded_commit_from_window( .. } if !matches!(source, MemoChangeSource::CloudSync) => Some(( notebook_id.clone(), - memo.id.clone(), + memo.relative_path.clone(), flowix_sync::LocalChangeKind::Put, )), MemoEvent::Updated { - id, + memo, notebook_id, source, .. } if !matches!(source, MemoChangeSource::CloudSync) => Some(( notebook_id.clone(), - id.clone(), + memo.relative_path.clone(), flowix_sync::LocalChangeKind::Put, )), MemoEvent::Deleted { - id, + relative_path, notebook_id, source, .. } if !matches!(source, MemoChangeSource::CloudSync) => Some(( notebook_id.clone(), - id.clone(), + relative_path.clone(), flowix_sync::LocalChangeKind::Delete, )), _ => None, @@ -294,7 +275,7 @@ pub(crate) fn emit_with_recorded_commit_from_window( _ => None, }; let Some(relative_path) = relative_path else { - tracing::warn!(notebook_id, memo_id = %note_id, "cloud change has no valid notebook-relative path"); + tracing::warn!(notebook_id, note_path = %note_id, "cloud change has no valid notebook-relative path"); return commit; }; let note_id = flowix_sync::v2_path_note_id(¬ebook_id, &relative_path); @@ -425,7 +406,6 @@ fn emit_committed_via_dispatcher( commit: Option<&DocumentCommit>, origin_window_label: Option<&str>, ) { - let _ = event.memo_id(); let payload = serde_json::to_value(MemoEventPayload { derived_only: false, event, @@ -438,14 +418,13 @@ fn emit_committed_via_dispatcher( #[cfg(test)] mod tests { - //! serde wire-format 测试 —保证与前�?TypeScript 镜像 (app/flowix-web/types/memo.ts) - //! 的硬契约。`kind` 必须�?snake_case, 字�?命名 (memo/id/path/source) �? //! �?IPC 边界的硬约定, 不�?随便改�? + //! Serde wire-format tests for the TypeScript MemoEvent mirror. Event fields + //! are the IPC contract; note entries are addressed by relative path. use super::*; use flowix_core::memo_file::Memo; fn sample_memo() -> Memo { Memo { - id: "abc123".to_string(), filename: "Sample.md".to_string(), relative_path: "Sample.md".to_string(), preview: "preview text".to_string(), @@ -479,8 +458,8 @@ mod tests { assert_eq!(v["source"], "user_new"); assert_eq!(v["notebookId"], "nb_default"); assert_eq!(v["derivedChanged"]["tags"], true); - // memo 字�?保持 camelCase (Memo struct �?���?#[serde(rename = "createdAt")] �? - assert_eq!(v["memo"]["id"], "abc123"); + assert_eq!(v["memo"]["relativePath"], "Sample.md"); + assert!(v["memo"].get("id").is_none()); assert_eq!(v["memo"]["filename"], "Sample.md"); assert_eq!(v["memo"]["thumbnail"], "https://example.com/cover.png"); assert_eq!(v["memo"]["createdAt"], 1_700_000_000_000i64); @@ -489,7 +468,6 @@ mod tests { #[test] fn updated_serializes_with_snake_case_tag() { let event = MemoEvent::Updated { - id: "m_abc".to_string(), path: "/tmp/foo.md".to_string(), notebook_id: "nb_default".to_string(), memo: sample_memo(), @@ -498,7 +476,7 @@ mod tests { }; let v: serde_json::Value = serde_json::to_value(&event).unwrap(); assert_eq!(v["kind"], "updated"); - assert_eq!(v["id"], "m_abc"); + assert!(v.get("id").is_none()); assert_eq!(v["path"], "/tmp/foo.md"); assert_eq!(v["source"], "external_tool"); } @@ -506,7 +484,6 @@ mod tests { #[test] fn committed_event_flattens_revision_contract_in_camel_case() { let event = MemoEvent::Updated { - id: "m_abc".to_string(), path: "/tmp/foo.md".to_string(), notebook_id: "nb_default".to_string(), memo: sample_memo(), @@ -545,7 +522,6 @@ mod tests { #[test] fn deleted_serializes_with_snake_case_tag() { let event = MemoEvent::Deleted { - id: "m_abc".to_string(), path: "/tmp/foo.md".to_string(), relative_path: "foo.md".to_string(), notebook_id: "nb_default".to_string(), @@ -558,7 +534,7 @@ mod tests { }; let v: serde_json::Value = serde_json::to_value(&event).unwrap(); assert_eq!(v["kind"], "deleted"); - assert_eq!(v["id"], "m_abc"); + assert!(v.get("id").is_none()); assert_eq!(v["path"], "/tmp/foo.md"); assert_eq!(v["notebookId"], "nb_default"); assert_eq!(v["derivedChanged"]["todos"], true); diff --git a/app/flowix-desktop/src/native_close_region.rs b/app/flowix-desktop/src/native_close_region.rs new file mode 100644 index 000000000..6a098094f --- /dev/null +++ b/app/flowix-desktop/src/native_close_region.rs @@ -0,0 +1,402 @@ +//! Renderer-independent mouse routing for the existing frameless close button. +//! DOM owns appearance/geometry; a dedicated Raw Input thread observes clicks. +//! Input is never suppressed; window-instance routing deduplicates JS/native intent. + +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CloseRegion { + pub right_inset: f64, + pub top: f64, + pub width: f64, + pub height: f64, +} + +impl CloseRegion { + pub(crate) fn valid(self) -> bool { + [self.right_inset, self.top, self.width, self.height] + .iter() + .all(|n| n.is_finite()) + && self.right_inset >= 0.0 + && self.top >= 0.0 + && self.width > 0.0 + && self.height > 0.0 + && self.width <= 256.0 + && self.height <= 256.0 + && self.top <= 256.0 + } + + #[cfg(any(windows, test))] + fn contains(self, x: f64, y: f64, client_width: f64, scale: f64) -> bool { + let right = client_width - self.right_inset * scale; + x >= right - self.width * scale + && x < right + && y >= self.top * scale + && y < (self.top + self.height) * scale + } +} + +/// A route owns a window-instance callback, never a reusable window label. +#[cfg(any(windows, test))] +#[derive(Clone)] +pub(crate) struct CloseRoute { + pub(crate) id: u64, + is_alive: std::sync::Arc bool + Send + Sync>, + callback: std::sync::Arc, +} +#[cfg(any(windows, test))] +impl CloseRoute { + pub(crate) fn new( + id: u64, + is_alive: impl Fn() -> bool + Send + Sync + 'static, + callback: impl Fn(std::time::Instant) + Send + Sync + 'static, + ) -> Self { + Self { + id, + is_alive: std::sync::Arc::new(is_alive), + callback: std::sync::Arc::new(callback), + } + } + fn is_alive(&self) -> bool { + (self.is_alive)() + } + fn dispatch(&self, started: std::time::Instant) { + (self.callback)(started); + } +} + +#[cfg(any(windows, test))] +#[path = "native_input_health.rs"] +mod health; +#[cfg(windows)] +pub(crate) use windows_impl::{remove, routing_ready, set, start}; + +#[cfg(windows)] +mod windows_impl { + use super::{health::RoutingHealth, CloseRegion, CloseRoute}; + use std::{ + collections::HashMap, + sync::{mpsc, Arc, Mutex, OnceLock}, + time::{Duration, Instant}, + }; + use windows::{ + core::w, + Win32::{ + Foundation::{HWND, RECT}, + Graphics::Gdi::ScreenToClient, + System::{SystemInformation::GetTickCount64, Threading::GetCurrentProcessId}, + UI::{ + HiDpi::{ + GetDpiForWindow, SetThreadDpiAwarenessContext, + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }, + Input::*, + WindowsAndMessaging::*, + }, + }, + }; + + #[derive(Clone)] + struct Entry { + route: CloseRoute, + revision: u64, + region: Option, + dpi: u32, + } + fn regions() -> &'static Mutex> { + static REGIONS: OnceLock>> = OnceLock::new(); + REGIONS.get_or_init(Mutex::default) + } + struct Router { + health: Arc, + } + static ROUTER: OnceLock = OnceLock::new(); + + /// Raw Input is process-owned, read-only mouse input. It cannot be silently + /// removed by LowLevelHooksTimeout, and it never suppresses normal clicks. + /// This module must remain the application's only raw mouse registration. + pub(crate) fn start() -> Result<(), String> { + let router = ROUTER.get_or_init(|| { + let health = Arc::new(RoutingHealth::new()); + let (sender, receiver) = mpsc::channel::<(CloseRoute, Instant)>(); + std::thread::spawn(move || { + for (route, started) in receiver { + // Blocking one Tauri dispatch cannot block input delivery. + std::thread::spawn(move || route.dispatch(started)); + } + }); + let state = health.clone(); + std::thread::spawn(move || loop { + state.retrying(); + // Initialization and GetMessage failures are recoverable. The + // initialized singleton is never confused with active routing. + if unsafe { input_pump(&sender, &state) }.is_err() { + state.retrying(); + } + std::thread::sleep(Duration::from_secs(1)); + }); + Router { health } + }); + // Give first registration a small startup opportunity; never wait for + // the UI thread or indefinitely block application startup. + let until = Instant::now() + Duration::from_millis(100); + while !router.health.ready(Instant::now()) && Instant::now() < until { + std::thread::sleep(Duration::from_millis(5)); + } + if routing_ready() { + Ok(()) + } else { + Err("Native close input is recovering; automatic retry is active".into()) + } + } + pub(crate) fn routing_ready() -> bool { + ROUTER + .get() + .is_some_and(|router| router.health.ready(Instant::now())) + } + + unsafe fn input_pump( + sender: &mpsc::Sender<(CloseRoute, Instant)>, + health: &RoutingHealth, + ) -> Result<(), String> { + if SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + .0 + .is_null() + { + return Err("Per-monitor DPI awareness is unavailable for native close input".into()); + } + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("STATIC"), + w!("Flowix close input"), + WINDOW_STYLE::default(), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + None, + None, + ) + .map_err(|error| error.to_string())?; + let result = pump_registered(hwnd, sender, health); + health.retrying(); + let _ = RegisterRawInputDevices( + &[RAWINPUTDEVICE { + usUsagePage: 1, + usUsage: 2, + dwFlags: RIDEV_REMOVE, + hwndTarget: HWND::default(), + }], + std::mem::size_of::() as u32, + ); + let _ = DestroyWindow(hwnd); + result + } + unsafe fn pump_registered( + hwnd: HWND, + sender: &mpsc::Sender<(CloseRoute, Instant)>, + health: &RoutingHealth, + ) -> Result<(), String> { + RegisterRawInputDevices( + &[RAWINPUTDEVICE { + usUsagePage: 1, + usUsage: 2, + dwFlags: RIDEV_INPUTSINK, + hwndTarget: hwnd, + }], + std::mem::size_of::() as u32, + ) + .map_err(|error| error.to_string())?; + if SetTimer(Some(hwnd), 1, 1000, None) == 0 { + return Err("Native input heartbeat unavailable".into()); + } + health.heartbeat(Instant::now()); + let mut pressed: Option<(usize, CloseRoute)> = None; + let mut message = MSG::default(); + loop { + let result = GetMessageW(&mut message, None, 0, 0).0; + if result <= 0 { + return Err("Native input message pump stopped".into()); + } + // Refresh on any message as well as the timer. Continuous mouse + // traffic can defer WM_TIMER because Windows queues timers last. + health.heartbeat(Instant::now()); + if message.message == WM_INPUT { + let mut raw = RAWINPUT::default(); + let mut size = std::mem::size_of::() as u32; + let copied = GetRawInputData( + HRAWINPUT(message.lParam.0 as _), + RID_INPUT, + Some((&mut raw as *mut RAWINPUT).cast()), + &mut size, + std::mem::size_of::() as u32, + ); + if copied != u32::MAX + && copied >= std::mem::size_of::() as u32 + && raw.header.dwType == RIM_TYPEMOUSE.0 + { + let flags = raw.data.mouse.Anonymous.Anonymous.usButtonFlags as u32; + if flags & (RI_MOUSE_LEFT_BUTTON_DOWN | RI_MOUSE_LEFT_BUTTON_UP) != 0 { + // MSG.pt/time are sampled when input was posted, rather + // than using today's cursor position after queue delay. + let hit = hit_region(message.pt); + if flags & RI_MOUSE_LEFT_BUTTON_DOWN != 0 { + pressed = hit.clone(); + } + if flags & RI_MOUSE_LEFT_BUTTON_UP != 0 { + if let Some((down, route)) = pressed.take() { + if hit.is_some_and(|(up, entry)| up == down && entry.id == route.id) + { + let age = (GetTickCount64() as u32).wrapping_sub(message.time); + let now = Instant::now(); + let started = now + .checked_sub(Duration::from_millis(age as u64)) + .unwrap_or(now); + let _ = sender.send((route, started)); + } + } + } + } + } + // Raw Input foreground messages require native buffer cleanup. + DefWindowProcW( + message.hwnd, + message.message, + message.wParam, + message.lParam, + ); + } else { + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + } + } + + unsafe fn hit_region( + mut point: windows::Win32::Foundation::POINT, + ) -> Option<(usize, CloseRoute)> { + let root = GetAncestor(WindowFromPoint(point), GA_ROOT); + let hwnd = root.0 as usize; + let mut process = 0; + GetWindowThreadProcessId(root, Some(&mut process)); + if process != GetCurrentProcessId() { + return None; + } + let entry = regions().try_lock().ok()?.get(&hwnd)?.clone(); + let region = entry.region?; + let mut client = RECT::default(); + if !ScreenToClient(root, &mut point).as_bool() || GetClientRect(root, &mut client).is_err() + { + return None; + } + region + .contains( + point.x as f64, + point.y as f64, + client.right as f64, + GetDpiForWindow(root).max(96) as f64 / entry.dpi as f64, + ) + .then_some((hwnd, entry.route)) + } + + pub(crate) fn set( + hwnd: usize, + route: CloseRoute, + revision: u64, + region: Option, + ) -> Result<(), String> { + if region.is_some_and(|region| !region.valid()) { + return Err("Invalid close region".into()); + } + let mut process = 0; + unsafe { + GetWindowThreadProcessId(HWND(hwnd as _), Some(&mut process)); + } + if process != unsafe { GetCurrentProcessId() } { + return Err("Close region is not an application window".into()); + } + let dpi = unsafe { GetDpiForWindow(HWND(hwnd as _)) }.max(96); + let mut entries = regions().lock().unwrap_or_else(|e| e.into_inner()); + // A delayed IPC from a destroyed window must not replace a new + // instance's route after Windows reuses the same HWND. + if !route.is_alive() { + return Err("Window has already been destroyed".into()); + } + if entries + .get(&hwnd) + .is_some_and(|entry| entry.route.id == route.id && entry.revision >= revision) + { + return Ok(()); + } + entries.insert( + hwnd, + Entry { + route, + revision, + region, + dpi, + }, + ); + Ok(()) + } + pub(crate) fn remove(id: u64) { + regions() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|_, entry| entry.route.id != id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + fn button() -> CloseRegion { + CloseRegion { + right_inset: 0.0, + top: 0.0, + width: 42.0, + height: 36.0, + } + } + #[test] + fn close_does_not_capture_adjacent_maximize_or_content() { + let button = button(); + assert!(button.contains(999.0, 35.0, 1000.0, 1.0)); + assert!(!button.contains(957.0, 10.0, 1000.0, 1.0)); + assert!(!button.contains(990.0, 36.0, 1000.0, 1.0)); + } + #[test] + fn region_follows_resize_and_dpi_without_renderer_updates() { + assert!(button().contains(1999.0, 71.0, 2000.0, 2.0)); + assert!(!button().contains(1915.0, 10.0, 2000.0, 2.0)); + assert!(button().contains(499.0, 10.0, 500.0, 1.0)); + } + #[test] + fn invalid_region_cannot_capture_window_content() { + assert!(!CloseRegion { + width: f64::NAN, + ..button() + } + .valid()); + assert!(!CloseRegion { + height: 1000.0, + ..button() + } + .valid()); + } + + #[test] + fn captured_close_route_stops_accepting_input_after_its_window_dies() { + let alive = Arc::new(AtomicBool::new(true)); + let route_alive = alive.clone(); + let route = CloseRoute::new(7, move || route_alive.load(Ordering::Acquire), |_| {}); + assert!(route.is_alive()); + alive.store(false, Ordering::Release); + assert!(!route.is_alive()); + } +} diff --git a/app/flowix-desktop/src/native_input_health.rs b/app/flowix-desktop/src/native_input_health.rs new file mode 100644 index 000000000..3c46caa16 --- /dev/null +++ b/app/flowix-desktop/src/native_input_health.rs @@ -0,0 +1,58 @@ +//! Initialization is not availability. Heartbeats expire and retry can recover. +use std::{ + sync::Mutex, + time::{Duration, Instant}, +}; + +pub(super) struct RoutingHealth { + last_heartbeat: Mutex>, +} +impl RoutingHealth { + pub(super) fn new() -> Self { + Self { + last_heartbeat: Mutex::new(None), + } + } + pub(super) fn retrying(&self) { + *self + .last_heartbeat + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + } + pub(super) fn heartbeat(&self, now: Instant) { + *self + .last_heartbeat + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(now); + } + pub(super) fn ready(&self, now: Instant) -> bool { + self.last_heartbeat + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some_and(|last| now.saturating_duration_since(last) < Duration::from_secs(3)) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn failed_initialization_is_not_reported_as_ready_on_retry() { + let status = RoutingHealth::new(); + let now = Instant::now(); + status.retrying(); + assert!(!status.ready(now)); + status.retrying(); + assert!(!status.ready(now)); + status.heartbeat(now); + assert!(status.ready(now)); + } + #[test] + fn stalled_pump_is_detected_and_a_recovered_pump_becomes_ready() { + let status = RoutingHealth::new(); + let now = Instant::now(); + status.heartbeat(now); + assert!(!status.ready(now + Duration::from_secs(4))); + status.heartbeat(now + Duration::from_secs(4)); + assert!(status.ready(now + Duration::from_secs(4))); + } +} diff --git a/app/flowix-desktop/src/plugin.rs b/app/flowix-desktop/src/plugin.rs index d3a114b26..bca86fb8f 100644 --- a/app/flowix-desktop/src/plugin.rs +++ b/app/flowix-desktop/src/plugin.rs @@ -119,7 +119,7 @@ pub fn emit_run_event(app_handle: &tauri::AppHandle, event: PluginRunEvent) -> R fn plugin_root() -> Result { let home = dirs::home_dir().ok_or_else(|| "Home directory is unavailable".to_string())?; let root = get_user_config_dir(&home).join("plugin"); - fs::create_dir_all(&root).map_err(|e| format!("create plugin directory: {e}"))?; + flowix_core::memo_file::ensure_storage_directory(&root).map_err(|e| format!("create plugin directory: {e}"))?; let metadata = fs::symlink_metadata(&root) .map_err(|error| format!("inspect plugin directory: {error}"))?; if metadata.file_type().is_symlink() || !metadata.is_dir() { @@ -169,43 +169,38 @@ fn ensure_builtin_plugin( let plugin = root.join(plugin_id); match fs::symlink_metadata(&plugin) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err(format!("built-in {plugin_id} plugin path is invalid")); + flowix_core::memo_file::backup_storage_file(&plugin) + .map_err(|error| error.to_string())?; + fs::create_dir_all(&plugin).map_err(|error| error.to_string())?; } + // Existing files stay untouched; missing files use fresh-install assets. Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { fs::create_dir_all(&plugin).map_err(|e| format!("create {plugin_id} plugin: {e}"))?; } Err(error) => return Err(format!("inspect {plugin_id} plugin: {error}")), } - let manifest = plugin.join("plugin.json"); - // The built-in plugin is versioned with the host application. Older - // installations may still have the pre-declaration manifest (including - // the removed agent selector), which would fail validation and silently - // disappear from the sidebar. Reconcile it on every startup so the - // built-in definition is migrated before plugin discovery runs. - let needs_manifest_migration = match fs::read_to_string(&manifest) { - Ok(existing) => existing != expected_manifest, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(error) => { - return Err(format!("read {plugin_id} manifest: {error}")); + for (name, content) in [ + ("plugin.json", expected_manifest), + ("SKILL.md", expected_skill), + ] { + let path = plugin.join(name); + match fs::read(&path) { + Ok(bytes) if bytes == content.as_bytes() => continue, + Ok(_) => { + flowix_core::memo_file::backup_storage_file(&path) + .map_err(|error| error.to_string())?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("read {plugin_id} {name}: {error}")), } - }; - if needs_manifest_migration { - fs::write(&manifest, expected_manifest) - .map_err(|e| format!("write {plugin_id} manifest: {e}"))?; - } - let skill = plugin.join("SKILL.md"); - let needs_skill_migration = match fs::read_to_string(&skill) { - Ok(existing) => existing != expected_skill, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(error) => return Err(format!("read {plugin_id} skill: {error}")), - }; - if needs_skill_migration { - fs::write(&skill, expected_skill).map_err(|e| format!("write {plugin_id} skill: {e}"))?; + flowix_core::memo_file::atomic_create_bytes(&path, content.as_bytes()) + .map_err(|error| format!("create {plugin_id} {name}: {error}"))?; } Ok(()) } + fn read_plugin(path: &Path) -> Result { let expected_id = path .file_name() @@ -509,7 +504,8 @@ fn scan_plugin_catalog() -> Result { }; for entry in fs::read_dir(&root).map_err(|e| format!("scan plugins: {e}"))? { let entry = entry.map_err(|e| format!("scan plugin entry: {e}"))?; - if !entry.path().is_dir() || entry.file_name().to_string_lossy().starts_with('.') { + if !entry.path().is_dir() || entry.file_name().to_string_lossy().starts_with('.') + || entry.file_name() == "rebuild-backups" { continue; } match read_plugin(&entry.path()) { @@ -784,6 +780,24 @@ mod tests { PluginRuntime, MINDMAP_MANIFEST, MINDMAP_SKILL, }; + #[test] + fn missing_runtime_dependencies_recreate_invalid_plugin_files() { + let temp = tempfile::tempdir().unwrap(); + super::ensure_builtin_plugin(temp.path(), "mindmap", MINDMAP_MANIFEST, MINDMAP_SKILL).unwrap(); + let dir = temp.path().join("mindmap"); + std::fs::write(dir.join("plugin.json"), "existing manifest").unwrap(); + std::fs::remove_file(dir.join("SKILL.md")).unwrap(); + super::ensure_builtin_plugin(temp.path(), "mindmap", MINDMAP_MANIFEST, MINDMAP_SKILL).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.join("plugin.json")).unwrap(), + MINDMAP_MANIFEST + ); + assert_eq!( + std::fs::read_to_string(dir.join("SKILL.md")).unwrap(), + MINDMAP_SKILL + ); + } + #[test] fn plugin_workspace_requires_a_registered_root_before_execution() { use flowix_core::memo_file::{MemoFile, NotebookConfig}; diff --git a/app/flowix-desktop/src/runtime_state.rs b/app/flowix-desktop/src/runtime_state.rs index 7a9c7378c..becc5bf1e 100644 --- a/app/flowix-desktop/src/runtime_state.rs +++ b/app/flowix-desktop/src/runtime_state.rs @@ -1,183 +1,8 @@ //! Mutable Windows state must not share the executable installation directory. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; pub fn windows_state_dir() -> Result { std::env::var_os("LOCALAPPDATA") .map(|root| PathBuf::from(root).join("FlowixData")) .ok_or_else(|| "LOCALAPPDATA is unavailable".to_string()) } - -pub fn migrate_pi_sessions(local_data: &Path) -> Result { - let old = local_data.join("Flowix").join("pi-sessions"); - let new = local_data.join("FlowixData").join("pi-sessions"); - if old.exists() { - if new.exists() { - // Coexisting stores are resolved per conversation without merging. - return Ok(new); - } - std::fs::create_dir_all(new.parent().unwrap()).map_err(|e| e.to_string())?; - // Move only this known data directory. Never overwrite, merge or remove - // installation files. Failure leaves the legacy data available. - std::fs::rename(&old, &new).map_err(|e| { - format!( - "Cannot migrate Pi sessions from {} to {}: {e}", - old.display(), - new.display() - ) - })?; - } - Ok(new) -} - -/// Select existing history in place, without overwriting or hiding a conflict. -pub fn resolve_pi_session_candidates(candidates: &[PathBuf]) -> Result { - let mut selected: Option<&PathBuf> = None; - for candidate in candidates { - if candidate.try_exists().map_err(|error| { - format!( - "cannot inspect Pi session directory {}: {error}", - candidate.display() - ) - })? { - if let Some(previous) = selected { - return Err(format!( - "Pi conversation has histories at both {} and {}; preserve both and resolve this conversation's conflict before continuing", - previous.display(), candidate.display() - )); - } - selected = Some(candidate); - } - } - selected - .or_else(|| candidates.first()) - .cloned() - .ok_or_else(|| "Pi session has no candidate directory".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - struct Fixture(PathBuf); - impl Fixture { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "flowix-state-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&path).unwrap(); - Self(path) - } - } - impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - #[test] - fn migrates_nested_sessions_once_without_touching_program_files() { - let f = Fixture::new(); - let old = f.0.join("Flowix/pi-sessions/thread"); - std::fs::create_dir_all(&old).unwrap(); - std::fs::write(old.join("history.jsonl"), b"saved conversation").unwrap(); - std::fs::write(f.0.join("Flowix/Flowix.exe"), b"program").unwrap(); - let new = migrate_pi_sessions(&f.0).unwrap(); - assert_eq!( - std::fs::read(new.join("thread/history.jsonl")).unwrap(), - b"saved conversation" - ); - assert_eq!(migrate_pi_sessions(&f.0).unwrap(), new); - assert_eq!( - std::fs::read(f.0.join("Flowix/Flowix.exe")).unwrap(), - b"program" - ); - } - #[test] - fn never_overwrites_an_existing_session_store() { - let f = Fixture::new(); - for base in ["Flowix", "FlowixData"] { - let dir = f.0.join(base).join("pi-sessions"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("session"), base).unwrap(); - } - assert_eq!( - migrate_pi_sessions(&f.0).unwrap(), - f.0.join("FlowixData/pi-sessions") - ); - assert_eq!( - std::fs::read(f.0.join("Flowix/pi-sessions/session")).unwrap(), - b"Flowix" - ); - assert_eq!( - std::fs::read(f.0.join("FlowixData/pi-sessions/session")).unwrap(), - b"FlowixData" - ); - } - - #[test] - fn coexisting_stores_resolve_distinct_conversations_and_new_sessions() { - let f = Fixture::new(); - let old_root = f.0.join("Flowix/pi-sessions"); - let new_root = f.0.join("FlowixData/pi-sessions"); - std::fs::create_dir_all(old_root.join("old-thread")).unwrap(); - std::fs::create_dir_all(new_root.join("new-thread")).unwrap(); - assert_eq!(migrate_pi_sessions(&f.0).unwrap(), new_root); - assert_eq!(migrate_pi_sessions(&f.0).unwrap(), new_root); - for (name, expected) in [ - ("old-thread", old_root.join("old-thread")), - ("new-thread", new_root.join("new-thread")), - ("fresh", new_root.join("fresh")), - ] { - assert_eq!( - resolve_pi_session_candidates(&[new_root.join(name), old_root.join(name)]).unwrap(), - expected - ); - } - assert!(!new_root.join("old-thread").exists()); - assert!(!new_root.join("fresh").exists()); - } - - #[test] - fn duplicate_conversation_preserves_both_histories() { - let f = Fixture::new(); - let candidates = [f.0.join("new/thread"), f.0.join("old/thread")]; - for (index, path) in candidates.iter().enumerate() { - std::fs::create_dir_all(path).unwrap(); - std::fs::write(path.join("history.jsonl"), index.to_string()).unwrap(); - } - assert!(resolve_pi_session_candidates(&candidates) - .unwrap_err() - .contains("both")); - for (index, path) in candidates.iter().enumerate() { - assert_eq!( - std::fs::read_to_string(path.join("history.jsonl")).unwrap(), - index.to_string() - ); - } - } - - #[test] - fn legacy_hash_directory_remains_accessible_without_renaming() { - let f = Fixture::new(); - let candidates = [ - f.0.join("FlowixData/pi-sessions/stable"), - f.0.join("FlowixData/pi-sessions/legacy"), - f.0.join("Flowix/pi-sessions/stable"), - f.0.join("Flowix/pi-sessions/legacy"), - ]; - std::fs::create_dir_all(&candidates[3]).unwrap(); - std::fs::write(candidates[3].join("history.jsonl"), b"legacy").unwrap(); - assert_eq!( - resolve_pi_session_candidates(&candidates).unwrap(), - candidates[3] - ); - assert_eq!( - std::fs::read(candidates[3].join("history.jsonl")).unwrap(), - b"legacy" - ); - assert!(!candidates[0].exists()); - } -} diff --git a/app/flowix-desktop/src/storage_initialization.rs b/app/flowix-desktop/src/storage_initialization.rs new file mode 100644 index 000000000..680e9f6b3 --- /dev/null +++ b/app/flowix-desktop/src/storage_initialization.rs @@ -0,0 +1,732 @@ +//! One-time cleanup and rebuilding of legacy user storage. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use flowix_core::memo_file::{MemoFile, NotebookConfig, NotebookInitializationJob}; +use rusqlite::Connection; + +use crate::device_registration::DeviceRegistry; + +const NOTEBOOK_STORAGE_RESET_VERSION: u32 = 1; +// An interruption signal, not a completion record. Keep it until the single +// reset job completion has been persisted in local.db. +const NOTEBOOK_RESET_PENDING_FILE: &str = "storage-reset-pending"; + +pub const CONFIG_LOCAL_MIGRATIONS_JOB: &str = "config_local_migrations_v1"; + +#[derive(Clone, Copy)] +enum GlobalInitializationJob { + AgentLayout, + LocalConfiguration, + WatcherSettings, + Templates, +} + +impl GlobalInitializationJob { + const ALL: [Self; 4] = [ + Self::AgentLayout, + Self::LocalConfiguration, + Self::WatcherSettings, + Self::Templates, + ]; + + fn key(self) -> &'static str { + match self { + Self::AgentLayout => "config_dsh_layout_v2", + Self::LocalConfiguration => CONFIG_LOCAL_MIGRATIONS_JOB, + Self::WatcherSettings => "config_watcher_exclusions_v1", + Self::Templates => "templates_v1", + } + } + + fn is_optional(self) -> bool { + matches!( + self, + Self::AgentLayout | Self::WatcherSettings | Self::Templates + ) + } + + fn run( + self, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + ) -> Result<(), String> { + match self { + Self::AgentLayout => user_config.initialize_legacy_dsh_layout(), + Self::LocalConfiguration => user_config.initialize_legacy_config_migrations(), + Self::WatcherSettings => user_config + .clear_legacy_watcher_exclusions() + .map_err(|error| format!("clear legacy watcher exclusions failed: {error}")), + Self::Templates => crate::template_store::initialize(user_config_dir) + .map_err(|error| format!("initialize templates failed: {error}")), + } + } +} + +const RETIRED_INITIALIZATION_JOB_IDS: [&str; 6] = [ + "storage_reset_v1:plugin", + "storage_reset_v1:notebook_settings", + "storage_reset_v1:index", + "storage_reset_v1:recovery", + "storage_reset_v1:db_artifacts", + "builtin_plugins_v1", +]; + +/// Move global databases before any store opens a connection. +pub fn prepare_database_paths(config_dir: &Path) -> io::Result { + let database_dir = flowix_core::memo_file::prepare_database_directory( + config_dir, + &["local.db", "sync.db", "thread.db", "default.db"], + )?; + // Credentials in the secret store must remain private after VACUUM INTO + // publishes the migrated snapshot. + #[cfg(unix)] + if database_dir.join("default.db").is_file() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + database_dir.join("default.db"), + fs::Permissions::from_mode(0o600), + )?; + } + Ok(database_dir) +} + +/// Retire the unused global index only after current storage initialization succeeds. +/// Preserve both layouts and SQLite companions for manual recovery; never open or +/// import the retired database. Cleanup failures never block initialization. +pub fn retire_global_index_databases(config_dir: &Path) { + for path in [config_dir.join("index.db"), config_dir.join("db/index.db")] { + if let Err(error) = flowix_core::memo_file::backup_database(&path) { + tracing::warn!(path = %path.display(), %error, "skipping retired index cleanup"); + } + } +} + +/// Run unfinished one-time initialization jobs and retry notebooks that were +/// offline when initialization first ran. Notebook content is never removed. +pub fn initialize_legacy_storage( + device_registry: &DeviceRegistry, + memo_file: &MemoFile, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + app_data_dir: &Path, +) -> Result<(), String> { + initialize_legacy_storage_with_progress( + device_registry, + memo_file, + user_config, + user_config_dir, + app_data_dir, + |_, _, _| {}, + ) +} + +pub fn initialize_legacy_storage_with_progress( + device_registry: &DeviceRegistry, + memo_file: &MemoFile, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + _app_data_dir: &Path, + mut report: impl FnMut(usize, usize, &str), +) -> Result<(), String> { + let notebooks = memo_file + .read_notebook_configs() + .map_err(|error| error.to_string())?; + // Four global jobs, six retired markers, and the per-notebook jobs. + // Offline notebook jobs resolve as deferred units instead of stalling progress. + let total = GlobalInitializationJob::ALL.len() + + RETIRED_INITIALIZATION_JOB_IDS.len() + + notebooks.len() * NotebookInitializationJob::ALL.len(); + let mut completed = 0usize; + for job in GlobalInitializationJob::ALL { + let result = run_job_with_progress( + memo_file, + job.key(), + || job.run(user_config, user_config_dir), + &mut report, + &mut completed, + total, + ); + if let Err(error) = result { + if !job.is_optional() { + return Err(error); + } + tracing::warn!(job_id = job.key(), %error, "optional initialization job deferred"); + } + } + let reset_version = device_registry.notebook_storage_reset_version(); + // Missing completion records are not permission to delete surviving data. + // Retire the old global cleanup jobs; fresh installs and rebuilt registries + // now create only the dependencies they need. + for key in RETIRED_INITIALIZATION_JOB_IDS { + report(completed, total, key); + memo_file + .mark_local_migration_completed(key) + .map_err(|error| error.to_string())?; + completed += 1; + report(completed, total, key); + } + + reset_pending_notebooks_with_progress( + memo_file, + ¬ebooks, + &mut report, + &mut completed, + total, + )?; + if reset_version < NOTEBOOK_STORAGE_RESET_VERSION { + device_registry.complete_notebook_storage_reset_version(NOTEBOOK_STORAGE_RESET_VERSION)?; + } + tracing::info!( + completed_reset_version = reset_version.max(NOTEBOOK_STORAGE_RESET_VERSION), + "legacy notebook storage reset completed" + ); + Ok(()) +} + +fn run_job_with_progress( + memo_file: &MemoFile, + key: &str, + operation: impl FnOnce() -> Result<(), String>, + report: &mut impl FnMut(usize, usize, &str), + completed: &mut usize, + total: usize, +) -> Result<(), String> { + report(*completed, total, key); + let result = run_job(memo_file, key, operation); + *completed += 1; + report(*completed, total, key); + result +} + +fn run_job( + memo_file: &MemoFile, + key: &str, + operation: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + if memo_file + .local_migration_completed(key) + .map_err(|error| format!("read initialization job {key} progress failed: {error}"))? + { + tracing::debug!(job_id = key, "initialization job already completed"); + return Ok(()); + } + let started = std::time::Instant::now(); + tracing::info!(job_id = key, "initialization job started"); + if let Err(error) = operation() { + crate::runtime_log::record_event( + "error", + "startup.job_failed", + serde_json::json!({ + "jobId": key, "error": error, "elapsedMs": started.elapsed().as_millis() + }) + .to_string(), + ); + return Err(format!("initialization job {key} failed: {error}")); + } + memo_file + .mark_local_migration_completed(key) + .map_err(|error| format!("record initialization job {key} completion failed: {error}"))?; + tracing::info!(job_id = key, "initialization job completed"); + Ok(()) +} + +fn reset_pending_notebooks(memo_file: &MemoFile) -> Result<(), String> { + let notebooks = memo_file + .read_notebook_configs() + .map_err(|error| format!("read notebooks for storage reset retry failed: {error}"))?; + let total = notebooks.len() * NotebookInitializationJob::ALL.len(); + let mut completed = 0; + reset_pending_notebooks_with_progress( + memo_file, + ¬ebooks, + &mut |_, _, _| {}, + &mut completed, + total, + ) +} + +fn reset_pending_notebooks_with_progress( + memo_file: &MemoFile, + notebooks: &[NotebookConfig], + report: &mut impl FnMut(usize, usize, &str), + completed: &mut usize, + total: usize, +) -> Result<(), String> { + for notebook in notebooks { + for (job_index, job) in NotebookInitializationJob::ALL.into_iter().enumerate() { + let key = job.key(¬ebook.id); + if memo_file + .local_migration_completed(&key) + .map_err(|error| error.to_string())? + { + // This job is recorded once, but notebook schemas evolve over + // time. Re-run the idempotent schema initializers on every + // startup so existing notebooks receive newer columns and + // data migrations after the one-time job has completed. + if matches!(job, NotebookInitializationJob::InitializeSchema) { + if !Path::new(¬ebook.path).is_dir() { + tracing::warn!(job_id = %key, notebook = %notebook.id, + "notebook schema refresh deferred until the notebook is available"); + break; + } + memo_file + .retire_legacy_memo_index(¬ebook.id) + .and_then(|_| memo_file.initialize_media_resource_schema(¬ebook.id)) + .map_err(|error| { + format!("refresh notebook {} database schema failed: {error}", notebook.id) + })?; + } + report(*completed, total, &key); + *completed += 1; + report(*completed, total, &key); + if matches!(job, NotebookInitializationJob::ResetLegacyStorage) { + clear_notebook_reset_signal_best_effort(notebook); + } + continue; + } + if !Path::new(¬ebook.path).is_dir() { + tracing::warn!(job_id = %key, notebook = %notebook.id, + "notebook initialization deferred until the notebook is available"); + for deferred_job in NotebookInitializationJob::ALL.iter().skip(job_index) { + let deferred_key = deferred_job.key(¬ebook.id); + *completed += 1; + report(*completed, total, &format!("deferred:{deferred_key}")); + } + break; + } + run_job_with_progress( + memo_file, + &key, + || match job { + NotebookInitializationJob::ResetLegacyStorage => { + if notebook_requires_reset(memo_file, notebook)? { + reset_notebook(memo_file, notebook)?; + } + Ok(()) + } + NotebookInitializationJob::InitializeSchema => { + // Retrying schema preparation must preserve a database + // already rebuilt by a successful reset job. + memo_file + .retire_legacy_memo_index(¬ebook.id) + .and_then(|_| memo_file.initialize_media_resource_schema(¬ebook.id)) + .map_err(|error| { + format!( + "initialize notebook {} database schema failed: {error}", + notebook.id + ) + }) + } + NotebookInitializationJob::MigrateMediaResourceIdentity => memo_file + .migrate_media_resource_identities(¬ebook.id) + .map(|count| { + tracing::info!(notebook_id = %notebook.id, changed = count, + "media resource identity migration completed"); + }) + .map_err(|error| format!("migrate notebook {} media identities failed: {error}", notebook.id)), + }, + report, + completed, + total, + )?; + if matches!(job, NotebookInitializationJob::ResetLegacyStorage) { + clear_notebook_reset_signal_best_effort(notebook); + } + } + } + Ok(()) +} + +fn clear_notebook_reset_signal_best_effort(notebook: &NotebookConfig) { + let dir = Path::new(¬ebook.path).join(".flowix"); + match fs::symlink_metadata(&dir) { + // Never follow a replaced .flowix symlink to delete a signal elsewhere. + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + if let Err(error) = remove_path(&dir.join(NOTEBOOK_RESET_PENDING_FILE)) { + tracing::warn!(notebook_id = %notebook.id, %error, + "remove completed notebook reset signal failed"); + } + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(notebook_id = %notebook.id, %error, + "inspect completed notebook reset directory failed"), + } +} + +fn notebook_requires_reset( + memo_file: &MemoFile, + notebook: &NotebookConfig, +) -> Result { + let root = Path::new(¬ebook.path); + let flowix = root.join(".flowix"); + if memo_file + .has_legacy_notebook_internal_data(¬ebook.id) + .map_err(|error| format!("inspect notebook artifacts failed: {error}"))? + || [ + flowix.join("agent.json"), + flowix.join(NOTEBOOK_RESET_PENDING_FILE), + ] + .iter() + .any(|path| path.exists()) + { + return Ok(true); + } + // Read old checkpoints only to resume resets interrupted before this + // simplified implementation. New resets never write stage completions. + for stage in [ + "plugin-artifacts", + "derived-reset", + "note-index", + "media-index", + ] { + let key = format!("storage_reset_v1:notebook:{}:{stage}", notebook.id); + if memo_file + .local_migration_completed(&key) + .map_err(|error| error.to_string())? + { + return Ok(true); + } + } + legacy_notebook_database(&flowix.join("notebook.db")) + .map_err(|error| format!("inspect notebook database failed: {error}")) +} + +fn legacy_notebook_database(path: &Path) -> io::Result { + if !path.is_file() { + return Ok(false); + } + let conn = Connection::open(path).map_err(sqlite_to_io)?; + let names = { + let mut statement = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .map_err(sqlite_to_io)?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + let has_old_memos = names.iter().any(|name| name == "memos"); + let has_old_v2_projection = names.iter().any(|name| name.starts_with("v2_")); + // Memo tables are migrated in place by the schema job so their + // non-derivable properties can be preserved before they are retired. + Ok(has_old_v2_projection && !has_old_memos) +} + +fn path_entry_exists(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +// Retry the complete reset safely. Only the enclosing run_job records success. +fn reset_notebook(memo_file: &MemoFile, notebook: &NotebookConfig) -> Result<(), String> { + let root = Path::new(¬ebook.path); + let flowix_dir = root.join(".flowix"); + if fs::symlink_metadata(&flowix_dir).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Err(format!( + "notebook .flowix directory is a symbolic link: {}", + flowix_dir.display() + )); + } + fs::create_dir_all(&flowix_dir) + .map_err(|error| format!("create notebook Flowix directory failed: {error}"))?; + // Write before moving any legacy source or deleting any derived data. + flowix_core::memo_file::atomic_write_bytes(&flowix_dir.join(NOTEBOOK_RESET_PENDING_FILE), b"1") + .map_err(|error| format!("persist notebook reset interruption signal failed: {error}"))?; + + MemoFile::ensure_notebook_manifest(notebook) + .map_err(|error| format!("restore notebook identity manifest failed: {error}"))?; + let migration = memo_file + .migrate_notebook_internal_data(¬ebook.id) + .map_err(|error| format!("preserve notebook plugin artifacts failed: {error}"))?; + if !migration.completed { + return Err(format!( + "notebook {} plugin artifact migration is incomplete: {:?}", + notebook.id, migration.warnings + )); + } + + // Rebuild the path index from Markdown, then discard the old Memo-ID + // projection. Derived note metadata is recreated from the source files. + memo_file + .retire_legacy_memo_index(¬ebook.id) + .map_err(|error| format!("migrate notebook note index failed: {error}"))?; + memo_file + .initialize_media_resource_schema(¬ebook.id) + .map_err(|error| format!("initialize notebook media schema failed: {error}"))?; + memo_file.invalidate_caches(); + for name in [ + "view-preferences.json", + "system.json", + "file-management-refresh-pending", + ] { + flowix_core::memo_file::backup_storage_file(&flowix_dir.join(name)) + .map_err(|error| format!("preserve notebook {name} failed: {error}"))?; + } + crate::commands::file::write_default_notebook_view_preferences(root)?; + memo_file + .reconcile_note_index_blocking(¬ebook.id) + .map_err(|error| format!("rebuild notebook note index failed: {error}"))?; + memo_file + .reconcile_media_resources(¬ebook.id) + .map_err(|error| format!("rebuild notebook media index failed: {error}"))?; + + // Discard the obsolete configuration after preservation and rebuilding. + // The pending signal survives until run_job has recorded overall success. + for name in ["agent.json", "notebook-template-initializing.json"] { + remove_path(&flowix_dir.join(name)) + .map_err(|error| format!("remove notebook {name} failed: {error}"))?; + } + Ok(()) +} + +fn remove_path(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + fs::remove_file(path) + } + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported path type: {}", path.display()), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn sqlite_to_io(error: rusqlite::Error) -> io::Error { + io::Error::other(error) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, MemoFile, NotebookConfig) { + let temp = tempfile::tempdir().unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.initialize_registry_storage().unwrap(); + let root = temp.path().join("notebook"); + fs::create_dir_all(&root).unwrap(); + let notebook = NotebookConfig { + id: "test".into(), + name: "notebook".into(), + icon: None, + path: root.to_string_lossy().into_owned(), + is_default: true, + sort: 0, + created_at: 0, + updated_at: 0, + }; + memo.write_notebook_configs(&[notebook.clone()]).unwrap(); + (temp, memo, notebook) + } + + #[test] + fn storage_rebuild_missing_markers_preserves_plugins_and_drafts() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = home.join(crate::USER_CONFIG_DIR_NAME); + let app_data = home.join("app-data"); + fs::create_dir_all(config.join("plugin/custom")).unwrap(); + fs::write(config.join("plugin/custom/kept.txt"), b"custom plugin").unwrap(); + fs::create_dir_all(app_data.join("recovery-drafts")).unwrap(); + fs::write(app_data.join("recovery-drafts/kept.md"), b"draft").unwrap(); + let user = crate::config::UserConfigStore::new(home); + let device = DeviceRegistry::load(&config, "test"); + let memo = MemoFile::new(config.clone()); + memo.initialize_registry_storage().unwrap(); + initialize_legacy_storage(&device, &memo, &user, &config, &app_data).unwrap(); + assert_eq!( + fs::read(config.join("plugin/custom/kept.txt")).unwrap(), + b"custom plugin" + ); + assert_eq!( + fs::read(app_data.join("recovery-drafts/kept.md")).unwrap(), + b"draft" + ); + } + + #[test] + fn registry_initialization_prepares_workspace_tables() { + let temp = tempfile::tempdir().unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.initialize_registry_storage().unwrap(); + let conn = Connection::open(memo.registry_db_path()).unwrap(); + for table in ["notebook_add_dirs", "notebook_workspace_state"] { + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + } + + #[test] + fn current_view_preferences_do_not_require_reset() { + let (_temp, memo, notebook) = fixture(); + crate::commands::file::write_default_notebook_view_preferences(Path::new(¬ebook.path)) + .unwrap(); + assert!(!notebook_requires_reset(&memo, ¬ebook).unwrap()); + } + + #[test] + fn schema_retry_preserves_database_after_completed_reset() { + let (_temp, memo, notebook) = fixture(); + crate::commands::file::write_default_notebook_view_preferences(Path::new(¬ebook.path)) + .unwrap(); + let path = Path::new(¬ebook.path).join(".flowix/notebook.db"); + Connection::open(&path).unwrap().execute_batch("CREATE TABLE retained_probe (value TEXT); INSERT INTO retained_probe VALUES ('keep');").unwrap(); + memo.mark_local_migration_completed( + &NotebookInitializationJob::ResetLegacyStorage.key(¬ebook.id), + ) + .unwrap(); + reset_pending_notebooks(&memo).unwrap(); + let value: String = Connection::open(path) + .unwrap() + .query_row("SELECT value FROM retained_probe", [], |row| row.get(0)) + .unwrap(); + assert_eq!(value, "keep"); + assert!(!memo.has_pending_notebook_storage_initialization().unwrap()); + } + + #[test] + fn current_status_markers_do_not_reset_preferences() { + let (_temp, memo, notebook) = fixture(); + let flowix = Path::new(¬ebook.path).join(".flowix"); + fs::create_dir_all(&flowix).unwrap(); + fs::write( + flowix.join("view-preferences.json"), + b"{\"default_create_folder\":\"custom\"}", + ) + .unwrap(); + fs::write(flowix.join("file-management-refresh-pending"), b"refresh").unwrap(); + fs::write(flowix.join("notebook-template-initializing.json"), b"{}").unwrap(); + assert!(!notebook_requires_reset(&memo, ¬ebook).unwrap()); + reset_pending_notebooks(&memo).unwrap(); + assert_eq!( + fs::read(flowix.join("view-preferences.json")).unwrap(), + b"{\"default_create_folder\":\"custom\"}" + ); + assert!(flowix.join("file-management-refresh-pending").exists()); + assert!(flowix.join("notebook-template-initializing.json").exists()); + } + + fn assert_reset_rebuilds_note(memo: &MemoFile, notebook: &NotebookConfig) { + let root = Path::new(¬ebook.path); + fs::write(root.join("retained.md"), "# Retained note\nbody").unwrap(); + reset_pending_notebooks(memo).unwrap(); + let conn = Connection::open(root.join(".flowix/notebook.db")).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM notes WHERE relative_path = 'retained.md'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!( + fs::read_to_string(root.join("retained.md")).unwrap(), + "# Retained note\nbody" + ); + assert!(!root + .join(".flowix") + .join(NOTEBOOK_RESET_PENDING_FILE) + .exists()); + assert!(memo + .local_migration_completed( + &NotebookInitializationJob::ResetLegacyStorage.key(¬ebook.id) + ) + .unwrap()); + } + + #[test] + fn interrupted_reset_rebuilds_indexes_and_records_only_overall_completion() { + let (_temp, memo, notebook) = fixture(); + let flowix = Path::new(¬ebook.path).join(".flowix"); + fs::create_dir_all(&flowix).unwrap(); + // Simulate interruption after legacy sources and derived DB disappeared. + fs::write(flowix.join(NOTEBOOK_RESET_PENDING_FILE), b"1").unwrap(); + assert_reset_rebuilds_note(&memo, ¬ebook); + let conn = Connection::open(memo.registry_db_path()).unwrap(); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM local_migrations WHERE migration_key LIKE 'storage_reset_v1:notebook:test:%'", [], |row| row.get(0)).unwrap(); + assert_eq!(count, 0, "new resets must not write stage completions"); + } + + #[test] + fn legacy_partial_reset_is_retried_without_original_sources() { + let (_temp, memo, notebook) = fixture(); + memo.mark_local_migration_completed("storage_reset_v1:notebook:test:derived-reset") + .unwrap(); + assert_reset_rebuilds_note(&memo, ¬ebook); + } + + #[test] + fn failed_job_retries_and_completed_job_skips() { + let (_temp, memo, _) = fixture(); + assert!(run_job(&memo, "test_retry", || Err("interrupted".into())).is_err()); + assert!(!memo.local_migration_completed("test_retry").unwrap()); + run_job(&memo, "test_retry", || Ok(())).unwrap(); + run_job(&memo, "test_retry", || panic!("completed job ran again")).unwrap(); + } +} + +#[cfg(test)] +mod retired_index_tests { + use super::*; + + #[test] + fn cleanup_failure_does_not_prevent_retiring_the_other_layout() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir(root.join("db")).unwrap(); + fs::write(root.join("index.db"), b"blocked legacy database").unwrap(); + // A file where the backup directory belongs fails on every platform, + // including when tests run with elevated filesystem permissions. + fs::write(root.join("rebuild-backups"), b"blocked").unwrap(); + fs::write(root.join("db/index.db"), b"other legacy database").unwrap(); + + retire_global_index_databases(root); + + assert_eq!(fs::read(root.join("index.db")).unwrap(), b"blocked legacy database"); + assert!(!root.join("db/index.db").exists()); + assert!(root.join("db/rebuild-backups").is_dir()); + } + + #[test] + fn retirement_preserves_both_layouts_and_sidecars_without_touching_current_stores() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir(root.join("db")).unwrap(); + for directory in [root.to_path_buf(), root.join("db")] { + for suffix in ["", "-wal", "-shm", "-journal"] { + fs::write(directory.join(format!("index.db{suffix}")), suffix.as_bytes()).unwrap(); + } + } + fs::write(root.join("db/local.db"), b"current registry").unwrap(); + fs::write(root.join("db/default.db"), b"credentials").unwrap(); + for _ in 0..2 { + retire_global_index_databases(root); + } + for directory in [root.to_path_buf(), root.join("db")] { + let backups: Vec<_> = fs::read_dir(directory.join("rebuild-backups")) + .unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(backups.len(), 1); + for suffix in ["", "-wal", "-shm", "-journal"] { + let name = format!("index.db{suffix}"); + assert!(!directory.join(&name).exists()); + assert_eq!(fs::read(backups[0].join(name)).unwrap(), suffix.as_bytes()); + } + } + assert_eq!(fs::read(root.join("db/local.db")).unwrap(), b"current registry"); + assert_eq!(fs::read(root.join("db/default.db")).unwrap(), b"credentials"); + } +} diff --git a/app/flowix-desktop/src/system_data.rs b/app/flowix-desktop/src/system_data.rs index 608375d9d..231bce2d4 100644 --- a/app/flowix-desktop/src/system_data.rs +++ b/app/flowix-desktop/src/system_data.rs @@ -156,7 +156,7 @@ impl SystemData { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let data = Self::read_from_disk(&path).unwrap_or_default(); + let data = flowix_core::memo_file::read_or_create_json(&path, SystemFile::default)?; Ok(Self { path, data: RwLock::new(data), @@ -188,20 +188,6 @@ impl SystemData { }) } - fn read_from_disk(path: &PathBuf) -> Option { - if !path.exists() { - return None; - } - let content = fs::read_to_string(path).ok()?; - match serde_json::from_str::(&content) { - Ok(data) => Some(data), - Err(e) => { - tracing::warn!("system.json parse error: {e}, falling back to empty"); - None - } - } - } - fn flush(&self, data: &SystemFile) -> std::io::Result<()> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; @@ -324,7 +310,6 @@ impl SystemData { .notebooks .entry(notebook_id.to_string()) .or_default(); - notebook.order = layout.iter().map(|item| item.id.clone()).collect(); notebook.layout = layout; self.flush(&data) } diff --git a/app/flowix-desktop/src/template_store.rs b/app/flowix-desktop/src/template_store.rs index dbbb54d69..9db4dcad3 100644 --- a/app/flowix-desktop/src/template_store.rs +++ b/app/flowix-desktop/src/template_store.rs @@ -1,27 +1,26 @@ -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs; use std::path::{Component, Path, PathBuf}; include!(concat!(env!("OUT_DIR"), "/notebook_template_assets.rs")); -const LEGACY_NOTES_MIGRATION_MARKER: &str = ".notes-template-migration-v1-complete"; - pub fn initialize(user_config_dir: &Path) -> Result<(), String> { initialize_notebook_templates(user_config_dir)?; let templates_dir = user_config_dir.join("templates"); let notes_templates_dir = templates_dir.join("notes"); - fs::create_dir_all(¬es_templates_dir) + flowix_core::memo_file::ensure_storage_directory(¬es_templates_dir) .map_err(|error| format!("create note template directory failed: {error}"))?; - migrate_legacy_note_templates(user_config_dir, &templates_dir, ¬es_templates_dir)?; + migrate_legacy_note_templates(user_config_dir, ¬es_templates_dir)?; Ok(()) } pub fn initialize_notebook_templates(user_config_dir: &Path) -> Result<(), String> { - let notebook_templates_dir = user_config_dir.join("templates").join("notebook-templates"); - fs::create_dir_all(¬ebook_templates_dir) + let templates_dir = user_config_dir.join("templates"); + flowix_core::memo_file::ensure_storage_directory(&templates_dir).map_err(|error| error.to_string())?; + let notebook_templates_dir = templates_dir.join("notebook-templates"); + flowix_core::memo_file::ensure_storage_directory(¬ebook_templates_dir) .map_err(|error| format!("create notebook template directory failed: {error}"))?; seed_notebook_templates_if_needed(¬ebook_templates_dir) } @@ -36,10 +35,23 @@ pub fn notes_templates_dir(user_config_dir: &Path) -> PathBuf { fn seed_notebook_templates_if_needed(target_dir: &Path) -> Result<(), String> { let index_path = target_dir.join("index.json"); + if index_path.is_file() { + let bytes = fs::read(&index_path).map_err(|error| error.to_string())?; + let valid = serde_json::from_slice::(&bytes).ok() + .and_then(|index| index.get("templates").cloned()) + .and_then(|templates| serde_json::from_value::>(templates).ok()) + .is_some_and(|templates| { + let mut ids = std::collections::HashSet::new(); + templates.iter().all(|template| crate::commands::memo::creates::validate_notebook_template(template).is_ok() && ids.insert(template.id.clone())) + }); + if !valid { + flowix_core::memo_file::backup_storage_file(&index_path).map_err(|error| error.to_string())?; + } + } if index_path.exists() { if !is_legacy_builtin_notebook_template_index(&index_path)? { migrate_builtin_scenario_ids_and_cover_urls(&index_path)?; - return Ok(()); + return ensure_bundled_template_files(target_dir); } // Replace the previous built-in collection on upgrade. A custom @@ -65,6 +77,12 @@ fn seed_notebook_templates_if_needed(target_dir: &Path) -> Result<(), String> { .map_err(|error| format!("replace legacy notebook template index failed: {error}"))?; } + ensure_bundled_template_files(target_dir) +} + +// Called when templates are used, so partial deletion does not require a +// version change or a startup scan. Existing files and custom indexes survive. +fn ensure_bundled_template_files(target_dir: &Path) -> Result<(), String> { for &(relative, content) in NOTEBOOK_TEMPLATE_ASSETS .iter() .filter(|(relative, _)| *relative != "index.json") @@ -90,7 +108,7 @@ fn seed_notebook_templates_if_needed(target_dir: &Path) -> Result<(), String> { .iter() .find_map(|(relative, content)| (*relative == "index.json").then_some(*content)) .ok_or_else(|| "bundled notebook template index is missing".to_string())?; - write_if_missing(&index_path, index_content)?; + write_if_missing(&target_dir.join("index.json"), index_content)?; Ok(()) } @@ -258,61 +276,99 @@ fn migrate_builtin_scenario_ids_and_cover_urls(index_path: &Path) -> Result<(), fn migrate_legacy_note_templates( user_config_dir: &Path, - templates_dir: &Path, notes_templates_dir: &Path, ) -> Result<(), String> { let legacy_dir = user_config_dir.join("template"); - if !legacy_dir.is_dir() { - return Ok(()); + let metadata = match fs::symlink_metadata(&legacy_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("inspect legacy note template directory failed: {error}")), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("legacy note template path is not a regular directory".to_string()); } + copy_legacy_template_tree(&legacy_dir, &legacy_dir, notes_templates_dir)?; - let marker = templates_dir.join(LEGACY_NOTES_MIGRATION_MARKER); - if marker.exists() { - return Ok(()); - } + fs::remove_dir_all(&legacy_dir) + .map_err(|error| format!("remove legacy note template directory failed: {error}")) +} - let entries = fs::read_dir(&legacy_dir) +fn copy_legacy_template_tree( + root: &Path, + source: &Path, + destination: &Path, +) -> Result<(), String> { + let entries = fs::read_dir(source) .map_err(|error| format!("read legacy note template directory failed: {error}"))?; for entry in entries { let entry = entry.map_err(|error| format!("read legacy note template failed: {error}"))?; let file_type = entry .file_type() .map_err(|error| format!("inspect legacy note template failed: {error}"))?; - if !file_type.is_file() { + if file_type.is_symlink() { continue; } - - let source = entry.path(); - let extension = source - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or_default(); - if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") { + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(|error| format!("resolve legacy note template path failed: {error}"))?; + if relative.components().any(|component| { + !matches!(component, Component::Normal(_)) + }) { continue; } - - let filename = source - .file_name() - .ok_or_else(|| "invalid legacy note template filename".to_string())?; - let target = notes_templates_dir.join(filename); - let content = fs::read(&source) - .map_err(|error| format!("read legacy note template failed: {error}"))?; - write_if_missing(&target, &content)?; + let target = destination.join(relative); + if file_type.is_dir() { + fs::create_dir_all(&target) + .map_err(|error| format!("create note template directory failed: {error}"))?; + copy_legacy_template_tree(root, &path, destination)?; + } else if file_type.is_file() { + let content = fs::read(&path) + .map_err(|error| format!("read legacy note template failed: {error}"))?; + write_if_missing(&target, &content)?; + } } - - write_if_missing(&marker, b"completed\n") + Ok(()) } fn write_if_missing(path: &Path, content: &[u8]) -> Result<(), String> { - let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()), - Err(error) => return Err(format!("create template file failed: {error}")), - }; + match flowix_core::memo_file::atomic_create_bytes(path, content) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(format!("create template file failed: {error}")), + } +} - if let Err(error) = file.write_all(content) { - let _ = fs::remove_file(path); - return Err(format!("write template file failed: {error}")); +#[cfg(test)] +mod missing_dependency_tests { + use super::*; + + #[test] + fn storage_rebuild_replaces_invalid_template_index() { + let temp = tempfile::tempdir().unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + let root = notebook_templates_dir(temp.path()); + fs::write(root.join("index.json"), b"{broken").unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + let index: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("index.json")).unwrap()).unwrap(); + assert!(index["templates"].is_array()); + assert!(root.join("rebuild-backups").exists()); + } + + #[test] + fn missing_runtime_dependencies_recreate_template_file_with_existing_index() { + let temp = tempfile::tempdir().unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + let root = notebook_templates_dir(temp.path()); + let (relative, content) = NOTEBOOK_TEMPLATE_ASSETS + .iter() + .find(|(path, _)| *path != "index.json") + .unwrap(); + let index = fs::read(root.join("index.json")).unwrap(); + fs::remove_file(root.join(relative)).unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + assert_eq!(fs::read(root.join(relative)).unwrap(), *content); + assert_eq!(fs::read(root.join("index.json")).unwrap(), index); } - Ok(()) } diff --git a/app/flowix-desktop/src/watcher/manager.rs b/app/flowix-desktop/src/watcher/manager.rs index 23dedc434..bbec54b85 100644 --- a/app/flowix-desktop/src/watcher/manager.rs +++ b/app/flowix-desktop/src/watcher/manager.rs @@ -29,6 +29,7 @@ pub struct MemoWatcher { watched_roots: Arc>>, suspended_notebook_ids: HashMap, recent_self_writes: Arc>, + rename_tracker: Arc>, remove_coalescer: Option, memo_file: Arc>, whitelist: Arc>, @@ -63,6 +64,10 @@ impl MemoWatcher { .collect(); drop(roots); for root in previous { + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .forget_root(&root); if watcher.unwatch(&root).is_err() { return false; } @@ -76,7 +81,23 @@ impl MemoWatcher { return true; } let root = PathBuf::from(&config.path); - if !root.is_dir() || watcher.watch(&root, RecursiveMode::Recursive).is_err() { + if !root.is_dir() { + return false; + } + let context = NotebookWatchContext { + notebook_id: config.id.clone(), + root: root.clone(), + }; + let seed = super::rename_tracker::RenameTracker::seed_root(&context); + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .merge_seed(seed); + if watcher.watch(&root, RecursiveMode::Recursive).is_err() { + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .forget_root(&root); return false; } if let Ok(mut roots) = self.watched_roots.write() { @@ -86,6 +107,11 @@ impl MemoWatcher { }); true } else { + let _ = watcher.unwatch(&root); + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .forget_root(&root); false } } @@ -106,7 +132,20 @@ impl MemoWatcher { if !root.is_dir() { return false; } + let context = NotebookWatchContext { + notebook_id: config.id.clone(), + root: root.clone(), + }; + let seed = super::rename_tracker::RenameTracker::seed_root(&context); + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .merge_seed(seed); if watcher.watch(&root, RecursiveMode::Recursive).is_err() { + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .forget_root(&root); return false; } match self.watched_roots.write() { @@ -116,6 +155,10 @@ impl MemoWatcher { } Err(_) => { let _ = watcher.unwatch(&root); + self.rename_tracker + .lock() + .unwrap_or_else(|p| p.into_inner()) + .forget_root(&root); false } } @@ -128,12 +171,21 @@ impl MemoWatcher { }) } + pub fn is_initialized(&self) -> bool { + self._watcher.is_some() + } + + pub fn is_notebook_suspended(&self, notebook_id: &str) -> bool { + self.suspended_notebook_ids.contains_key(notebook_id) + } + pub fn new(memo_file: Arc>) -> Self { Self { _watcher: None, watched_roots: Arc::new(std::sync::RwLock::new(Vec::new())), suspended_notebook_ids: HashMap::new(), recent_self_writes: Arc::new(Mutex::new(HashMap::new())), + rename_tracker: Arc::new(Mutex::new(super::rename_tracker::RenameTracker::default())), remove_coalescer: None, memo_file, whitelist: Arc::new(std::sync::RwLock::new(WhitelistConfig::load_or_default())), @@ -147,7 +199,8 @@ impl MemoWatcher { pub fn set_whitelist(&self, new_cfg: WhitelistConfig) { if let Ok(mut g) = self.whitelist.write() { let mut config = new_cfg; - // Legacy name rules are migrated into each notebook policy. + // Name exclusions now come from the new notebook-local policy. + // Keep only the internal directories in the global event filter. config.skip_dirs = vec![".flowix".into(), ".plugin-output".into()]; config.skip_files.clear(); config.allowed_filename_patterns.clear(); @@ -200,10 +253,6 @@ impl MemoWatcher { }) .collect(); if let Ok(mut watched) = self.watched_roots.write() { watched.clear(); } - if roots.is_empty() { - return; - } - let remove_coalescer = RemoveCoalescer::new(app.clone(), self.memo_file.clone(), REMOVE_TOMBSTONE_DELAY); let remove_coalescer_for_callback = remove_coalescer.clone(); @@ -214,22 +263,32 @@ impl MemoWatcher { // notify 共享事件线程 -> 单 worker 线程的派发通道。notify 回调只做廉价的 // filter / debounce / 自写抑制 + 入队 (`send` 非阻塞), 重 `process` 交给 worker。 - // tx 给回调, rx 留给下方 watched_count>0 后 spawn 的 worker。 + // tx 给回调, rx 交给 worker;即使当前没有可用根,worker 也保持就绪, + // 供后续离线根恢复挂载后接收事件。 let (worker_tx, worker_rx) = std::sync::mpsc::channel::<(RawFsEvent, NotebookWatchContext)>(); let worker_tx_for_callback = worker_tx.clone(); - let mut rename_tracker = super::rename_tracker::RenameTracker::seed(&roots); + let rename_tracker = Arc::new(Mutex::new( + super::rename_tracker::RenameTracker::seed(&roots), + )); + self.rename_tracker = rename_tracker.clone(); + let rename_tracker_for_callback = rename_tracker.clone(); let mut watcher: RecommendedWatcher = match notify::recommended_watcher(move |res: notify::Result| { let Ok(event) = res else { return; }; - let removed_known_directories: std::collections::HashSet = event.paths.iter() - .filter(|path| rename_tracker.was_directory(path)) - .cloned() - .collect(); - let event = rename_tracker.correlate(event); + let (event, removed_known_directories) = { + let mut rename_tracker = rename_tracker_for_callback + .lock() + .unwrap_or_else(|p| p.into_inner()); + let removed_known_directories: std::collections::HashSet = event.paths.iter() + .filter(|path| rename_tracker.was_directory(path)) + .cloned() + .collect(); + (rename_tracker.correlate(event), removed_known_directories) + }; handle_notify_event( &remove_coalescer_for_callback, &whitelist, @@ -267,7 +326,7 @@ impl MemoWatcher { watched_count += 1; } if watched_count == 0 { - return; + tracing::info!("[MemoWatcher] initialized with no available notebook roots; offline roots can be retried later"); } // 单 worker 串行 drain -> 保 FIFO (同路径事件顺序不乱)。`process` 含 @@ -613,6 +672,31 @@ fn context_for_path( mod tests { use super::*; + #[test] + fn recovered_bindings_reconcile_attached_roots_but_not_offline_or_suspended_roots() { + let temp = tempfile::tempdir().unwrap(); + let memo = Arc::new(std::sync::RwLock::new(MemoFile::new(temp.path().join("config")))); + let mut manager = MemoWatcher::new(memo); + manager._watcher = Some(notify::recommended_watcher(|_: notify::Result| {}).unwrap()); + let configs: Vec<_> = (0..3).map(|index| { + let path = temp.path().join(format!("book-{index}")); + if index != 1 { std::fs::create_dir_all(&path).unwrap(); } + NotebookConfig { + id: format!("nb_{index}"), name: format!("Book {index}"), + path: path.to_string_lossy().into_owned(), icon: None, + is_default: false, sort: 0, created_at: 1, updated_at: 1, + } + }).collect(); + manager.set_notebook_suspended(&configs[2].id, true); + for config in &configs { manager.add_notebook_root(config); } + let background = crate::app::notebook_background::NotebookBackground::default(); + crate::watcher::runtime::reconcile_bound_roots(&manager, &configs, &background); + let statuses = background.statuses(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].notebook_id, configs[0].id); + assert_eq!(statuses[0].state, "queued"); + } + #[test] fn template_suspension_only_rebinds_its_own_root() { let directory = tempfile::tempdir().unwrap(); diff --git a/app/flowix-desktop/src/watcher/processor.rs b/app/flowix-desktop/src/watcher/processor.rs index ae7200bea..43d749c01 100644 --- a/app/flowix-desktop/src/watcher/processor.rs +++ b/app/flowix-desktop/src/watcher/processor.rs @@ -212,7 +212,20 @@ impl PathNoteEventProcessor { memo_file: &Arc>, ctx: &NotebookWatchContext, ) { + if let Some(previous) = &event.rename_from { + crate::commands::external_document_watch::rebase_path(app, previous, &event.path); + } + if process_media_event(event, memo_file, ctx) { + if let Some(old_path) = event.rename_from.as_ref() { + if let Ok(memo_file) = memo_file.read() { + crate::commands::document_list::rebase_collection_paths_for_move( + &memo_file, + old_path, + &event.path, + ); + } + } let old_media_path = event.rename_from.as_ref().and_then(|old_path| { let old_ctx = NotebookWatchContext { notebook_id: event.rename_from_notebook_id.clone().unwrap_or_else(|| ctx.notebook_id.clone()), @@ -291,9 +304,8 @@ impl PathNoteEventProcessor { // references before publishing its completion event. return; } - if let Err(error) = memo_file.reconcile_note_index(&ctx.notebook_id) { - tracing::warn!(notebook_id = %ctx.notebook_id, "path rename reconciliation failed: {error}"); - } + app.state::().notebook_background + .request(&ctx.notebook_id, true, true); drop(_write_guard); let old_path_is_markdown = is_markdown_path(old_path); let new_path_is_markdown = is_markdown_path(&event.path); @@ -351,7 +363,21 @@ impl PathNoteEventProcessor { } match event.kind { - FsEventKind::Create | FsEventKind::Modify | FsEventKind::Remove => { + FsEventKind::Create => { + crate::commands::document_list::refresh_view_document_path(&memo_file, &event.path); + match refresh_path(&memo_file, ctx, &event.path) { + Ok(DispatchOutcome::PathIndexed { relative_path }) => { + crate::commands::path_change::NotebookPathCreated::from_external_tool( + ctx.notebook_id.clone(), + relative_path.clone(), + ) + .emit(app); + record_cloud_path_change(app, ctx, &relative_path, false); + } + Err(error) => tracing::debug!(path = %event.path.display(), "path refresh skipped: {error}"), + } + } + FsEventKind::Modify | FsEventKind::Remove => { crate::commands::document_list::refresh_view_document_path(&memo_file, &event.path); match refresh_path(&memo_file, ctx, &event.path) { Ok(DispatchOutcome::PathIndexed { relative_path }) => { @@ -369,26 +395,9 @@ impl PathNoteEventProcessor { fn reconcile_directory_change(app: &AppHandle, memo_file: &MemoFile, ctx: &NotebookWatchContext) { crate::commands::document_list::refresh_view_document_catalog(memo_file, &ctx.notebook_id, &ctx.root); - match memo_file.reconcile_note_index(&ctx.notebook_id) { - Ok(report) => tracing::info!(notebook_id = %ctx.notebook_id, - added = report.added, updated = report.updated, removed = report.removed, - "path index reconciliation completed"), - Err(error) => tracing::warn!(notebook_id = %ctx.notebook_id, - "path index reconciliation failed: {error}"), - } + app.state::().notebook_background + .request(&ctx.notebook_id, false, true); emit_path_changed(app, ctx, "", false); - match memo_file.reconcile_media_resources(&ctx.notebook_id) { - Ok(count) => { - tracing::debug!(notebook_id = %ctx.notebook_id, count, "media catalog reconciliation completed"); - // Directory notifications can reach the UI before a large media scan - // finishes. Publish completion so an empty early query is refreshed. - let _ = app.emit( - "file-management-changed", - serde_json::json!({ "notebookId": ctx.notebook_id, "notebookPath": ctx.root.to_string_lossy() }), - ); - } - Err(error) => tracing::warn!(notebook_id = %ctx.notebook_id, "media catalog reconciliation failed: {error}"), - } } pub(crate) fn unregister_and_emit( diff --git a/app/flowix-desktop/src/watcher/rename_tracker.rs b/app/flowix-desktop/src/watcher/rename_tracker.rs index 7f3ce7d42..5870936d5 100644 --- a/app/flowix-desktop/src/watcher/rename_tracker.rs +++ b/app/flowix-desktop/src/watcher/rename_tracker.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; #[derive(Default)] pub struct RenameTracker { + roots: HashSet, known: HashMap, known_directories: HashSet, pending: HashMap, @@ -43,30 +44,52 @@ impl RenameTracker { pub fn seed(roots: &[NotebookWatchContext]) -> Self { let mut tracker = Self::default(); for root in roots { - let policy = FileManagementPolicy::from_notebook_root(&root.root); - for entry in walkdir::WalkDir::new(&root.root) - .follow_links(false) - .into_iter() - .filter_entry(|entry| { - entry - .path() - .strip_prefix(&root.root) - .is_ok_and(|relative| !policy.is_index_ignored_at(&root.root, relative)) - }) - .filter_map(Result::ok) + tracker.add_root(root); + } + tracker + } + pub fn seed_root(root: &NotebookWatchContext) -> Self { + let mut tracker = Self::default(); + tracker.add_root(root); + tracker + } + pub fn merge_seed(&mut self, seed: Self) { + self.roots.extend(seed.roots); + self.known.extend(seed.known); + self.known_directories.extend(seed.known_directories); + } + pub fn add_root(&mut self, root: &NotebookWatchContext) { + self.roots.insert(root.root.clone()); + let policy = FileManagementPolicy::from_notebook_root(&root.root); + for entry in walkdir::WalkDir::new(&root.root) + .follow_links(false) + .into_iter() + .filter_entry(|entry| { + entry + .path() + .strip_prefix(&root.root) + .is_ok_and(|relative| !policy.is_index_ignored_at(&root.root, relative)) + }) + .filter_map(Result::ok) + { + if entry.file_type().is_dir() + || entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + || media_kind_for_path(entry.path()).is_some() { - if entry.file_type().is_dir() - || entry - .path() - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) - || media_kind_for_path(entry.path()).is_some() - { - tracker.observe(entry.path()); - } + self.observe(entry.path()); } } - tracker + } + pub fn forget_root(&mut self, root: &Path) { + self.roots.remove(root); + let roots = &self.roots; + let belongs_to_watched_root = |path: &Path| roots.iter().any(|base| path.starts_with(base)); + self.known.retain(|path, _| belongs_to_watched_root(path)); + self.known_directories.retain(|path| belongs_to_watched_root(path)); + self.pending.retain(|path, _| belongs_to_watched_root(path)); } fn observe(&mut self, path: &Path) { if let Some(identity) = filesystem_identity(path) { diff --git a/app/flowix-desktop/src/watcher/runtime.rs b/app/flowix-desktop/src/watcher/runtime.rs index 5333f5536..b63ce42fb 100644 --- a/app/flowix-desktop/src/watcher/runtime.rs +++ b/app/flowix-desktop/src/watcher/runtime.rs @@ -6,6 +6,31 @@ use tauri::{AppHandle, Manager}; use crate::watcher::manager::MemoWatcher; use flowix_core::memo_file::atomic_write_bytes; +/// Read a fresh registry snapshot without acquiring AppState's MemoFile lock. +/// Call while holding the watcher binding lock, so delayed rebinds cannot apply +/// older snapshots. Never replace this with a global MemoFile read: document +/// operations hold that lock while recording watcher self-write marks. +/// The facade uses the current registry path and does not run legacy migrations. +pub(crate) fn read_registered_notebooks( + config_dir: &Path, +) -> std::io::Result> { + flowix_core::memo_file::MemoFile::new(config_dir.to_path_buf()).read_notebook_configs() +} + +/// After recovery, reconcile the roots that really attached. The integration +/// layer owns queue admission; the watcher manager only owns OS bindings. +pub(crate) fn reconcile_bound_roots( + watcher: &MemoWatcher, + configs: &[flowix_core::memo_file::NotebookConfig], + background: &crate::app::notebook_background::NotebookBackground, +) { + for config in configs { + if watcher.is_watching(&config.id) { + background.request(&config.id, false, true); + } + } +} + pub fn current_watcher(app: &AppHandle) -> Option>> { app.try_state::>>() .map(|s| s.inner().clone()) @@ -51,3 +76,61 @@ pub(crate) fn write_note_atomic( mark_self_write_content_for(app, path, content); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use flowix_core::memo_file::{MemoFile, NotebookConfig}; + + fn notebook(root: &Path) -> NotebookConfig { + NotebookConfig { + id: "notebook".into(), name: "Notebook".into(), icon: None, + path: root.to_string_lossy().into_owned(), is_default: false, + sort: 0, created_at: 1, updated_at: 1, + } + } + + #[test] + fn registry_snapshot_observes_updates_from_another_facade() { + let temp = tempfile::tempdir().unwrap(); + let memo = MemoFile::new(temp.path().to_path_buf()); + let mut config = notebook(&temp.path().join("book")); + memo.write_notebook_configs(&[config.clone()]).unwrap(); + assert_eq!(read_registered_notebooks(temp.path()).unwrap()[0].name, "Notebook"); + config.name = "Renamed".into(); + memo.write_notebook_configs(&[config]).unwrap(); + assert_eq!(read_registered_notebooks(temp.path()).unwrap()[0].name, "Renamed"); + memo.write_notebook_configs(&[]).unwrap(); + assert!(read_registered_notebooks(temp.path()).unwrap().is_empty()); + } + + #[test] + fn registry_snapshot_under_watcher_lock_does_not_wait_for_global_memo_lock() { + let temp = tempfile::tempdir().unwrap(); + let memo = Arc::new(RwLock::new(MemoFile::new(temp.path().to_path_buf()))); + memo.read().unwrap().write_notebook_configs(&[notebook(&temp.path().join("book"))]).unwrap(); + let watcher = Arc::new(RwLock::new(MemoWatcher::new(memo.clone()))); + let held_memo = memo.write().unwrap(); + let config_dir = temp.path().to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let _binding = watcher.write().unwrap(); + tx.send(read_registered_notebooks(&config_dir).map(|configs| configs.len())).unwrap(); + }); + let snapshot = rx.recv_timeout(std::time::Duration::from_secs(3)); + // Release before asserting/joining so a regression cannot strand a thread. + drop(held_memo); + worker.join().unwrap(); + assert_eq!(snapshot.unwrap().unwrap(), 1); + } + + #[test] + fn unreadable_registry_is_an_error_not_an_empty_snapshot_or_rebuild() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("db")).unwrap(); + let db = temp.path().join("db/local.db"); + std::fs::write(&db, b"invalid database").unwrap(); + assert!(read_registered_notebooks(temp.path()).is_err()); + assert_eq!(std::fs::read(db).unwrap(), b"invalid database"); + } +} diff --git a/app/flowix-desktop/tauri.conf.dev.json b/app/flowix-desktop/tauri.conf.dev.json index 320956f8d..6158c24fe 100644 --- a/app/flowix-desktop/tauri.conf.dev.json +++ b/app/flowix-desktop/tauri.conf.dev.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Flowix Dev", "mainBinaryName": "Flowix Dev", - "identifier": "com.flowix.app.dev", + "identifier": "com.flowix-dev.app", "plugins": { "deep-link": { "desktop": { "schemes": ["flowix-dev"] } diff --git a/app/flowix-desktop/tauri.conf.json b/app/flowix-desktop/tauri.conf.json index 5cf71c3c8..6785a4fdb 100644 --- a/app/flowix-desktop/tauri.conf.json +++ b/app/flowix-desktop/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Flowix", "mainBinaryName": "Flowix", - "version": "1.5.6", + "version": "1.5.8", "identifier": "com.flowix.app", "build": { "beforeDevCommand": "npm run dev", @@ -40,7 +40,11 @@ }, "plugins": { "deep-link": { - "desktop": { "schemes": ["flowix"] } + "desktop": { + "schemes": [ + "flowix" + ] + } }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgRUEyNzZBMzYyMDQyNEI1MApSV1JRUzBJZ05tb242a1lBZDlLYnZJYkVXNm84VFIvTWZ2ODhLS29kM2lFaGVWSzBwejF2TEQyWgo=", diff --git a/app/flowix-desktop/tauri.windows.dev.conf.json b/app/flowix-desktop/tauri.windows.dev.conf.json index 5cfa24b74..37239c729 100644 --- a/app/flowix-desktop/tauri.windows.dev.conf.json +++ b/app/flowix-desktop/tauri.windows.dev.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Flowix Dev", "mainBinaryName": "Flowix Dev", - "identifier": "com.flowix.app.dev", + "identifier": "com.flowix-dev.app", "plugins": { "deep-link": { "desktop": { "schemes": ["flowix-dev"] } diff --git a/app/flowix-sync/Cargo.toml b/app/flowix-sync/Cargo.toml index acbc01bf4..c217e4cdd 100644 --- a/app/flowix-sync/Cargo.toml +++ b/app/flowix-sync/Cargo.toml @@ -3,6 +3,7 @@ name = "flowix-sync" version.workspace = true description = "Flowix Cloud authentication and notebook synchronization engine" edition.workspace = true +build = "build.rs" [lib] name = "flowix_sync" diff --git a/app/flowix-sync/build.rs b/app/flowix-sync/build.rs new file mode 100644 index 000000000..9adb81f7a --- /dev/null +++ b/app/flowix-sync/build.rs @@ -0,0 +1,18 @@ +fn main() { + println!("cargo:rerun-if-env-changed=FLOWIX_CLOUD_ENVIRONMENT"); + println!("cargo:rerun-if-env-changed=FLOWIX_DEV_MODE"); + + let is_dev_build = matches!(std::env::var("FLOWIX_DEV_MODE").as_deref(), Ok("1")); + let configured_environment = std::env::var("FLOWIX_CLOUD_ENVIRONMENT") + .unwrap_or_else(|_| "prod".to_owned()); + let environment = if is_dev_build { + configured_environment.as_str() + } else { + "prod" + }; + match environment { + "staging" | "prod" => (), + other => panic!("FLOWIX_CLOUD_ENVIRONMENT must be staging or prod, got {other:?}"), + } + println!("cargo:rustc-env=FLOWIX_CLOUD_ENVIRONMENT={environment}"); +} diff --git a/app/flowix-sync/src/client.rs b/app/flowix-sync/src/client.rs index 7c3ca47b9..5acf8cba0 100644 --- a/app/flowix-sync/src/client.rs +++ b/app/flowix-sync/src/client.rs @@ -4,8 +4,9 @@ use serde_json::json; use crate::error::SyncError; use crate::models::{ - ApiErrorEnvelope, AppleAuthChallenge, AppleAuthorization, AuthData, CloudCheckout, - CloudProduct, DataEnvelope, EntitlementData, MeData, RefreshData, + ApiErrorEnvelope, AppleAuthChallenge, AppleAuthorization, AuthData, CloudAiCredential, + CloudCheckout, CloudOrder, CloudOrderCheck, CloudProduct, DataEnvelope, EntitlementData, + MeData, RefreshData, }; use crate::v2::{ V2BlobDownloadEnvelope, V2BlobReservationEnvelope, V2Bootstrap, V2ChangesPage, V2History, @@ -133,7 +134,6 @@ impl CloudClient { .json(&json!({ "productId": product_id, "successUrl": "flowix://billing/success", - "cancelUrl": "flowix://billing/cancel", })) .send() .await?; @@ -142,6 +142,61 @@ impl CloudClient { .map(|value| value.data) } + pub(crate) async fn billing_orders( + &self, + access_token: &str, + ) -> Result, SyncError> { + self.send::>>( + Method::GET, + "/v2/billing/orders", + Some(access_token), + None, + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn check_billing_order( + &self, + access_token: &str, + order_id: &str, + ) -> Result { + self.send::>( + Method::POST, + &format!("/v2/billing/orders/{order_id}/check"), + Some(access_token), + Some(json!({})), + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn submit_feedback( + &self, + device_id: &str, + kind: &str, + content: &str, + app_version: &str, + platform: &str, + attachments: &[serde_json::Value], + ) -> Result<(), SyncError> { + self.send::( + Method::POST, + "/v1/feedback", + None, + Some(json!({ + "deviceId": device_id, + "type": kind, + "content": content, + "appVersion": app_version, + "platform": platform, + "attachments": attachments, + })), + ) + .await + .map(|_| ()) + } + pub(crate) async fn register( &self, email: &str, @@ -279,6 +334,45 @@ impl CloudClient { .map(|value| value.data) } + pub(crate) async fn create_ai_credential( + &self, + access_token: &str, + ) -> Result { + self.send::>( + Method::POST, + "/v1/ai/credentials", + Some(access_token), + Some(json!({})), + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn ai_credit_balance( + &self, + access_token: &str, + ) -> Result { + self.send::>( + Method::GET, + "/v1/account/ai-credits", + Some(access_token), + None, + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn revoke_ai_credential(&self, access_token: &str) -> Result<(), SyncError> { + self.send::>( + Method::DELETE, + "/v1/ai/credentials/current", + Some(access_token), + None, + ) + .await + .map(|_| ()) + } + pub async fn v2_sync_status( &self, access_token: &str, diff --git a/app/flowix-sync/src/environment.rs b/app/flowix-sync/src/environment.rs new file mode 100644 index 000000000..fb6c3963a --- /dev/null +++ b/app/flowix-sync/src/environment.rs @@ -0,0 +1,34 @@ +/// Cloud target used by the desktop application. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloudEnvironment { + Staging, + Prod, +} + +impl CloudEnvironment { + pub const fn name(self) -> &'static str { + match self { + Self::Staging => "staging", + Self::Prod => "prod", + } + } + + pub const fn api_base(self) -> &'static str { + match self { + Self::Staging => "https://staging-cloud.flowix.cc", + Self::Prod => "https://cloud.flowix.cc", + } + } + + pub fn parse(name: &str) -> Option { + match name { + "staging" => Some(Self::Staging), + "prod" => Some(Self::Prod), + _ => None, + } + } +} + +pub fn default_cloud_environment() -> CloudEnvironment { + CloudEnvironment::parse(env!("FLOWIX_CLOUD_ENVIRONMENT")).unwrap_or(CloudEnvironment::Prod) +} diff --git a/app/flowix-sync/src/lib.rs b/app/flowix-sync/src/lib.rs index 5e1e22ab9..401264cd9 100644 --- a/app/flowix-sync/src/lib.rs +++ b/app/flowix-sync/src/lib.rs @@ -5,6 +5,7 @@ //! HTTP contract, sync state database, revisions, cursors and conflict plans. mod client; +mod environment; mod error; mod manager; mod models; @@ -12,28 +13,26 @@ mod store; mod v2; pub use client::CloudClient; +pub use environment::{default_cloud_environment, CloudEnvironment}; pub use error::SyncError; -pub use manager::SyncManager; +pub use manager::{PendingLogout, SyncManager}; pub use models::{ - AppleAuthChallenge, AppleAuthorization, CloudAuthStatus, CloudCheckout, - CloudMembership, - CloudNotebook, CloudPrice, CloudProduct, CloudState, CloudUser, LocalChangeKind, + AppleAuthChallenge, AppleAuthorization, CloudAiCredential, CloudAiCreditBalance, CloudAiModel, + CloudAiProvider, CloudAuthStatus, CloudCheckout, CloudMembership, CloudNotebook, CloudOrder, + CloudOrderCheck, CloudPrice, CloudProduct, CloudState, CloudUser, LocalChangeKind, ProductDuration, ProductEntitlement, RefreshTokenStore, }; pub use store::SyncStore; pub use v2::{ collect_v2_attachments, new_v2_operation_id, referenced_attachment_paths, v2_content_hash, - v2_local_content_diverged, v2_notebook_metadata_hash, v2_path_note_id, V2AccountSyncReport, V2Attachment, - V2BlobDownload, V2BlobDownloadCapability, V2BlobDownloadEnvelope, V2BlobReservation, - V2BlobReservationEnvelope, V2BlobUpload, V2Bootstrap, V2BootstrapNote, V2Change, V2ChangesPage, V2CloudAccount, - V2History, V2HistoryRevision, - V2ConflictMaterial, - V2DirtyEntity, V2EntityType, V2FreezeOperation, V2InflightOperation, V2LocalAttachment, + v2_local_content_diverged, v2_notebook_metadata_hash, v2_path_note_id, V2AccountSyncReport, + V2Attachment, V2BlobDownload, V2BlobDownloadCapability, V2BlobDownloadEnvelope, + V2BlobReservation, V2BlobReservationEnvelope, V2BlobUpload, V2Bootstrap, V2BootstrapNote, + V2Change, V2ChangesPage, V2CloudAccount, V2ConflictMaterial, V2DirtyEntity, V2EntityType, + V2FreezeOperation, V2History, V2HistoryRevision, V2InflightOperation, V2LocalAttachment, V2LocalNote, V2LocalNotebook, V2NoteState, V2NotebookState, V2OperationError, V2OperationKind, - V2PendingMove, - V2PushOperation, V2PushResult, V2RemoteApply, V2RemoteAttachment, V2SyncStatus, + V2PendingMove, V2PushOperation, V2PushResult, V2RemoteApply, V2RemoteAttachment, V2SyncStatus, V2SyncedNotebook, PROTOCOL_EPOCH, }; -pub const DEFAULT_CLOUD_API_BASE: &str = "https://cloud.flowix.cc"; pub mod text_merge; diff --git a/app/flowix-sync/src/manager.rs b/app/flowix-sync/src/manager.rs index d60ad7e73..c736b2f8f 100644 --- a/app/flowix-sync/src/manager.rs +++ b/app/flowix-sync/src/manager.rs @@ -6,7 +6,8 @@ use crate::client::CloudClient; use crate::error::SyncError; use crate::models::{ AppleAuthChallenge, AppleAuthorization, CloudAuthStatus, CloudCheckout, CloudMembership, - CloudProduct, CloudState, LocalChangeKind, RefreshTokenStore, RuntimeSession, + CloudOrder, CloudOrderCheck, CloudProduct, CloudState, LocalChangeKind, RefreshTokenStore, + RuntimeSession, }; use crate::store::SyncStore; use crate::v2::{ @@ -31,6 +32,12 @@ pub struct SyncManager { state_notifier: Arc, } +/// Local logout has completed; finish this value to revoke its server session. +pub struct PendingLogout { + refresh_token: Option, + local_cleanup: Result<(), SyncError>, +} + struct AuthState { session: Option, status: CloudAuthStatus, diff --git a/app/flowix-sync/src/manager/auth.rs b/app/flowix-sync/src/manager/auth.rs index 8befe0b95..5f414d623 100644 --- a/app/flowix-sync/src/manager/auth.rs +++ b/app/flowix-sync/src/manager/auth.rs @@ -181,22 +181,24 @@ impl SyncManager { Ok(next_generation) } - pub async fn logout(&self) -> Result<(), SyncError> { - self.logout_with_cleanup(|| self.delete_persisted_refresh_token()) - .await + /// Clear local authentication immediately and return a handle for remote revocation. + pub fn begin_logout(&self) -> PendingLogout { + let (refresh_token, local_cleanup) = + self.clear_auth_with_cleanup(|| self.delete_persisted_refresh_token()); + PendingLogout { + refresh_token, + local_cleanup, + } } - async fn logout_with_cleanup( - &self, - cleanup: impl FnOnce() -> Result<(), SyncError>, - ) -> Result<(), SyncError> { - let (refresh_token, local_cleanup) = self.clear_auth_with_cleanup(cleanup); - let remote_logout = if let Some(refresh_token) = refresh_token { + /// Revoke a session after its local state and dependent UI have been cleared. + pub async fn finish_logout(&self, pending: PendingLogout) -> Result<(), SyncError> { + let remote_logout = if let Some(refresh_token) = pending.refresh_token { self.client.logout_with_refresh_token(&refresh_token).await } else { Ok(()) }; - if let Err(error) = local_cleanup { + if let Err(error) = pending.local_cleanup { if let Err(remote_error) = remote_logout { tracing::warn!( "failed to revoke Flowix Cloud session during local logout: {remote_error}" @@ -207,6 +209,10 @@ impl SyncManager { remote_logout } + pub async fn logout(&self) -> Result<(), SyncError> { + self.finish_logout(self.begin_logout()).await + } + fn clear_auth_with_cleanup( &self, cleanup: impl FnOnce() -> Result<(), SyncError>, @@ -426,7 +432,6 @@ mod tests { id: token.into(), email: "test@example.test".into(), display_name: "Test".into(), - system_role: "user".into(), }, protocol_epoch: crate::v2::PROTOCOL_EPOCH, }, @@ -551,6 +556,24 @@ mod tests { } } + #[test] + fn begin_logout_clears_local_session_before_remote_revocation() { + let directory = tempfile::tempdir().unwrap(); + let manager = SyncManager::new( + "https://cloud.example.test", + directory.path().join("sync.db"), + ) + .unwrap(); + let generation = sign_in(&manager, "current"); + + let pending = manager.begin_logout(); + + assert!(!manager.state().unwrap().authenticated); + assert!(manager.session_generation() > generation); + assert_eq!(pending.refresh_token.as_deref(), Some("current")); + assert!(pending.local_cleanup.is_ok()); + } + #[test] fn late_refresh_cannot_restore_a_logged_out_session() { let directory = tempfile::tempdir().unwrap(); diff --git a/app/flowix-sync/src/manager/catalog.rs b/app/flowix-sync/src/manager/catalog.rs index 9288a0db4..bab57af98 100644 --- a/app/flowix-sync/src/manager/catalog.rs +++ b/app/flowix-sync/src/manager/catalog.rs @@ -1,6 +1,59 @@ use super::*; impl SyncManager { + /// Monotonic identity for the current in-memory Cloud login session. + /// Runtime adapters use this to refresh session-bound API credentials only + /// after a sign-in transition, not before every request. + pub fn ai_credential_session_generation(&self) -> u64 { + self.auth_generation() + } + + pub async fn ai_credit_balance( + &self, + ) -> Result { + let generation = self.auth_generation(); + let access_token = self.access_token(generation).await?; + let first = self.client.ai_credit_balance(&access_token).await; + let balance = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.ai_credit_balance(&refreshed).await? + } else { + first? + }; + let _generation = self.require_auth_generation(generation)?; + Ok(balance) + } + + pub async fn create_ai_credential( + &self, + ) -> Result { + let generation = self.auth_generation(); + let access_token = self.access_token(generation).await?; + let first = self.client.create_ai_credential(&access_token).await; + let credential = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.create_ai_credential(&refreshed).await? + } else { + first? + }; + let _generation = self.require_auth_generation(generation)?; + Ok(credential) + } + + pub async fn revoke_ai_credential(&self) -> Result<(), SyncError> { + let generation = self.auth_generation(); + let access_token = self.access_token(generation).await?; + let first = self.client.revoke_ai_credential(&access_token).await; + if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.revoke_ai_credential(&refreshed).await?; + } else { + first?; + } + let _generation = self.require_auth_generation(generation)?; + Ok(()) + } + pub async fn refresh_membership(&self) -> Result { let generation = self.auth_generation(); let mut refresh = match self.membership_refresh.try_lock() { @@ -49,6 +102,48 @@ impl SyncManager { self.client.products().await } + pub async fn billing_orders(&self) -> Result, SyncError> { + let generation = self.auth_generation(); + let token = self.access_token(generation).await?; + let first = self.client.billing_orders(&token).await; + let result = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.billing_orders(&refreshed).await + } else { + first + }; + let _generation = self.require_auth_generation(generation)?; + result + } + + pub async fn check_billing_order(&self, order_id: &str) -> Result { + let generation = self.auth_generation(); + let token = self.access_token(generation).await?; + let first = self.client.check_billing_order(&token, order_id).await; + let result = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.check_billing_order(&refreshed, order_id).await + } else { + first + }; + let _generation = self.require_auth_generation(generation)?; + result + } + + pub async fn submit_feedback( + &self, + device_id: &str, + kind: &str, + content: &str, + app_version: &str, + platform: &str, + attachments: &[serde_json::Value], + ) -> Result<(), SyncError> { + self.client + .submit_feedback(device_id, kind, content, app_version, platform, attachments) + .await + } + pub async fn create_checkout( &self, product_id: &str, diff --git a/app/flowix-sync/src/manager/lifecycle/tests.rs b/app/flowix-sync/src/manager/lifecycle/tests.rs index 5037a23e0..58e8603b2 100644 --- a/app/flowix-sync/src/manager/lifecycle/tests.rs +++ b/app/flowix-sync/src/manager/lifecycle/tests.rs @@ -89,7 +89,7 @@ impl Drop for Server { } fn user(id: &str) -> Value { - json!({ "id": id, "email": format!("{id}@example.test"), "displayName": id, "systemRole": "user" }) + json!({ "id": id, "email": format!("{id}@example.test"), "displayName": id }) } fn session(id: &str) -> Value { json!({ "accessToken": format!("access-{id}"), "accessTokenExpiresAt": 4_102_444_800_000_i64, diff --git a/app/flowix-sync/src/manager/state.rs b/app/flowix-sync/src/manager/state.rs index 3f9df6d47..956d8fdfd 100644 --- a/app/flowix-sync/src/manager/state.rs +++ b/app/flowix-sync/src/manager/state.rs @@ -25,6 +25,17 @@ impl SyncManager { Self::create(api_base, database_path, Some(token_store), state_notifier) } + /// Desktop startup variant: defer database migrations and credential + /// restoration to the initialization worker. + pub fn new_with_session_dependencies_deferred( + api_base: &str, + database_path: impl AsRef, + token_store: Arc, + state_notifier: Arc, + ) -> Result { + Self::create_deferred(api_base, database_path, Some(token_store), state_notifier) + } + fn create( api_base: &str, database_path: impl AsRef, @@ -32,24 +43,37 @@ impl SyncManager { state_notifier: Arc, ) -> Result { let sync_store = SyncStore::new(database_path)?; - let restore_blocked = sync_store.session_restore_blocked()?; let credential_retry = Arc::new(tokio::sync::Notify::new()); + let manager = Self { + client: CloudClient::new(api_base)?, + store: sync_store, + auth: Arc::new(RwLock::new(AuthState::default())), + membership: Arc::new(RwLock::new(None)), + membership_refresh: Arc::new(tokio::sync::Mutex::new(None)), + refresh_lock: Arc::new(tokio::sync::Mutex::new(())), + auth_generation: Arc::new(std::sync::Mutex::new(0)), + account_sync_lock: Arc::new(tokio::sync::Mutex::new(())), + refresh_token_store: token_store, + credential_retry, + state_notifier, + }; + manager.initialize_storage()?; + Ok(manager) + } + + fn create_deferred( + api_base: &str, + database_path: impl AsRef, + token_store: Option>, + state_notifier: Arc, + ) -> Result { let mut auth = AuthState::default(); - if let Some(store) = token_store.as_ref() { - if restore_blocked { - auth.pending_credential = Some(CredentialWrite::Delete); - credential_retry.notify_one(); - } else { - match store.load() { - Ok(Some(_)) => auth.status = CloudAuthStatus::Restoring, - Ok(None) => {} - Err(error) => auth.error = Some(error), - } - } + if token_store.is_some() { + auth.status = CloudAuthStatus::Restoring; } Ok(Self { client: CloudClient::new(api_base)?, - store: sync_store, + store: SyncStore::new_uninitialized(database_path), auth: Arc::new(RwLock::new(auth)), membership: Arc::new(RwLock::new(None)), membership_refresh: Arc::new(tokio::sync::Mutex::new(None)), @@ -57,11 +81,59 @@ impl SyncManager { auth_generation: Arc::new(std::sync::Mutex::new(0)), account_sync_lock: Arc::new(tokio::sync::Mutex::new(())), refresh_token_store: token_store, - credential_retry, + credential_retry: Arc::new(tokio::sync::Notify::new()), state_notifier, }) } + pub fn initialize_storage(&self) -> Result<(), SyncError> { + self.store.initialize_schema()?; + let restore_blocked = self.store.session_restore_blocked()?; + let Some(token_store) = self.refresh_token_store.as_ref() else { + return Ok(()); + }; + // Reading the OS credential store may block. Do not hold the auth lock, + // and never overwrite login/logout that happened after the shell opened. + if self.auth_generation() != 0 { + return Ok(()); + } + let token = if restore_blocked { + Ok(None) + } else { + token_store.load() + }; + let Ok(generation_guard) = self.require_auth_generation(0) else { + return Ok(()); + }; + let mut auth = self.auth.write().unwrap_or_else(|p| p.into_inner()); + if auth.session.is_none() { + let previous = (auth.status, auth.error.clone()); + auth.error = None; + if restore_blocked { + auth.status = CloudAuthStatus::SignedOut; + auth.pending_credential = Some(CredentialWrite::Delete); + self.credential_retry.notify_one(); + } else { + match token { + Ok(Some(_)) => auth.status = CloudAuthStatus::Restoring, + Ok(None) => auth.status = CloudAuthStatus::SignedOut, + Err(error) => { + auth.status = CloudAuthStatus::Restoring; + auth.error = Some(error); + } + } + } + // The deferred initial snapshot can still be in flight when the + // background result is emitted. Give a changed result a newer revision. + if previous != (auth.status, auth.error.clone()) { + auth.revision = auth.revision.saturating_add(1); + } + } + drop(auth); + drop(generation_guard); + Ok(()) + } + pub fn store(&self) -> &SyncStore { &self.store } @@ -86,3 +158,97 @@ impl SyncManager { }) } } + +#[cfg(test)] +mod deferred_startup_tests { + use super::*; + use std::sync::{mpsc, Mutex}; + use std::time::Duration; + + struct DelayedTokenStore { + entered: mpsc::Sender<()>, + release: Mutex>, + } + impl RefreshTokenStore for DelayedTokenStore { + fn load(&self) -> Result, String> { + self.entered.send(()).unwrap(); + self.release + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + Ok(Some("old-session".into())) + } + fn save(&self, _: &str) -> Result<(), String> { + Ok(()) + } + fn delete(&self) -> Result<(), String> { + Ok(()) + } + } + + struct EmptyTokenStore; + impl RefreshTokenStore for EmptyTokenStore { + fn load(&self) -> Result, String> { + Ok(None) + } + fn save(&self, _: &str) -> Result<(), String> { + Ok(()) + } + fn delete(&self) -> Result<(), String> { + Ok(()) + } + } + + #[test] + fn deferred_credential_result_has_a_newer_revision_than_initial_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let manager = SyncManager::new_with_session_dependencies_deferred( + "http://127.0.0.1:1", + temp.path().join("sync.db"), + Arc::new(EmptyTokenStore), + Arc::new(|_| {}), + ) + .unwrap(); + let before = manager.state().unwrap(); + assert_eq!(before.auth_status, CloudAuthStatus::Restoring); + manager.initialize_storage().unwrap(); + let after = manager.state().unwrap(); + assert_eq!(after.auth_status, CloudAuthStatus::SignedOut); + assert!(after.revision > before.revision); + manager.initialize_storage().unwrap(); + assert_eq!(manager.state().unwrap().revision, after.revision); + } + + #[tokio::test] + async fn delayed_startup_credentials_cannot_overwrite_explicit_logout() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("sync.db"); + let (entered, started) = mpsc::channel(); + let (release, wait) = mpsc::channel(); + let manager = Arc::new( + SyncManager::new_with_session_dependencies_deferred( + "http://127.0.0.1:1", + &path, + Arc::new(DelayedTokenStore { + entered, + release: Mutex::new(wait), + }), + Arc::new(|_| {}), + ) + .unwrap(), + ); + assert!(!path.exists()); + assert!(started.try_recv().is_err()); + let worker_manager = manager.clone(); + let worker = std::thread::spawn(move || worker_manager.initialize_storage()); + started.recv_timeout(Duration::from_secs(3)).unwrap(); + manager.logout().await.unwrap(); + release.send(()).unwrap(); + worker.join().unwrap().unwrap(); + assert_eq!( + manager.state().unwrap().auth_status, + CloudAuthStatus::SignedOut + ); + } +} diff --git a/app/flowix-sync/src/models.rs b/app/flowix-sync/src/models.rs index 54f475f45..240f904ce 100644 --- a/app/flowix-sync/src/models.rs +++ b/app/flowix-sync/src/models.rs @@ -6,7 +6,6 @@ pub struct CloudUser { pub id: String, pub email: String, pub display_name: String, - pub system_role: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -121,6 +120,50 @@ pub struct CloudMembership { pub read_only: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CloudAiCredential { + pub api_key: String, + pub model: CloudAiModel, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub provider: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudAiCreditBalance { + pub balance_microcredits: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CloudAiModel { + pub id: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub subtitle: Option, + #[serde(default)] + pub credit_multiplier: Option, + #[serde(default)] + pub provider_id: Option, + #[serde(default)] + pub provider_name: Option, + #[serde(default)] + pub provider_icon_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CloudAiProvider { + pub id: String, + pub display_name: String, + #[serde(default)] + pub icon_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloudProduct { @@ -160,6 +203,27 @@ pub struct CloudCheckout { pub expires_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudOrder { + pub id: String, + pub product_id: String, + pub product_name: String, + pub amount: i64, + pub currency: String, + pub status: String, + pub created_at: i64, + pub paid_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudOrderCheck { + pub status: String, + pub confirmed: bool, + pub check_status: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloudState { diff --git a/app/flowix-sync/src/store.rs b/app/flowix-sync/src/store.rs index 0ac78999d..b0a6c2fad 100644 --- a/app/flowix-sync/src/store.rs +++ b/app/flowix-sync/src/store.rs @@ -8,6 +8,8 @@ use crate::error::SyncError; #[derive(Clone)] pub struct SyncStore { path: PathBuf, + schema_ready: std::sync::Arc, + schema_lock: std::sync::Arc>, } mod schema; diff --git a/app/flowix-sync/src/store/schema.rs b/app/flowix-sync/src/store/schema.rs index d6f8e0b5f..21cbd008f 100644 --- a/app/flowix-sync/src/store/schema.rs +++ b/app/flowix-sync/src/store/schema.rs @@ -2,14 +2,57 @@ use super::*; impl SyncStore { pub fn new(path: impl AsRef) -> Result { - let store = Self { - path: path.as_ref().to_path_buf(), - }; - store.open()?; + let store = Self::new_uninitialized(path); + store.initialize_schema()?; Ok(store) } + pub fn new_uninitialized(path: impl AsRef) -> Self { + Self { + path: path.as_ref().to_path_buf(), + schema_ready: Default::default(), + schema_lock: Default::default(), + } + } + + pub fn initialize_schema(&self) -> Result<(), SyncError> { + let _guard = self.schema_lock.lock().unwrap_or_else(|p| p.into_inner()); + self.initialize_schema_locked() + } + + fn initialize_schema_locked(&self) -> Result<(), SyncError> { + flowix_core::memo_file::with_database_rebuild(&self.path, || { + let connection = self.open_raw().map_err(std::io::Error::other)?; + Self::initialize_v2_schema(&connection).map_err(std::io::Error::other)?; + connection + .query_row( + "SELECT blocked FROM session_restore_policy WHERE singleton=1", + [], + |row| row.get::<_, bool>(0), + ) + .optional() + .map_err(std::io::Error::other)?; + Ok(()) + }) + .map_err(|error| SyncError::InvalidState(format!("initialize sync database: {error}")))?; + self.schema_ready + .store(true, std::sync::atomic::Ordering::Release); + Ok(()) + } + pub(super) fn open(&self) -> Result { + // Early Cloud UI/API requests initialize on demand; the local shell + // never waits for this database or the operating-system credential store. + if !self.schema_ready.load(std::sync::atomic::Ordering::Acquire) { + let _guard = self.schema_lock.lock().unwrap_or_else(|p| p.into_inner()); + if !self.schema_ready.load(std::sync::atomic::Ordering::Acquire) { + self.initialize_schema_locked()?; + } + } + self.open_raw() + } + + fn open_raw(&self) -> Result { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent).map_err(|error| { SyncError::InvalidState(format!("create sync directory: {error}")) @@ -24,7 +67,40 @@ impl SyncStore { PRAGMA foreign_keys = ON; "#, )?; - Self::initialize_v2_schema(&connection)?; Ok(connection) } } + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + + #[test] + fn deferred_store_initializes_on_first_use_and_coalesces_concurrent_readers() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("cloud/sync.db"); + let store = SyncStore::new_uninitialized(&path); + assert!(!path.exists()); + let handles: Vec<_> = (0..4) + .map(|_| { + let store = store.clone(); + std::thread::spawn(move || store.session_restore_blocked().unwrap()) + }) + .collect(); + for handle in handles { + assert!(!handle.join().unwrap()); + } + assert!(path.is_file()); + assert!(!path.parent().unwrap().join("rebuild-backups").exists()); + } + + #[test] + fn invalid_sync_database_is_backed_up_and_recreated() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("sync.db"); + std::fs::write(&path, b"invalid sync database").unwrap(); + let store = SyncStore::new(&path).unwrap(); + assert!(!store.session_restore_blocked().unwrap()); + assert!(temp.path().join("rebuild-backups").exists()); + } +} diff --git a/app/flowix-sync/src/store/v2_tests.rs b/app/flowix-sync/src/store/v2_tests.rs index 2abbdf8d0..4deae3a51 100644 --- a/app/flowix-sync/src/store/v2_tests.rs +++ b/app/flowix-sync/src/store/v2_tests.rs @@ -14,7 +14,6 @@ fn v2_state_uses_one_account_cursor_and_same_notebook_id() { id: "usr_1".into(), email: "user@example.com".into(), display_name: "User".into(), - system_role: "user".into(), }, protocol_epoch: PROTOCOL_EPOCH, }; @@ -419,7 +418,6 @@ fn switching_users_clears_user_scoped_sync_state() { id: id.into(), email: format!("{id}@example.com"), display_name: id.into(), - system_role: "user".into(), }, protocol_epoch: PROTOCOL_EPOCH, }; @@ -443,7 +441,6 @@ fn clearing_account_removes_user_scoped_sync_state() { id: "usr_a".into(), email: "a@example.com".into(), display_name: "A".into(), - system_role: "user".into(), }, protocol_epoch: PROTOCOL_EPOCH, }) diff --git a/app/flowix-web/app/agent-window-effects.test.ts b/app/flowix-web/app/agent-window-effects.test.ts index a88c55d2a..bbf39e667 100644 --- a/app/flowix-web/app/agent-window-effects.test.ts +++ b/app/flowix-web/app/agent-window-effects.test.ts @@ -3,11 +3,17 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + waitForStartupReady: vi.fn(async (): Promise => undefined), + waitForWorkspaceRestoration: vi.fn(async (): Promise => undefined), useAgentEvents: vi.fn(), refreshRuntime: vi.fn(async () => undefined), - loadAccess: vi.fn(async () => undefined), - listenAccess: vi.fn(), - unlistenAccess: vi.fn(), + refreshCloudPiModelProviderAtStartup: vi.fn(async () => true), + refreshAgentModelCatalog: vi.fn(async () => ({ items: [] })), + loadWorkspace: vi.fn(async (): Promise => undefined), + initializePiMode: vi.fn(async () => undefined), + initializeLegacyPreferences: vi.fn(async () => undefined), + listenWorkspace: vi.fn(), + unlistenWorkspace: vi.fn(), invalidateNotebookCache: vi.fn(), prewarmNotebookCache: vi.fn(async () => undefined), invalidateMentionNotes: vi.fn(), @@ -16,6 +22,13 @@ const mocks = vi.hoisted(() => ({ releaseMemoEventBridge: vi.fn(), })); +vi.mock('./main-window-startup', () => ({ waitForWorkspaceRestoration: mocks.waitForWorkspaceRestoration })); +vi.mock('@features/agent/services/agent-catalog-service', () => ({ + prefetchAvailableAgentModels: vi.fn(), + prefetchNotebookSkills: vi.fn(), + refreshAgentModelCatalog: mocks.refreshAgentModelCatalog, +})); +vi.mock('@features/memo/store/note-store', () => ({ useNoteStore: (selector: (state: { selectedNotebook: null }) => unknown) => selector({ selectedNotebook: null }) })); vi.mock('@features/agent/hooks/use-agent-events', () => ({ useAgentEvents: mocks.useAgentEvents, })); @@ -24,9 +37,17 @@ vi.mock('@features/agent/store/agent-runtime-store', () => ({ selector({ refresh: mocks.refreshRuntime }) ), })); -vi.mock('@features/agent/store/agent-access-store', () => ({ - useAgentAccessStore: (selector: (state: { loadInitial: typeof mocks.loadAccess }) => unknown) => ( - selector({ loadInitial: mocks.loadAccess }) +vi.mock('@features/agent/store/agent-data-store', () => ({ + ensureAgentPreferencesReady: async () => { + await Promise.all([mocks.initializePiMode(), mocks.initializeLegacyPreferences()]); + }, + useAgentDataStore: (selector: (state: { initializePiMode: typeof mocks.initializePiMode; initializeLegacyPreferences: typeof mocks.initializeLegacyPreferences }) => unknown) => ( + selector({ initializePiMode: mocks.initializePiMode, initializeLegacyPreferences: mocks.initializeLegacyPreferences }) + ), +})); +vi.mock('@features/agent/store/agent-workspace-store', () => ({ + useAgentWorkspaceStore: (selector: (state: { workspaceActions: { loadInitial: typeof mocks.loadWorkspace } }) => unknown) => ( + selector({ workspaceActions: { loadInitial: mocks.loadWorkspace } }) ), })); vi.mock('@features/editor/extensions/note-link', () => ({ @@ -40,7 +61,9 @@ vi.mock('@features/editor/extensions/tag-mention', () => ({ invalidateMentionTags: mocks.invalidateMentionTags, })); vi.mock('@platform/tauri/client', () => ({ - listenToAgentAccessChanges: mocks.listenAccess, + boot: { waitForStartupReady: mocks.waitForStartupReady }, + cloud: { refreshPiModelProviderAtStartup: mocks.refreshCloudPiModelProviderAtStartup }, + listenToAgentWorkspaceChanges: mocks.listenWorkspace, })); vi.mock('@/lib/memo-dispatcher', () => ({ acquireMemoEventBridge: () => { @@ -50,68 +73,163 @@ vi.mock('@/lib/memo-dispatcher', () => ({ })); import { AgentWindowEffects } from './agent-window-effects'; -import { useAgentAccessWindowSync } from './use-agent-access-window-sync'; +import { useAgentWorkspaceWindowLifecycle } from './use-agent-workspace-window-lifecycle'; +import { admitStartupStage3 } from '@/lib/startup-stage3'; -function PreferencesAccessSync() { - useAgentAccessWindowSync(); +function PreferencesWorkspaceLifecycle() { + useAgentWorkspaceWindowLifecycle(); return null; } describe('AgentWindowEffects', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.listenAccess.mockReturnValue(mocks.unlistenAccess); + mocks.listenWorkspace.mockReturnValue(mocks.unlistenWorkspace); }); afterEach(() => { document.body.replaceChildren(); }); - it('loads and synchronizes access preferences without mounting the Agent runtime', async () => { + it('loads preferences window Agent data without mounting the Agent runtime', async () => { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); - await act(async () => root.render(createElement(PreferencesAccessSync))); + await act(async () => root.render(createElement(PreferencesWorkspaceLifecycle))); - expect(mocks.loadAccess).toHaveBeenCalledOnce(); - expect(mocks.listenAccess).toHaveBeenCalledOnce(); + expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); + expect(mocks.initializePiMode).toHaveBeenCalledOnce(); + expect(mocks.initializeLegacyPreferences).toHaveBeenCalledOnce(); + expect(mocks.listenWorkspace).toHaveBeenCalledOnce(); expect(mocks.refreshRuntime).not.toHaveBeenCalled(); expect(mocks.useAgentEvents).not.toHaveBeenCalled(); expect(mocks.prewarmNotebookCache).not.toHaveBeenCalled(); - await act(async () => mocks.listenAccess.mock.calls[0][0]()); - expect(mocks.loadAccess).toHaveBeenCalledTimes(2); + await act(async () => mocks.listenWorkspace.mock.calls[0][0]()); + expect(mocks.loadWorkspace).toHaveBeenCalledTimes(2); - const { onListenerReady } = mocks.listenAccess.mock.calls[0][1]; + const { onListenerReady } = mocks.listenWorkspace.mock.calls[0][1]; await act(async () => onListenerReady()); - expect(mocks.loadAccess).toHaveBeenCalledTimes(3); + expect(mocks.loadWorkspace).toHaveBeenCalledTimes(3); // Reconcile again if the native listener reconnects after a failure. await act(async () => onListenerReady()); - expect(mocks.loadAccess).toHaveBeenCalledTimes(4); + expect(mocks.loadWorkspace).toHaveBeenCalledTimes(4); await act(async () => root.unmount()); - expect(mocks.unlistenAccess).toHaveBeenCalledOnce(); + expect(mocks.unlistenWorkspace).toHaveBeenCalledOnce(); + }); + + it('waits for native startup before reading workspace data', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + let ready!: () => void; + mocks.waitForStartupReady.mockImplementationOnce(() => new Promise((resolve) => { ready = resolve; })); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + try { + await act(async () => root.render(createElement(PreferencesWorkspaceLifecycle))); + expect(mocks.loadWorkspace).not.toHaveBeenCalled(); + expect(mocks.initializePiMode).not.toHaveBeenCalled(); + expect(mocks.listenWorkspace).not.toHaveBeenCalled(); + await act(async () => ready()); + expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); + } finally { + await act(async () => root.unmount()); + Reflect.deleteProperty(window, '__TAURI_INTERNALS__'); + } + }); + + it('does not read workspace data when native initialization fails', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + mocks.waitForStartupReady.mockRejectedValueOnce(new Error('migration failed')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render(createElement(PreferencesWorkspaceLifecycle))); + expect(mocks.loadWorkspace).not.toHaveBeenCalled(); + expect(mocks.initializePiMode).not.toHaveBeenCalled(); + expect(mocks.listenWorkspace).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + warn.mockRestore(); + Reflect.deleteProperty(window, '__TAURI_INTERNALS__'); + } }); it('owns the shared Agent lifecycle for a content-capable Webview', async () => { + function MainWorkspaceSync({ revision }: { revision: string }) { + useAgentWorkspaceWindowLifecycle({ deferUntilStage3: true }); + return createElement('span', { 'data-revision': revision }); + } const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); + let finishSnapshot!: () => void; + mocks.loadWorkspace.mockImplementationOnce(() => new Promise((resolve) => { finishSnapshot = resolve; })); await act(async () => { - root.render(createElement(AgentWindowEffects)); + root.render(createElement('div', null, + createElement(MainWorkspaceSync, { key: 'initial', revision: 'initial' }), + createElement(AgentWindowEffects), + )); }); + expect(mocks.listenWorkspace).toHaveBeenCalledTimes(2); + await act(async () => mocks.listenWorkspace.mock.calls[0][0]()); + expect(mocks.loadWorkspace).not.toHaveBeenCalled(); + expect(mocks.initializePiMode).not.toHaveBeenCalled(); + expect(mocks.initializeLegacyPreferences).not.toHaveBeenCalled(); expect(mocks.useAgentEvents).toHaveBeenCalledOnce(); expect(mocks.acquireMemoEventBridge).toHaveBeenCalledOnce(); + expect(mocks.refreshRuntime).not.toHaveBeenCalled(); + expect(mocks.loadWorkspace).not.toHaveBeenCalled(); + expect(mocks.listenWorkspace).toHaveBeenCalledTimes(2); + expect(mocks.prewarmNotebookCache).not.toHaveBeenCalled(); + expect(mocks.refreshCloudPiModelProviderAtStartup).not.toHaveBeenCalled(); + + let finishWorkspaceRestoration!: () => void; + mocks.waitForWorkspaceRestoration.mockImplementationOnce(() => new Promise((resolve) => { + finishWorkspaceRestoration = resolve; + })); + await act(async () => admitStartupStage3()); + expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); + expect(mocks.initializePiMode).toHaveBeenCalledOnce(); + expect(mocks.initializeLegacyPreferences).toHaveBeenCalledOnce(); expect(mocks.refreshRuntime).toHaveBeenCalledWith({ force: true }); - expect(mocks.loadAccess).not.toHaveBeenCalled(); - expect(mocks.listenAccess).toHaveBeenCalledOnce(); expect(mocks.prewarmNotebookCache).toHaveBeenCalledOnce(); + expect(mocks.waitForWorkspaceRestoration).toHaveBeenCalledOnce(); + expect(mocks.refreshCloudPiModelProviderAtStartup).not.toHaveBeenCalled(); + + await act(async () => root.render(createElement('div', null, + createElement(MainWorkspaceSync, { key: 'during', revision: 'during' }), + createElement(AgentWindowEffects), + ))); + expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); + await act(async () => mocks.listenWorkspace.mock.calls[2][0]()); + expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); + + await act(async () => finishWorkspaceRestoration()); + expect(mocks.refreshCloudPiModelProviderAtStartup).toHaveBeenCalledOnce(); + expect(mocks.refreshAgentModelCatalog).toHaveBeenCalledWith('pi'); + + await act(async () => finishSnapshot()); + expect(mocks.loadWorkspace).toHaveBeenCalledTimes(2); + + mocks.listenWorkspace.mockImplementationOnce((_reload, { onListenerReady }) => { + onListenerReady(); + return mocks.unlistenWorkspace; + }); + await act(async () => root.render(createElement('div', null, + createElement(MainWorkspaceSync, { key: 'after', revision: 'after' }), + createElement(AgentWindowEffects), + ))); + expect(mocks.loadWorkspace).toHaveBeenCalledTimes(3); + await act(async () => mocks.listenWorkspace.mock.calls[mocks.listenWorkspace.mock.calls.length - 1][0]()); + expect(mocks.loadWorkspace).toHaveBeenCalledTimes(4); await act(async () => root.unmount()); - expect(mocks.unlistenAccess).toHaveBeenCalledOnce(); + expect(mocks.unlistenWorkspace).toHaveBeenCalledTimes(4); expect(mocks.releaseMemoEventBridge).toHaveBeenCalledOnce(); }); }); diff --git a/app/flowix-web/app/agent-window-effects.tsx b/app/flowix-web/app/agent-window-effects.tsx index 9c01b9a7f..41e606701 100644 --- a/app/flowix-web/app/agent-window-effects.tsx +++ b/app/flowix-web/app/agent-window-effects.tsx @@ -6,8 +6,11 @@ import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store' import { invalidateNotebookCache, prewarmNotebookCache } from '@features/editor/extensions/note-link'; import { invalidateMentionNotes } from '@features/editor/extensions/note-mention'; import { invalidateMentionTags } from '@features/editor/extensions/tag-mention'; -import { listenToAgentAccessChanges } from '@platform/tauri/client'; +import { cloud, listenToAgentWorkspaceChanges } from '@platform/tauri/client'; +import { refreshAgentModelCatalog } from '@features/agent/services/agent-catalog-service'; import { acquireMemoEventBridge } from '@/lib/memo-dispatcher'; +import { runStartupStage3Task } from '@/lib/startup-stage3'; +import { waitForWorkspaceRestoration } from './main-window-startup'; /** * Agent infrastructure shared by every content-capable Webview. @@ -17,15 +20,33 @@ import { acquireMemoEventBridge } from '@/lib/memo-dispatcher'; * infrastructure; preferences intentionally does not mount this component. */ export function AgentWindowEffects() { - useAgentEvents(); + useAgentEvents(waitForWorkspaceRestoration); useEffect(() => acquireMemoEventBridge(), []); const refreshAgentRuntime = useAgentRuntimeStore((state) => state.refresh); useEffect(() => { - void refreshAgentRuntime({ force: true }); + void runStartupStage3Task('agent-runtime-status-refresh', () => { + // The native status command owns the CLI-discovery dependency for every + // consumer, including refreshes initiated by feature-specific views. + return refreshAgentRuntime({ force: true }); + }); }, [refreshAgentRuntime]); useEffect(() => { - return listenToAgentAccessChanges(() => { + void runStartupStage3Task('pi-cloud-model-catalog-refresh', async () => { + // Stage 3 releases low-priority work after the shell paints, while the + // selected workspace may still be restoring its initial surface. + await waitForWorkspaceRestoration(); + const connected = await cloud.refreshPiModelProviderAtStartup(); + if (connected) await refreshAgentModelCatalog('pi'); + }); + }, []); + + useEffect(() => { + void runStartupStage3Task('agent-note-link-cache-prewarm', prewarmNotebookCache); + }, []); + + useEffect(() => { + return listenToAgentWorkspaceChanges(() => { invalidateNotebookCache(); invalidateMentionNotes(); invalidateMentionTags(); @@ -33,9 +54,5 @@ export function AgentWindowEffects() { }); }, []); - useEffect(() => { - void prewarmNotebookCache(); - }, []); - return null; } diff --git a/app/flowix-web/app/app.tsx b/app/flowix-web/app/app.tsx index 5f6abd94f..6b199d373 100644 --- a/app/flowix-web/app/app.tsx +++ b/app/flowix-web/app/app.tsx @@ -1,28 +1,38 @@ 'use client'; -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { CenteredLoadingSpinner } from "@shared/ui/centered-loading-spinner"; +import { useWorkspaceStartupStore, applyNativeStartupStatus } from "@/lib/store/workspace-startup-store"; import { ErrorBoundary } from "@shared/error-boundary"; -import { Toaster } from "sonner"; +import { Toaster, toast } from "sonner"; import { useAppPreferencesViewModel } from "@features/preferences/public/app-api"; import { useAppAgentRuntimeViewModel } from "@features/agent/public/app-api"; import { useApplyFontSettings } from "@features/preferences/public/app-api"; import { ThemeProvider } from "@features/theme"; import { NativeEditMenuBridge, ShortcutsProvider } from "@features/shortcuts"; -import { I18nProvider } from "@/lib/i18n"; +import { I18nProvider, useI18n, type I18nKey } from "@/lib/i18n"; import { TooltipProvider } from "@shared/ui/tooltip"; import "@features/shortcuts/actions"; -import { listenToUserConfigChanges, windows } from "@platform/tauri/client"; +import { boot, listenToUserConfigChanges, windows } from "@platform/tauri/client"; import { syncUserConfigChange } from "./user-config-sync"; -import { useAgentAccessWindowSync } from "./use-agent-access-window-sync"; +import { useAgentWorkspaceWindowLifecycle } from "./use-agent-workspace-window-lifecycle"; import { invalidateDshModelConfigs } from "@features/agent/public/app-api"; import { createLogger } from "@/lib/logger"; import { ActionableNoticeHost } from "@features/notifications/actionable-notice-host"; +import { NotebookSetupNoticeHost } from "@features/memo/components/notebook-setup-notice-host"; +import { NotebookSetupCoordinator } from "@features/memo/components/notebook-setup-coordinator"; +import { useAgentPersistenceError } from "@features/agent/public/app-api"; +import { translate, type AppLanguage } from "@/lib/i18n"; const DocumentSaveNotifications = lazy(() => import('@features/document/public/app-api') .then(module => ({ default: module.DocumentSaveNotifications }))); const logger = createLogger("app"); +function removeAppLoading() { + document.getElementById("app-loading")?.remove(); +} + const MainWindow = lazy(() => import("./main-window/main-window") .then((module) => ({ default: module.MainWindow })) @@ -47,7 +57,93 @@ const AgentWindowEffects = lazy(() => import("./agent-window-effects").then((module) => ({ default: module.AgentWindowEffects })) ); -function AppToaster() { +function isTauriRuntime(): boolean { + return typeof window !== 'undefined' + && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window); +} + +const startupProgressLabels: Record = { + prepareDatabasePaths: 'startup.progress.prepareDatabasePaths', + prepareThreadDatabase: 'startup.progress.prepareThreadDatabase', + prepareSyncDatabase: 'startup.progress.prepareSyncDatabase', + prepareNotebookRegistry: 'startup.progress.prepareNotebookRegistry', + runMigrations: 'startup.progress.runMigrations', + config_dsh_layout_v2: 'startup.progress.migrateAgentLayout', + config_local_migrations_v1: 'startup.progress.migrateLocalConfiguration', + config_watcher_exclusions_v1: 'startup.progress.migrateWatcherSettings', + templates_v1: 'startup.progress.prepareTemplates', + prepareRuntimeStorage: 'startup.progress.prepareRuntimeStorage', + activateRuntime: 'startup.progress.activateRuntime', + completeInitialization: 'startup.progress.completeInitialization', +}; + +function startupProgressLabel(task: string): I18nKey { + if (task.startsWith('deferred:')) return 'startup.progress.deferredNotebookJobs'; + if (task.includes(':notebook:')) return 'startup.progress.migrateNotebookStorage'; + return startupProgressLabels[task] ?? 'startup.progress.runMigrations'; +} + +function StartupLoadingOverlay() { + const nativePhase = useWorkspaceStartupStore((state) => state.nativePhase); + const nativeStage = useWorkspaceStartupStore((state) => state.nativeStage); + const nativeStep = useWorkspaceStartupStore((state) => state.nativeStep); + const nativeProgress = useWorkspaceStartupStore((state) => state.nativeProgress); + const workspaceStatus = useWorkspaceStartupStore((state) => state.status); + const workbenchMounted = useWorkspaceStartupStore((state) => state.workbenchMounted); + const { t } = useI18n(); + const visible = nativePhase === 'pending' || nativePhase === 'running' + || (nativePhase === 'ready' && ( + workspaceStatus === 'idle' + || workspaceStatus === 'running' + || (workspaceStatus === 'ready' && !workbenchMounted) + )); + if (!visible) return null; + return ( +
+ {nativePhase === 'running' && nativeStage === 'initialization' && nativeProgress ? ( +
+ +
+
+
+ + {t(startupProgressLabel(nativeProgress.task))} + {(!['prepareDatabasePaths', 'prepareThreadDatabase', 'prepareSyncDatabase', 'prepareNotebookRegistry', + 'prepareRuntimeStorage', 'activateRuntime', 'completeInitialization'].includes(nativeProgress.task)) + ? ` (${nativeProgress.completed}/${nativeProgress.total})` + : ''} + +
+ ) : ( + + )} +
+ ); +} + +function AppToaster({ language }: { language: AppLanguage }) { + const persistenceError = useAgentPersistenceError(); + const lastPersistenceError = useRef(null); + + useEffect(() => { + if (!persistenceError) { + lastPersistenceError.current = null; + return; + } + if (lastPersistenceError.current === persistenceError) return; + lastPersistenceError.current = persistenceError; + toast.error(translate(language, "preferences.agents.cacheWriteFailed")); + }, [language, persistenceError]); + return ( <> + + ); } -function removeAppLoading() { - document.getElementById("app-loading")?.remove(); -} - function AppReadySignal() { useEffect(() => { removeAppLoading(); @@ -112,8 +206,8 @@ function useUnexpectedControlInputGuard() { function App() { useUnexpectedControlInputGuard(); - useAgentAccessWindowSync(); const [hash, setHash] = useState(() => window.location.hash); + useAgentWorkspaceWindowLifecycle({ deferUntilStage3: !hash.startsWith('#preferences') }); const { language, format, @@ -124,6 +218,62 @@ function App() { const { refreshAgentRuntime } = useAppAgentRuntimeViewModel(); useApplyFontSettings(format); + useEffect(() => { + const setNativeReady = () => useWorkspaceStartupStore.setState((state) => ( + state.nativePhase === 'ready' + && state.nativeStage === 'workspacePreparation' + && state.nativeStep === 'ready' + && state.nativeError === null + && state.nativeProgress === null + ? state + : { ...state, nativePhase: 'ready', nativeStage: 'workspacePreparation', nativeStep: 'ready', nativeError: null, nativeProgress: null } + )); + if (hash.startsWith("#preferences") || !isTauriRuntime()) { + setNativeReady(); + return; + } + let active = true; + let inFlight = false; + let timer: number | undefined; + const refresh = async () => { + if (inFlight) return; + inFlight = true; + try { + const status = await boot.getStartupStatus(); + if (!active) return; + applyNativeStartupStatus(status); + if (status.phase === 'ready' || status.phase === 'failed') { + if (timer !== undefined) window.clearInterval(timer); + timer = undefined; + } + } catch { + if (active) setNativeReady(); + if (timer !== undefined) window.clearInterval(timer); + timer = undefined; + } finally { inFlight = false; } + }; + void refresh(); + timer = window.setInterval(() => void refresh(), 250); + return () => { + active = false; + if (timer !== undefined) window.clearInterval(timer); + }; + }, [hash]); + + useEffect(() => { + const root = document.documentElement; + const handleFocus = () => { root.dataset.windowFocused = "true"; }; + const handleBlur = () => { root.dataset.windowFocused = "false"; }; + root.dataset.windowFocused = String(document.hasFocus()); + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + delete root.dataset.windowFocused; + }; + }, []); + useEffect(() => { void windows.applyMenuLanguage(language).catch((error) => { logger.error("apply native menu language failed", { error }); @@ -176,8 +326,8 @@ function App() { const tab = hash.split("/")[1] || undefined; return ( - + @@ -196,9 +346,10 @@ function App() { return ( - + + @@ -208,6 +359,7 @@ function App() { + {/* The root startup overlay stays mounted while this code loads. */} diff --git a/app/flowix-web/app/desktop-shutdown.test.ts b/app/flowix-web/app/desktop-shutdown.test.ts new file mode 100644 index 000000000..b7b33afe0 --- /dev/null +++ b/app/flowix-web/app/desktop-shutdown.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ surfaces: vi.fn(), preferences: vi.fn(), flush: vi.fn(), diagnostics: vi.fn(), invoke: vi.fn(), subscribe: vi.fn() })); +vi.mock('@platform/tauri/core', () => ({ invoke: mocks.invoke })); +vi.mock('@platform/tauri/event-bus', () => ({ subscribe: mocks.subscribe })); +vi.mock('@platform/tauri/runtime', () => ({ isTauriDesktopRuntime: () => true })); +vi.mock('@features/document/public/shutdown-api', () => ({ flushDocumentsForShutdown: mocks.flush, documentShutdownDiagnostics: mocks.diagnostics })); +vi.mock('@features/workspace/public/shutdown-api', () => ({ flushWorkspaceSurfacesForShutdown: mocks.surfaces })); +vi.mock('@features/preferences/public/shutdown-api', () => ({ flushPreferencesForShutdown: mocks.preferences })); +async function tick() { for (let i = 0; i < 50; i++) await Promise.resolve(); } +async function install() { const module = await import('./desktop-shutdown'); module.installDesktopShutdown(); await tick(); return module; } +function emit(name: string, request: number) { mocks.subscribe.mock.calls.find(([event]) => event === name)![1](request); } +describe('native-owned shutdown preparation', () => { + beforeEach(() => { + vi.resetModules(); Object.values(mocks).forEach(mock => mock.mockReset()); + mocks.surfaces.mockResolvedValue(true); mocks.preferences.mockResolvedValue(undefined); mocks.flush.mockResolvedValue(true); mocks.invoke.mockResolvedValue(undefined); + }); + afterEach(() => { vi.useRealTimers(); }); + it('installs once without a JS native-close interceptor', async () => { + const module = await install(); module.installDesktopShutdown(); + expect(mocks.subscribe).toHaveBeenCalledTimes(3); + }); + it('reports persistence success and leaves destruction to Rust', async () => { + await install(); emit('document:prepare-close', 1); await tick(); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 1, ready: true }); + expect(document.querySelector('dialog')).toBeNull(); + }); + it.each(['false', 'reject'])('still acknowledges close after save %s', async failure => { + if (failure === 'false') mocks.flush.mockResolvedValue(false); + else mocks.flush.mockRejectedValue(new Error('disk unavailable')); + await install(); emit('document:prepare-close', 2); await tick(); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 2, ready: false }); + expect(document.querySelector('dialog')).toBeNull(); + }); + it('leaves a stuck save to the native deadline without starting a second timer', async () => { + vi.useFakeTimers(); mocks.flush.mockReturnValue(new Promise(() => {})); + await install(); emit('document:prepare-close', 3); await tick(); + await vi.advanceTimersByTimeAsync(2_000); + expect(mocks.invoke).not.toHaveBeenCalledWith('finish_document_close', expect.anything()); + expect(vi.getTimerCount()).toBe(0); + }); + it('global quit captures fresh inputs and ignores the superseded acknowledgement', async () => { + let finish!: (value: boolean) => void; + mocks.flush.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await install(); emit('document:prepare-close', 4); await tick(); + emit('document:prepare-exit', 5); await tick(); finish(false); await tick(); + expect(mocks.flush).toHaveBeenCalledTimes(2); + expect(mocks.invoke).not.toHaveBeenCalledWith('finish_document_close', expect.anything()); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_shutdown', { request: 5, ready: true }); + }); + it('restores interaction when the updater cancels', async () => { + await install(); emit('document:prepare-exit', 6); emit('document:exit-cancelled', 6); await tick(); + expect(document.querySelector('dialog')).toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalledWith('finish_document_shutdown', expect.anything()); + }); + it('diagnostics failure cannot prevent persistence or acknowledgement', async () => { + mocks.diagnostics.mockImplementation(() => { throw new Error('diagnostics unavailable'); }); + await install(); emit('document:prepare-close', 11); await tick(); + expect(mocks.flush).toHaveBeenCalledTimes(1); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 11, ready: true }); + }); + it('does not show a blocking notice while persistence is pending', async () => { + let finish!: (value: boolean) => void; + mocks.flush.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await install(); emit('document:prepare-close', 12); await tick(); + expect(document.querySelector('dialog')).toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalledWith('finish_document_close', expect.anything()); + finish(true); await tick(); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 12, ready: true }); + }); + it('closes immediately when all saves finish, without waiting one second', async () => { + vi.useFakeTimers(); + await install(); emit('document:prepare-close', 8); await tick(); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 8, ready: true }); + expect(vi.getTimerCount()).toBe(0); + }); + it('starts preferences and documents together and waits for both', async () => { + let finish!: () => void; + mocks.preferences.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await install(); emit('document:prepare-close', 9); await tick(); + expect(mocks.flush).toHaveBeenCalledTimes(1); + expect(mocks.preferences).toHaveBeenCalledTimes(1); + expect(mocks.invoke).not.toHaveBeenCalledWith('finish_document_close', expect.anything()); + finish(); await tick(); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 9, ready: true }); + }); + it('preference failure cannot veto closing', async () => { + mocks.preferences.mockRejectedValueOnce(new Error('settings locked')); + await install(); emit('document:prepare-close', 10); await tick(); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 10, ready: false }); + }); + + it('flushes fresh preferences and surfaces after updater cancellation', async () => { + let finish!: (value: boolean) => void; + mocks.flush.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await install(); emit('document:prepare-exit', 21); await tick(); + emit('document:exit-cancelled', 21); + // The previous document write is still pending; new edits may now exist. + emit('document:prepare-close', 22); await tick(); + expect(mocks.preferences).toHaveBeenCalledTimes(2); + expect(mocks.surfaces).toHaveBeenCalledTimes(2); + expect(mocks.invoke).toHaveBeenCalledWith('finish_document_close', { request: 22, ready: true }); + finish(true); await tick(); + expect(mocks.invoke).not.toHaveBeenCalledWith('finish_document_shutdown', { request: 21, ready: true }); + }); + +}); diff --git a/app/flowix-web/app/desktop-shutdown.ts b/app/flowix-web/app/desktop-shutdown.ts new file mode 100644 index 000000000..9fa74ea33 --- /dev/null +++ b/app/flowix-web/app/desktop-shutdown.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@/lib/logger'; +import { invoke } from '@platform/tauri/core'; +import { subscribe } from '@platform/tauri/event-bus'; +import { isTauriDesktopRuntime } from '@platform/tauri/runtime'; +import { flushDocumentsForShutdown, documentShutdownDiagnostics } from '@features/document/public/shutdown-api'; +import { flushWorkspaceSurfacesForShutdown } from '@features/workspace/public/shutdown-api'; +import { flushPreferencesForShutdown } from '@features/preferences/public/shutdown-api'; + +const log = createLogger('window:shutdown'); +let installed = false; + +type ShutdownKind = 'close' | 'exit'; +type ShutdownAttempt = { + request: number; + kind: ShutdownKind; +}; + +function safeDiagnostics(): object { + try { + return documentShutdownDiagnostics(); + } catch { + return { unavailable: true }; + } +} + +function logFailure(level: 'warn' | 'error', message: string, detail?: unknown): void { + try { + const context = detail === undefined + ? undefined + : detail !== null && typeof detail === 'object' && !Array.isArray(detail) + ? detail as Readonly> + : { error: detail }; + log[level](message, context); + } catch { + // Logging is diagnostic and must not become part of the close protocol. + } +} + +/** This is a best-effort report, never permission to close. Rust owns deadlines. */ +async function prepare(): Promise { + const started = Date.now(); + try { + const results = await Promise.allSettled([ + Promise.resolve().then(flushDocumentsForShutdown), + Promise.resolve().then(flushPreferencesForShutdown), + Promise.resolve().then(flushWorkspaceSurfacesForShutdown), + ]); + const ready = results.every(result => result.status === 'fulfilled' && result.value !== false); + try { + log.info('prepare settled', { ready, elapsedMs: Date.now() - started, ...safeDiagnostics() }); + } catch { + // A diagnostics or logger failure cannot veto the close acknowledgement. + } + return ready; + } catch (error) { + logFailure('warn', 'shutdown preparation failed', error); + return false; + } +} + +async function acknowledge(attempt: ShutdownAttempt, ready: boolean): Promise { + try { + await invoke(attempt.kind === 'close' ? 'finish_document_close' : 'finish_document_shutdown', { + request: attempt.request, + ready, + }); + } catch (error) { + logFailure('warn', 'shutdown acknowledgement failed; native deadline remains active', { + request: attempt.request, + kind: attempt.kind, + error, + }); + } +} + +/** Install once. Native CloseRequested must never depend on a JS listener. */ +export function installDesktopShutdown(): void { + if (installed || !isTauriDesktopRuntime()) return; + installed = true; + + let active: ShutdownAttempt | null = null; + const clear = () => { active = null; }; + + const begin = (request: number, kind: ShutdownKind) => { + if (active?.request === request && active.kind === kind) return; + clear(); + const attempt: ShutdownAttempt = { request, kind }; + active = attempt; + + // Persistence is background work; native Rust owns the hard close deadline. + const preparation = prepare(); + + void preparation.then(async ready => { + if (active !== attempt) return; + await acknowledge(attempt, ready); + }).catch(async error => { + logFailure('error', 'shutdown preparation failed; reporting best-effort close', { + request, + kind, + error, + }); + if (active === attempt) await acknowledge(attempt, false); + }); + }; + + subscribe('document:prepare-close', request => begin(request, 'close')); + subscribe('document:exit-cancelled', request => { + // Only the updater can cancel. Ordinary user close/quit never emits this. + if (active?.kind === 'exit' && active.request === request) clear(); + }); + subscribe('document:prepare-exit', request => begin(request, 'exit'), { + onListenerReady: () => { + void invoke('register_document_window').catch(error => { + logFailure('error', 'window registration failed', error); + }); + }, + }); +} diff --git a/app/flowix-web/app/main-window-effects.tsx b/app/flowix-web/app/main-window-effects.tsx index a3be01c45..59fccd4c9 100644 --- a/app/flowix-web/app/main-window-effects.tsx +++ b/app/flowix-web/app/main-window-effects.tsx @@ -1,11 +1,13 @@ 'use client'; import { useEffect } from "react"; -import { subscribe } from '@platform/tauri/event-bus'; +import { subscribeNotebookChanges } from '@platform/tauri/notebook-change-events'; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useI18n } from "@/lib/i18n"; import { subscribeAppActiveAgentConversation } from "@features/document/public/app-api"; import { + mountNotebookBackgroundUpdates, + mountMemoListRefreshService, applyAppMemoCreated, applyAppMemoDeleted, applyAppMemoUpdated, @@ -32,23 +34,58 @@ import { import { initializeMainWindowStartup } from './main-window-startup'; import { createLogger } from '@/lib/logger'; import { joinNotebookMemoPath } from '@/lib/path'; +import { useUserSettingsStore } from '@features/preferences/store/user-settings-store'; import { resumePendingNoteLinkUpdates } from '@features/memo/public/app-api'; -import { subscribeWorkspaceDocumentSaves } from '@features/document/public/workspace-api'; +import { subscribeWorkspaceDocumentSaves, rebaseStoredRecoveryDraftPaths } from '@features/document/public/workspace-api'; import { syncAppAgentConversationRestore, - openBrowserColumnNotebookNote, removeBrowserColumnTabsByPath, applyNotebookPathMove, } from '@features/workspace/public/app-api'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; +import { clearWorkspaceTargetForDeletedFile } from '@features/workspace/public/shell-api'; +import { + dismissNavigationFailure, + retryLastNavigation, + useWorkspaceNavigationFailure, +} from '@features/workspace/public/main-window-api'; +import { + clearActionableNotice, + upsertActionableNotice, +} from '@features/notifications/actionable-notice-store'; const logger = createLogger('main-window-effects'); +const NAVIGATION_FAILURE_NOTICE_ID = 'workspace-navigation-failure'; export function MainWindowEffects() { const { t } = useI18n(); const mainWindowTitle = t("window.main.title"); + const navigationFailure = useWorkspaceNavigationFailure(); + + useEffect(() => { + if (!navigationFailure) { + clearActionableNotice(NAVIGATION_FAILURE_NOTICE_ID); + return; + } + upsertActionableNotice({ + id: NAVIGATION_FAILURE_NOTICE_ID, + priority: 100, + tone: 'error', + title: t('shell.navigation.failed'), + message: navigationFailure.message, + actions: [{ id: 'retry', label: t('error.retry'), run: retryLastNavigation }], + dismissible: true, + onDismiss: dismissNavigationFailure, + revision: navigationFailure.requestId, + }); + }, [navigationFailure, t]); + + useEffect(() => () => clearActionableNotice(NAVIGATION_FAILURE_NOTICE_ID), []); + + useEffect(() => mountNotebookBackgroundUpdates(), []); + useEffect(() => mountMemoListRefreshService(), []); useEffect(() => { - resumePendingNoteLinkUpdates(); return subscribeWorkspaceDocumentSaves(resumePendingNoteLinkUpdates); }, []); @@ -82,27 +119,55 @@ export function MainWindowEffects() { }, [mainWindowTitle]); useEffect(() => { - let moveChain = Promise.resolve(); + let pathChangeChain = Promise.resolve(); let disposed = false; - const unsubscribe = subscribe<{ notebookId: string; relativePath: string; previousRelativePath?: string; directory?: boolean; deleted?: boolean }>( - 'flowix:path-note-changed', - ({ notebookId, relativePath, previousRelativePath, directory, deleted }) => { + const unsubscribe = subscribeNotebookChanges( + (change) => { + const { notebookId } = change; invalidateMentionNotes(); invalidateMentionTags(); refreshAppPathNoteMetadata(notebookId); - if (previousRelativePath && relativePath && !deleted) { - moveChain = moveChain.then(async () => { + if (change.kind === 'resource-created' + && change.source === 'external_tool' + && change.relativePath.split('/').pop() !== 'AGENTS.md' + && useUserSettingsStore.getState().settings.autoOpenCreatedNotesInBrowser) { + const { relativePath } = change; + pathChangeChain = pathChangeChain.then(async () => { + if (disposed) return; const notebookPath = await getAppNotebookPath(notebookId); - if (!disposed && notebookPath) applyNotebookPathMove({ - notebookId, notebookPath, previousRelativePath, relativePath, - directory: directory === true, + const path = notebookPath && joinNotebookMemoPath(notebookPath, relativePath); + if (disposed || !path || !notebookPath) return; + await openWorkspaceSurface({ + host: 'browser-column', + target: { kind: 'external', path, scopePath: notebookPath, transitionId: null }, + options: { notebookId }, }); + }).catch((error) => logger.warn('open externally created note failed', { error, notebookId, relativePath })); + } + if (change.kind === 'path-moved') { + const { previousRelativePath, relativePath, directory } = change; + pathChangeChain = pathChangeChain.then(async () => { + if (disposed) return; + const notebookPath = await getAppNotebookPath(notebookId); + if (!disposed && notebookPath) { + applyNotebookPathMove({ notebookId, notebookPath, previousRelativePath, relativePath, directory }); + const previous = joinNotebookMemoPath(notebookPath, previousRelativePath); + const next = joinNotebookMemoPath(notebookPath, relativePath); + if (previous && next) await rebaseStoredRecoveryDraftPaths(previous, next, directory); + } }).catch((error) => logger.warn('rebase moved note path failed', { error, notebookId, relativePath })); } - if (deleted && relativePath) { - void getAppNotebookPath(notebookId).then((notebookPath) => { + if (change.kind === 'resource-deleted') { + const { relativePath } = change; + pathChangeChain = pathChangeChain.then(async () => { + if (disposed) return; + const notebookPath = await getAppNotebookPath(notebookId); + if (disposed) return; const path = notebookPath && joinNotebookMemoPath(notebookPath, relativePath); - if (path) removeBrowserColumnTabsByPath(path); + if (path) { + removeBrowserColumnTabsByPath(path); + await clearWorkspaceTargetForDeletedFile(path); + } }).catch((error) => logger.warn('resolve deleted note path failed', { error, notebookId, relativePath })); } }, @@ -137,7 +202,11 @@ export function MainWindowEffects() { const notebookPath = await getAppNotebookPath(notebookId); const path = notebookPath && joinNotebookMemoPath(notebookPath, relativePath); if (!path) throw new Error('Created note path is unavailable'); - await openBrowserColumnNotebookNote(path, notebookId, notebookPath); + await openWorkspaceSurface({ + host: 'browser-column', + target: { kind: 'external', path, scopePath: notebookPath, transitionId: null }, + options: { notebookId }, + }); }, reportOpenFailure: (error) => { logger.warn('open created note in browser column failed', { error }); @@ -147,6 +216,10 @@ export function MainWindowEffects() { handleMemoUpdated: applyAppMemoUpdated, handleMemoDeleted: applyAppMemoDeleted, removeBrowserColumnTabsByPath: (path) => removeBrowserColumnTabsByPath(path), + clearWorkspaceTargetByPath: (path) => { + void clearWorkspaceTargetForDeletedFile(path).catch((error) => + logger.warn('clear deleted document target failed', { error, path })); + }, handleTagsRenamed: applyAppTagsRenamed, handleTagsDeleted: applyAppTagsDeleted, refreshSelectedNotebookMetadata, diff --git a/app/flowix-web/app/main-window-memo-event-handler.test.ts b/app/flowix-web/app/main-window-memo-event-handler.test.ts index 767ea7a42..2476fa4af 100644 --- a/app/flowix-web/app/main-window-memo-event-handler.test.ts +++ b/app/flowix-web/app/main-window-memo-event-handler.test.ts @@ -8,7 +8,7 @@ import type { MemoEvent } from '@/types/memo'; import type { MemoItem } from '@/types/memo-item'; const memo: MemoItem = { - id: 'memo-b', + relativePath: 'Created.md', filename: 'Created.md', preview: '', tags: [], @@ -135,7 +135,6 @@ describe('handleMainWindowMemoEvent', () => { const actions = createActions('notebook-b'); const event: MemoEvent = { kind: 'updated', - id: memo.id, path: '/notebook-b/Renamed.md', memo: { ...memo, filename: 'Renamed.md' }, notebookId: 'notebook-b', diff --git a/app/flowix-web/app/main-window-memo-event-handler.ts b/app/flowix-web/app/main-window-memo-event-handler.ts index 4fd60db14..f949c5909 100644 --- a/app/flowix-web/app/main-window-memo-event-handler.ts +++ b/app/flowix-web/app/main-window-memo-event-handler.ts @@ -11,6 +11,7 @@ export interface MainWindowMemoEventActions { handleMemoUpdated: (memo: MemoItem) => void; handleMemoDeleted: () => void; removeBrowserColumnTabsByPath: (path: string) => void; + clearWorkspaceTargetByPath?: (path: string) => void; handleTagsRenamed: (event: Extract) => void; handleTagsDeleted: (event: Extract) => void; refreshSelectedNotebookMetadata: (event: MemoEvent) => void; @@ -20,10 +21,11 @@ export interface MainWindowMemoEventActions { /** * Route one memo event inside the main Webview. * - * Externally created notes and notes created in a background notebook may - * auto-open according to the user preference. Template notes skip that side - * effect. List and tag metadata updates remain scoped to the selected - * notebook, while notebook-keyed todo counts refresh in background. + * Legacy Memo events for externally created notes and notes created in a + * background notebook may auto-open according to the user preference. + * Path-first external creates are handled by the notebook path event instead. + * Template notes skip auto-open. List and tag metadata updates remain scoped + * to the selected notebook, while notebook-keyed todo counts refresh in background. * * `tags_renamed` / `tags_deleted` 都是 tag 子树操作的收口事件, 后端已经 * 完成所有 affected memo 的 body 改写 + index 同步。 这里只走 @@ -58,6 +60,7 @@ export function handleMainWindowMemoEvent( // events, otherwise a later tab activation can resurrect the old filename. if (event.kind === 'deleted') { actions.removeBrowserColumnTabsByPath(event.path); + actions.clearWorkspaceTargetByPath?.(event.path); } const selectedNotebookId = actions.getSelectedNotebookId(); diff --git a/app/flowix-web/app/main-window-startup.test.ts b/app/flowix-web/app/main-window-startup.test.ts index 5da92136b..b4cd6630c 100644 --- a/app/flowix-web/app/main-window-startup.test.ts +++ b/app/flowix-web/app/main-window-startup.test.ts @@ -1,122 +1,115 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { boot } from '@platform/tauri/client'; -import { useNoteStore } from '@features/memo/store/note-store'; -import { useDocumentStore } from '@features/document/store/document-store'; -import { waitForInitialDocumentLoad } from '@features/document/public/startup-api'; +import { useWorkspaceStartupStore } from '@/lib/store/workspace-startup-store'; +import { notifyWorkbenchMounted } from '@/lib/workbench-mount'; const mocks = vi.hoisted(() => ({ - initializeNoteLibrary: vi.fn(), - captureWorkspaceRestoreTarget: vi.fn(), - restoreExternalDocumentWorkspace: vi.fn(), - restoreMediaWorkspace: vi.fn(), - restoreTableWorkspace: vi.fn(), - restoreCollectionWorkspace: vi.fn(), - setWorkspaceRestoreStatus: vi.fn(), - restoreAgentConversationWorkspace: vi.fn(), - calls: [] as string[], + context: vi.fn(), markError: vi.fn(), resumeNoteLinks: vi.fn(), target: vi.fn(), restore: vi.fn(), restoreStatus: vi.fn(), + native: vi.fn(), notebook: vi.fn(), notify: vi.fn(), record: vi.fn(), revision: 0, })); - -vi.mock('@features/memo/public/app-api', async (importOriginal) => ({ - ...(await importOriginal()), - initializeNotebookContext: mocks.initializeNoteLibrary, +vi.mock('@platform/tauri/client', () => ({ boot: { + waitForStartupReady: mocks.native, getStartupNotebookId: mocks.notebook, + notifyStartupInteractive: mocks.notify, recordStartupStage: mocks.record, +} })); +vi.mock('@features/memo/public/app-api', () => ({ + initializeNotebookContext: mocks.context, + markMemoLibraryStartupError: mocks.markError, + resumePendingNoteLinkUpdates: mocks.resumeNoteLinks, })); vi.mock('@features/workspace/public/startup-api', () => ({ - captureWorkspaceRestoreTarget: mocks.captureWorkspaceRestoreTarget, - restoreExternalDocumentWorkspace: mocks.restoreExternalDocumentWorkspace, - restoreMediaWorkspace: mocks.restoreMediaWorkspace, - restoreTableWorkspace: mocks.restoreTableWorkspace, - restoreCollectionWorkspace: mocks.restoreCollectionWorkspace, - setWorkspaceRestoreStatus: mocks.setWorkspaceRestoreStatus, - restoreAgentConversationWorkspace: mocks.restoreAgentConversationWorkspace, + captureWorkspaceRestoreTarget: mocks.target, setWorkspaceRestoreStatus: mocks.restoreStatus, + restoreExternalDocumentWorkspace: mocks.restore, restoreMediaWorkspace: mocks.restore, + restoreTableWorkspace: mocks.restore, restoreCollectionWorkspace: mocks.restore, + restoreFolderCollectionWorkspace: mocks.restore, restoreLegacyFolderListWorkspace: mocks.restore, + restoreAgentConversationWorkspace: mocks.restore, })); - +vi.mock('@features/memo/public/workspace-api', () => ({ + getWorkspaceMemoState: () => ({ notebooks: [{ id: 'notebook-a' }] }), +})); +vi.mock('@features/workspace/public/shell-api', () => ({ getWorkspaceNavigationRevision: () => mocks.revision })); import { initializeMainWindowStartup } from './main-window-startup'; -describe('initializeMainWindowStartup', () => { - beforeEach(() => { - vi.restoreAllMocks(); - Reflect.deleteProperty(window, '__TAURI_INTERNALS__'); - useNoteStore.getState().setStartupPhase('idle'); - useDocumentStore.setState({ isDocumentTransitioning: false, documentTransitionId: 0 }); - mocks.calls.length = 0; - mocks.initializeNoteLibrary.mockReset().mockImplementation(async () => { - mocks.calls.push('memo-library'); - }); - mocks.captureWorkspaceRestoreTarget.mockReset().mockReturnValue(null); - mocks.restoreExternalDocumentWorkspace.mockReset(); - mocks.restoreMediaWorkspace.mockReset(); - mocks.setWorkspaceRestoreStatus.mockReset(); - mocks.restoreAgentConversationWorkspace.mockReset().mockImplementation(async () => { - mocks.calls.push('agent-workspace'); - }); - }); +beforeEach(() => { + vi.resetAllMocks(); + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { callback(0); return 1; }); + useWorkspaceStartupStore.setState({ status: 'idle', task: null, error: null }); + notifyWorkbenchMounted(); + mocks.revision = 0; + mocks.native.mockResolvedValue(undefined); mocks.notebook.mockResolvedValue('notebook-a'); + mocks.context.mockResolvedValue(undefined); mocks.notify.mockResolvedValue(undefined); + mocks.record.mockResolvedValue(undefined); mocks.target.mockReturnValue(null); + mocks.restore.mockResolvedValue(undefined); +}); - it('runs startup stages in dependency order', async () => { +describe('workbench startup', () => { + it('resolves metadata before releasing the shell and starting content restoration', async () => { + const order: string[] = []; + mocks.context.mockImplementation(async () => { order.push('context'); }); + mocks.target.mockReturnValue({ kind: 'external', path: '/notes/a.md', scopePath: '/notes' }); + mocks.notify.mockImplementation(async () => { order.push('interactive'); }); + mocks.restore.mockImplementation(async () => { order.push('restore'); }); await initializeMainWindowStartup(); - - expect(mocks.calls).toEqual([ - 'memo-library', - ]); + expect(order).toEqual(['context', 'interactive', 'restore']); + expect(useWorkspaceStartupStore.getState().status).toBe('ready'); }); - it('stops dependent restoration when library initialization fails', async () => { - mocks.initializeNoteLibrary.mockRejectedValueOnce(new Error('backend unavailable')); + it('does not hold stage 2 open while a restored document is unresolved', async () => { + let finish!: () => void; + mocks.target.mockReturnValue({ kind: 'external', path: '/slow.md', scopePath: null }); + mocks.restore.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + await initializeMainWindowStartup(); + expect(mocks.restore).toHaveBeenCalledOnce(); + expect(mocks.notify).toHaveBeenCalledOnce(); + expect(useWorkspaceStartupStore.getState().status).toBe('ready'); + finish(); + await vi.waitFor(() => expect(mocks.restoreStatus).toHaveBeenLastCalledWith('restored')); + }); - await expect(initializeMainWindowStartup()).rejects.toThrow('backend unavailable'); - expect(mocks.restoreExternalDocumentWorkspace).not.toHaveBeenCalled(); - expect(mocks.restoreAgentConversationWorkspace).not.toHaveBeenCalled(); + it('keeps restoration errors local after the workbench is interactive', async () => { + mocks.target.mockReturnValue({ kind: 'external', path: '/missing.md', scopePath: null }); + mocks.restore.mockRejectedValue(new Error('file unavailable')); + await initializeMainWindowStartup(); + await vi.waitFor(() => expect(mocks.restoreStatus).toHaveBeenLastCalledWith('unavailable')); + expect(useWorkspaceStartupStore.getState()).toMatchObject({ status: 'ready', error: null }); }); - it('shows a retryable error when the native notebook identity cannot be read', async () => { - Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); - vi.spyOn(boot, 'waitForStartupReady').mockResolvedValue(); - vi.spyOn(boot, 'getStartupNotebookId').mockRejectedValue(new Error('notebook identity unavailable')); - const notifyInteractive = vi.spyOn(boot, 'notifyStartupInteractive').mockResolvedValue(); - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - callback(0); - return 1; + it('does not restore an old target after the user navigates during paint', async () => { + mocks.target.mockReturnValue({ kind: 'external', path: '/old.md', scopePath: null }); + vi.mocked(window.requestAnimationFrame).mockImplementation((callback) => { + mocks.revision = 1; callback(0); return 1; }); - - await expect(initializeMainWindowStartup()).rejects.toThrow('notebook identity unavailable'); - - expect(mocks.initializeNoteLibrary).not.toHaveBeenCalled(); - expect(useNoteStore.getState().startupPhase).toBe('error'); - expect(notifyInteractive).toHaveBeenCalledOnce(); + await initializeMainWindowStartup(); + expect(mocks.restore).not.toHaveBeenCalled(); + expect(useWorkspaceStartupStore.getState().status).toBe('ready'); }); - it('holds background maintenance until the restored document finishes loading', async () => { - Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); - vi.spyOn(boot, 'waitForStartupReady').mockResolvedValue(); - vi.spyOn(boot, 'getStartupNotebookId').mockResolvedValue('notebook-a'); - vi.spyOn(boot, 'recordStartupStage').mockResolvedValue(); - const notifyInteractive = vi.spyOn(boot, 'notifyStartupInteractive').mockResolvedValue(); - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - callback(0); - return 1; - }); - mocks.captureWorkspaceRestoreTarget.mockReturnValue({ kind: 'external', path: '/notes/active.md', scopePath: '/notes' }); - mocks.restoreExternalDocumentWorkspace.mockImplementationOnce(async () => { - useDocumentStore.setState({ isDocumentTransitioning: true, documentTransitionId: 1 }); - }); + it('exposes a metadata failure without starting dependent content restoration', async () => { + mocks.context.mockRejectedValue(new Error('registry unavailable')); + await expect(initializeMainWindowStartup()).rejects.toThrow('registry unavailable'); + expect(useWorkspaceStartupStore.getState()).toMatchObject({ status: 'failed', task: 'notebook-context' }); + expect(mocks.restore).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledOnce(); + }); - const startup = initializeMainWindowStartup(); - await vi.waitFor(() => expect(useDocumentStore.getState().isDocumentTransitioning).toBe(true)); - expect(notifyInteractive).not.toHaveBeenCalled(); + it('does not start background work when native preparation fails', async () => { + mocks.native.mockRejectedValue(new Error('storage unavailable')); + await expect(initializeMainWindowStartup()).rejects.toThrow('storage unavailable'); + expect(mocks.notify).not.toHaveBeenCalled(); expect(mocks.context).not.toHaveBeenCalled(); + }); - useDocumentStore.getState().finishDocumentTransition(1); - await startup; - expect(notifyInteractive).toHaveBeenCalledOnce(); + it('retains the identity error and releases an error screen for retry', async () => { + mocks.notebook.mockRejectedValue(new Error('identity unavailable')); + await expect(initializeMainWindowStartup()).rejects.toThrow('identity unavailable'); + expect(mocks.markError).toHaveBeenCalledOnce(); expect(mocks.notify).toHaveBeenCalledOnce(); }); - it('releases the initial document barrier if its load stalls', async () => { - vi.useFakeTimers(); - try { - useDocumentStore.setState({ isDocumentTransitioning: true, documentTransitionId: 1 }); - const wait = waitForInitialDocumentLoad(100); - await vi.advanceTimersByTimeAsync(100); - await expect(wait).resolves.toBe('timeout'); - } finally { - vi.useRealTimers(); - } + it('coalesces concurrent startup calls', async () => { + let finish!: () => void; + mocks.context.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const first = initializeMainWindowStartup(); const second = initializeMainWindowStartup(); + expect(second).toBe(first); + await vi.waitFor(() => expect(mocks.context).toHaveBeenCalledOnce()); + finish(); await first; + expect(useWorkspaceStartupStore.getState().status).toBe('ready'); }); }); diff --git a/app/flowix-web/app/main-window-startup.ts b/app/flowix-web/app/main-window-startup.ts index da2049e45..aaa64dbc9 100644 --- a/app/flowix-web/app/main-window-startup.ts +++ b/app/flowix-web/app/main-window-startup.ts @@ -1,106 +1,194 @@ import { initializeNotebookContext, markMemoLibraryStartupError, + resumePendingNoteLinkUpdates, } from '@features/memo/public/app-api'; -import { waitForInitialDocumentLoad } from '@features/document/public/startup-api'; import { captureWorkspaceRestoreTarget, - restoreDocumentListWorkspace, + restoreLegacyFolderListWorkspace, restoreAgentConversationWorkspace, restoreExternalDocumentWorkspace, restoreMediaWorkspace, restoreTableWorkspace, restoreCollectionWorkspace, + restoreFolderCollectionWorkspace, setWorkspaceRestoreStatus, type PersistedWorkspaceTarget, } from '@features/workspace/public/startup-api'; import { boot } from '@platform/tauri/client'; +import { useWorkspaceStartupStore } from '@/lib/store/workspace-startup-store'; +import { getWorkspaceNavigationRevision } from '@features/workspace/public/shell-api'; +import { getWorkspaceMemoState } from '@features/memo/public/workspace-api'; +import { admitStartupStage3, runStartupStage3Task } from '@/lib/startup-stage3'; +import { waitForWorkbenchMounted } from '@/lib/workbench-mount'; let startupAttemptSequence = 0; +let startupPromise: Promise | null = null; +let workspaceRestorationPromise: Promise = Promise.resolve(); + +/** Agent history catch-up yields to the selected target, with a liveness bound. */ +export async function waitForWorkspaceRestoration(maxWaitMs = 2_000): Promise { + let timeout: number | undefined; + await Promise.race([ + workspaceRestorationPromise, + new Promise((resolve) => { + timeout = window.setTimeout(resolve, maxWaitMs); + }), + ]); + if (timeout !== undefined) window.clearTimeout(timeout); +} /** - * Run the main-window startup stages in one failure-aware transaction. - * - * Notebook identity is resolved before workspace restoration. The cards query - * then loads independently inside its view, so it cannot block folders or - * conversations. + * Stage 2 ends when notebook identity and the interactive shell are available. + * Restored content loads independently, with view-local loading/error states. */ -export async function initializeMainWindowStartup(): Promise { +export function initializeMainWindowStartup(): Promise { + if (startupPromise) return startupPromise; + const { status } = useWorkspaceStartupStore.getState(); + if (status === 'ready' || status === 'degraded') { + admitStartupStage3(); + void runStartupStage3Task('pending-note-link-updates', resumePendingNoteLinkUpdates); + return Promise.resolve(); + } + startupPromise = performMainWindowStartup().finally(() => { startupPromise = null; }); + return startupPromise; +} + +async function performMainWindowStartup(): Promise { const startedAt = performance.now(); const nativeRuntime = isTauriRuntime(); + let nativeReady = !nativeRuntime; const startupAttemptId = `${Date.now()}-${++startupAttemptSequence}`; const logStage = (stage: string) => { const elapsedMs = Math.round(performance.now() - startedAt); console.info('[perf:startup]', stage, { elapsedMs }); if (nativeRuntime) void boot.recordStartupStage(stage, elapsedMs, startupAttemptId).catch(() => undefined); }; - let startupNotebookId: string | null = null; - if (nativeRuntime) { - await boot.waitForStartupReady(); - logStage('native-ready'); - } + // Keep only workbench prerequisites in this awaited chain. Content hydration + // and optional services start after the shell has painted. + const runTask = async (task: string, operation: () => Promise): Promise => { + useWorkspaceStartupStore.setState({ task }); + logStage(`task:${task}:start`); + const result = await operation(); + logStage(`task:${task}:complete`); + return result; + }; + useWorkspaceStartupStore.setState({ + status: 'running', task: 'native-ready', error: null, + }); try { if (nativeRuntime) { - try { - startupNotebookId = await boot.getStartupNotebookId(); - logStage('notebook-selected'); - } catch (error) { - markMemoLibraryStartupError(error); - throw error; - } + await boot.waitForStartupReady(); + nativeReady = true; + useWorkspaceStartupStore.setState({ nativePhase: 'ready', nativeStage: 'workspacePreparation', nativeProgress: null, nativeError: null }); + logStage('native-ready'); + } + const startupNotebookId = nativeRuntime + ? await runTask('notebook-selection', async () => { + try { + return await boot.getStartupNotebookId(); + } catch (error) { + markMemoLibraryStartupError(error); + throw error; + } + }) + : null; + logStage('notebook-selected'); + await runTask('notebook-context', () => initializeNotebookContext(startupNotebookId)); + const hasNotebook = getWorkspaceMemoState().notebooks.length > 0; + const desiredTarget = hasNotebook ? captureWorkspaceRestoreTarget() : null; + const navigationRevision = getWorkspaceNavigationRevision(); + // Release the shell before reading documents, tables or agent histories. + useWorkspaceStartupStore.setState({ status: 'ready', task: null, error: null }); + setWorkspaceRestoreStatus(desiredTarget ? 'restoring' : 'restored'); + await waitForWorkbenchMounted(); + await afterShellPaint(); + if (nativeRuntime) { + logStage('first-workspace-paint'); + await boot.notifyStartupInteractive().catch(() => undefined); } - await initializeNotebookContext(startupNotebookId); - logStage('notebook-context-ready'); - const desiredTarget = captureWorkspaceRestoreTarget(); - setWorkspaceRestoreStatus('restoring'); - try { - await restoreDesiredTarget(desiredTarget); - logStage('workspace-restored'); + const shouldRestoreTarget = getWorkspaceNavigationRevision() === navigationRevision; + const restorePromise = shouldRestoreTarget + ? restoreDesiredTarget(desiredTarget, navigationRevision) + : Promise.resolve(); + // Do not let low-priority Agent history recovery race the selected surface. + // The promise is observed without blocking the workbench startup barrier. + workspaceRestorationPromise = restorePromise.then(() => undefined, () => undefined); + admitStartupStage3(); + void runStartupStage3Task('pending-note-link-updates', resumePendingNoteLinkUpdates); + logStage('stage3-admitted'); + nativeReady = false; // The success path already released native startup. + if (!shouldRestoreTarget) { setWorkspaceRestoreStatus('restored'); - const outcome = await waitForInitialDocumentLoad(); - logStage(`initial-document-${outcome}`); - } catch (error) { - // Keep desiredTarget unchanged. Temporary permission, mount, or IPC - // failures can then be retried on the next launch. - setWorkspaceRestoreStatus('unavailable'); - throw error; + return; } + // Do not await hydration from the startup barrier. Each navigation operation + // owns its cancellation identity so later user navigation wins. + void restorePromise.then(() => { + logStage('workspace-restore-settled'); + setWorkspaceRestoreStatus('restored'); + }).catch((error) => { + const targetStillDesired = JSON.stringify(captureWorkspaceRestoreTarget()) === JSON.stringify(desiredTarget); + if (targetStillDesired) { + console.warn('Workspace content restoration unavailable:', error); + setWorkspaceRestoreStatus('unavailable'); + } else { + logStage('workspace-restore-superseded'); + setWorkspaceRestoreStatus('restored'); + } + }); + } catch (error) { + useWorkspaceStartupStore.setState({ + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + logStage(`task:${useWorkspaceStartupStore.getState().task}:failed`); + throw error; } finally { - if (nativeRuntime) { - // The first callback runs before paint; the second frame confirms that - // the restored content had a chance to appear before disk scans begin. - await new Promise((resolve) => window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => resolve()); - })); - logStage('first-workspace-paint'); + if (nativeRuntime && nativeReady) { + // A stage-2 retry screen can also paint and release independent work. + await waitForWorkbenchMounted(); + await afterShellPaint(); + const status = useWorkspaceStartupStore.getState().status; + logStage(status === 'ready' ? 'first-workspace-paint' : `foreground-${status}-paint`); await boot.notifyStartupInteractive().catch(() => undefined); } } } -async function restoreDesiredTarget(target: PersistedWorkspaceTarget | null): Promise { - if (!target) return; +function afterShellPaint(): Promise { + return new Promise((resolve) => window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => resolve()); + })); +} + +async function restoreDesiredTarget(target: PersistedWorkspaceTarget | null, requestId: number): Promise { + if (!target || getWorkspaceNavigationRevision() !== requestId) return; if (target.kind === 'external') { - await restoreExternalDocumentWorkspace(target); + await restoreExternalDocumentWorkspace(target, requestId); return; } if (target.kind === 'media') { - await restoreMediaWorkspace(target); + await restoreMediaWorkspace(target, requestId); return; } if (target.kind === 'collection') { - await restoreCollectionWorkspace(target); + await restoreCollectionWorkspace(target, requestId); + return; + } + if (target.kind === 'folder-collection') { + await restoreFolderCollectionWorkspace(target, requestId); return; } if (target.kind === 'table') { - await restoreTableWorkspace(target); + await restoreTableWorkspace(target, requestId); return; } if (target.kind === 'document-list') { - restoreDocumentListWorkspace(target); + await restoreLegacyFolderListWorkspace(target, requestId); return; } - await restoreAgentConversationWorkspace(); + await restoreAgentConversationWorkspace(requestId); } function isTauriRuntime(): boolean { diff --git a/app/flowix-web/app/main-window/main-window.tsx b/app/flowix-web/app/main-window/main-window.tsx index b373f731d..2f5dbea05 100644 --- a/app/flowix-web/app/main-window/main-window.tsx +++ b/app/flowix-web/app/main-window/main-window.tsx @@ -1,7 +1,8 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { useMainWindowBusinessController } from './use-main-window-business-controller'; import { useMainWindowSystemController } from './use-main-window-system-controller'; import { ensureFileDisplayTrackingStarted } from '@features/workspace/public/app-api'; +import { notifyWorkbenchMounted } from '@/lib/workbench-mount'; // Initialize before the first persisted surface is rendered. if (typeof window !== 'undefined') ensureFileDisplayTrackingStarted(); @@ -16,6 +17,12 @@ export function MainWindow() { return ( + ); } + +function WorkbenchMountedSignal() { + useEffect(() => notifyWorkbenchMounted(), []); + return null; +} diff --git a/app/flowix-web/app/main-window/use-main-window-business-controller.ts b/app/flowix-web/app/main-window/use-main-window-business-controller.ts index 88691ac38..00997745e 100644 --- a/app/flowix-web/app/main-window/use-main-window-business-controller.ts +++ b/app/flowix-web/app/main-window/use-main-window-business-controller.ts @@ -14,14 +14,17 @@ import { } from '@features/memo/public/workspace-api'; import { clearPluginWorkbenchTarget, - flushWorkspaceDocument, - openCollectionTarget, - openPluginWorkbench, reconcileDeletedNotebook, selectNotebook, useWorkspaceNavigationPhase, useWorkspaceTargetKind, } from '@features/workspace/public/main-window-api'; +import { + documentIdentityFromFile, + getWorkspaceDocumentState, + saveWorkspaceDocumentPath, +} from '@features/document/public/workspace-api'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { switchFolderCollectionType, type CollectionType } from '@features/collection/public/folder-api'; const logger = createLogger('main-window-business-controller'); @@ -104,7 +107,17 @@ export function useMainWindowBusinessController(): MainWindowBusinessController const target = notebookToDelete; if (!target) return; try { - if (selectedNotebook?.id === target.id) await flushWorkspaceDocument(); + if (selectedNotebook?.id === target.id) { + const active = getWorkspaceDocumentState().activeExternalSession; + if (active && !await saveWorkspaceDocumentPath( + documentIdentityFromFile(active.fileIdentity), + active.fileIdentity.path, + active.scopePath, + )) { + toast.error(t('notebook.delete.saveCurrentDocumentFailed')); + return; + } + } const deleted = await notebooksClient.delete(target.id, deleteLocalFiles); if (!deleted) { toast.error(t('shell.notebook.deleteFailed')); @@ -132,7 +145,7 @@ export function useMainWindowBusinessController(): MainWindowBusinessController return; } try { - await openPluginWorkbench(plugin); + await openWorkspaceSurface({ host: 'main-third', target: { kind: 'plugin-workbench', plugin } }); setMemoListVisible(true); } catch (error) { logger.warn('open plugin failed to clear document', { error }); @@ -145,10 +158,11 @@ export function useMainWindowBusinessController(): MainWindowBusinessController ) => { try { const collectionId = await switchFolderCollectionType(target.filePath, target.notebookPath, type); - await openCollectionTarget( - { notebookId: target.notebookId, collectionId, viewId: null }, - { destination: 'main-third', history: 'skip' }, - ); + await openWorkspaceSurface({ + host: 'main-third', + target: { kind: 'collection-reference', notebookId: target.notebookId, collectionId, viewId: null, collectionKind: type }, + options: { history: 'skip', refresh: true }, + }); } catch (error) { toast.error(error instanceof Error ? error.message : '切换视图类型失败'); } diff --git a/app/flowix-web/app/main-window/use-main-window-system-controller.ts b/app/flowix-web/app/main-window/use-main-window-system-controller.ts index a645f10e8..adbda88c6 100644 --- a/app/flowix-web/app/main-window/use-main-window-system-controller.ts +++ b/app/flowix-web/app/main-window/use-main-window-system-controller.ts @@ -1,11 +1,9 @@ -import { useCallback, useEffect, useState } from 'react'; -import { boot, consumeAppUpdateResult, dshIntegration, type DshDownloadProgress } from '@platform/tauri/client'; -import { subscribe } from '@platform/tauri/event-bus'; -import { useAppUpdatePreferences } from '@features/preferences/public/app-api'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { - useDshRuntimeInstaller, - type DshRuntimeInstallerState, -} from '@features/preferences/public/system-api'; + consumeAppUpdateResult, + type AppUpdateInstallResult, +} from '@platform/tauri/client'; +import { useAppUpdatePreferences } from '@features/preferences/public/app-api'; import { useAppUpdater, type AppUpdaterState, @@ -13,113 +11,70 @@ import { import { createLogger } from '@/lib/logger'; import { toast } from '@/lib/toast'; import { useI18n } from '@/lib/i18n'; +import { useWorkspaceMemoViewModel } from '@features/memo/public/workspace-api'; const logger = createLogger('main-window-system-controller'); -function isActiveDshDownload(progress: DshDownloadProgress | null): boolean { - return progress?.phase === 'checking' - || progress?.phase === 'downloading' - || progress?.phase === 'downloaded' - || progress?.phase === 'installing'; -} - export interface MainWindowSystemController { - dshDownload: DshDownloadProgress | null; - dshInstallPromptOpen: boolean; onboardingOpen: boolean; onboardingLoading: boolean; updater: AppUpdaterState; - dshInstaller: DshRuntimeInstallerState; - closeDshInstallPrompt(): void; - markDshIntroDisplayed(): void; - completeDshInstallPrompt(): void; + hasPendingAppUpdateResult: boolean; + presentPendingAppUpdateResult(): void; completeOnboarding(): Promise; } export function useMainWindowSystemController(): MainWindowSystemController { - const [dshDownload, setDshDownload] = useState(null); - const [dshInstallPromptOpen, setDshInstallPromptOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); const [onboardingLoading, setOnboardingLoading] = useState(true); + const onboardingDecisionMade = useRef(false); + const { notebooks, startupPhase } = useWorkspaceMemoViewModel(); + const [pendingAppUpdateResult, setPendingAppUpdateResult] = useState(null); const { t } = useI18n(); const updatePreferences = useAppUpdatePreferences(); const updater = useAppUpdater({ autoCheck: !updatePreferences.loading, enabled: updatePreferences.enabled, }); - const dshInstaller = useDshRuntimeInstaller(); useEffect(() => { void consumeAppUpdateResult().then((result) => { - if (!result) return; - if (result.status === 'failed') { - toast.error(`${t('appUpdates.previousInstallFailed')}${result.message ? `: ${result.message}` : ''}`); - } else { - toast.success(t('appUpdates.installSucceeded')); - } + if (result) setPendingAppUpdateResult(result); }).catch((error) => { logger.warn('read previous app update result failed', { error }); }); - }, [t]); - - useEffect(() => { - const applyProgress = (progress: DshDownloadProgress) => { - setDshDownload(isActiveDshDownload(progress) ? progress : null); - }; - const unsubscribe = subscribe('dsh-download-progress', applyProgress); - void dshIntegration.downloadStatus() - .then((progress) => { - if (progress) applyProgress(progress); - }) - .catch(() => { - // Preferences remains the detailed recovery surface. - }); - return unsubscribe; }, []); useEffect(() => { - let cancelled = false; - void boot.getFeatures().then((features) => { - if (cancelled) return; - if (!features.isOnboardingCompleted) { - setOnboardingOpen(true); - return; - } - // Notebook-only onboarding must not schedule the legacy Agent intro on a later launch. - }).catch(() => { - // Do not show onboarding when its durable state cannot be read. - }).finally(() => { - if (!cancelled) setOnboardingLoading(false); - }); - return () => { cancelled = true; }; - }, []); - - const markDshIntroDisplayed = useCallback(() => { - void boot.setIntroDisplayed().catch((error) => { - logger.warn('persist DSH intro display state failed', { error }); - }); - }, []); - - const closeDshInstallPrompt = useCallback(() => { - markDshIntroDisplayed(); - setDshInstallPromptOpen(false); - }, [markDshIntroDisplayed]); + if (startupPhase !== 'ready' || onboardingDecisionMade.current) return; + // Notebook identity is resolved in stage 2. Show first-run onboarding only + // when the notebook list is empty; do not wait for stage 3 or another read. + onboardingDecisionMade.current = true; + setOnboardingOpen(notebooks.length === 0); + setOnboardingLoading(false); + }, [notebooks.length, startupPhase]); const completeOnboarding = useCallback(async () => { - await boot.setOnboardingCompleted(); setOnboardingOpen(false); }, []); + const presentPendingAppUpdateResult = useCallback(() => { + if (!pendingAppUpdateResult) return; + const result = pendingAppUpdateResult; + setPendingAppUpdateResult(null); + if (result.status === 'failed') { + toast.error(`${t('appUpdates.previousInstallFailed')}${result.message ? `: ${result.message}` : ''}`); + } else { + toast.success(t('appUpdates.installSucceeded')); + } + }, [pendingAppUpdateResult, t]); + return { - dshDownload, - dshInstallPromptOpen, onboardingOpen, onboardingLoading, updater, - dshInstaller, - closeDshInstallPrompt, - markDshIntroDisplayed, - completeDshInstallPrompt: () => setDshInstallPromptOpen(false), + hasPendingAppUpdateResult: pendingAppUpdateResult !== null, + presentPendingAppUpdateResult, completeOnboarding, }; } diff --git a/app/flowix-web/app/memo-dispatcher-integration.test.ts b/app/flowix-web/app/memo-dispatcher-integration.test.ts index 6ef0775dd..09706ac0f 100644 --- a/app/flowix-web/app/memo-dispatcher-integration.test.ts +++ b/app/flowix-web/app/memo-dispatcher-integration.test.ts @@ -24,7 +24,7 @@ describe('memo dispatcher window isolation', () => { const releaseDocument = memoDispatcher.subscribe(documentHandler); const releaseRefresh = registerMemoDerivedRefreshHandler(refresh); const event: MemoEvent = { - kind: 'deleted', id: 'memo', path: '/memo.md', relativePath: 'memo.md', notebookId: 'notebook', + kind: 'deleted', path: '/memo.md', relativePath: 'memo.md', notebookId: 'notebook', source: 'external_tool', derivedOnly: true, derivedChanged: { tags: true, todos: true, agents: false }, }; @@ -141,8 +141,8 @@ describe('memo dispatcher window isolation', () => { bridge?.({ kind: 'created', memo: { - id: 'memo-external', filename: 'External.md', + relativePath: 'External.md', preview: '', tags: [], todos: [], diff --git a/app/flowix-web/app/use-agent-access-window-sync.ts b/app/flowix-web/app/use-agent-access-window-sync.ts deleted file mode 100644 index bf7a5d548..000000000 --- a/app/flowix-web/app/use-agent-access-window-sync.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from 'react'; -import { useAgentAccessStore } from '@features/agent/store/agent-access-store'; -import { listenToAgentAccessChanges } from '@platform/tauri/client'; - -/** Each Webview owns a store, including the preferences window. */ -export function useAgentAccessWindowSync() { - const loadInitial = useAgentAccessStore((state) => state.loadInitial); - useEffect(() => { - const reload = () => { void loadInitial(); }; - const unlisten = listenToAgentAccessChanges(reload, { onListenerReady: reload }); - reload(); - return unlisten; - }, [loadInitial]); -} diff --git a/app/flowix-web/app/use-agent-workspace-window-lifecycle.ts b/app/flowix-web/app/use-agent-workspace-window-lifecycle.ts new file mode 100644 index 000000000..6105adab4 --- /dev/null +++ b/app/flowix-web/app/use-agent-workspace-window-lifecycle.ts @@ -0,0 +1,87 @@ +import { ensureAgentPreferencesReady } from "@features/agent/store/agent-data-store"; +import { useAgentWorkspaceStore } from "@features/agent/store/agent-workspace-store"; +import { useEffect } from 'react'; +import { listenToAgentWorkspaceChanges, boot } from '@platform/tauri/client'; +import { runStartupStage3Task, waitForStartupStage3 } from '@/lib/startup-stage3'; + +interface AgentWorkspaceWindowLifecycleOptions { + /** Defer main-window Agent data until its shell paints; the preferences window loads on mount. */ + deferUntilStage3?: boolean; +} + +// Shared snapshot state outlives effect instances, just like the named task. +let startupSnapshotRequested = false; + +/** Initialize per-Webview Agent data and keep its workspace snapshot in sync. */ +export function useAgentWorkspaceWindowLifecycle( + { deferUntilStage3 = false }: AgentWorkspaceWindowLifecycleOptions = {}, +) { + const loadInitial = useAgentWorkspaceStore((state) => state.workspaceActions.loadInitial); + useEffect(() => { + let active = true; + let initialLoadStarted = false; + let initialLoadSettled = false; + let reloadQueued = false; + let reloadInFlight = false; + let unlisten: (() => void) | undefined; + const runQueuedReload = () => { + if (!active || !initialLoadSettled || !reloadQueued || reloadInFlight) return; + reloadQueued = false; + reloadInFlight = true; + void loadInitial().catch((error) => { + console.warn('Workspace reload failed:', error); + }).finally(() => { + reloadInFlight = false; + runQueuedReload(); + }); + }; + const reload = () => { + if (!active) return; + if (!initialLoadSettled || reloadInFlight) { + reloadQueued = true; + return; + } + reloadQueued = true; + runQueuedReload(); + }; + const initialize = async () => { + if ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) { + await boot.waitForStartupReady(); + } + if (!active) return; + unlisten = listenToAgentWorkspaceChanges(reload, { + onListenerReady: () => { + // The initial snapshot covers first registration when it has not + // started yet; reconnects after that need a fresh snapshot. + if (initialLoadStarted || (deferUntilStage3 && startupSnapshotRequested)) reload(); + }, + }); + + // Stage 3 owns scheduling; each store loads its own state independently. + const markInitialLoadSettled = () => { + if (!active) return; + initialLoadSettled = true; + runQueuedReload(); + }; + const initializeAgentPreferences = () => ensureAgentPreferencesReady().catch((error) => { + console.warn('Pi mode initialization failed:', error); + }); + + const workspaceTask = deferUntilStage3 + ? runStartupStage3Task('agent-workspace-snapshot-load', async () => { await loadInitial(); }) + : loadInitial(); + const preferencesTask = deferUntilStage3 + ? runStartupStage3Task('agent-preferences-initialize', initializeAgentPreferences) + : initializeAgentPreferences(); + void workspaceTask.finally(markInitialLoadSettled); + + if (deferUntilStage3) await waitForStartupStage3(); + if (!active) return; + initialLoadStarted = true; + if (deferUntilStage3) startupSnapshotRequested = true; + await Promise.all([workspaceTask, preferencesTask]); + }; + void initialize().catch((error) => console.warn('Workspace initialization deferred:', error)); + return () => { active = false; unlisten?.(); }; + }, [deferUntilStage3, loadInitial]); +} diff --git a/app/flowix-web/app/user-config-sync.ts b/app/flowix-web/app/user-config-sync.ts index 2c357000d..e8ff15385 100644 --- a/app/flowix-web/app/user-config-sync.ts +++ b/app/flowix-web/app/user-config-sync.ts @@ -25,7 +25,12 @@ export function syncUserConfigChange( void actions.reloadPreferences(); return; } - if (kind === 'pi_config') return; + if (kind === 'pi_config') { + void import('@features/agent/services/agent-catalog-service') + .then(({ refreshAgentModelCatalog }) => refreshAgentModelCatalog('pi')) + .catch((error) => console.warn('Failed to refresh the Pi model catalog after its configuration changed', error)); + return; + } // Runtime freshness currently has one timestamp for the complete status // snapshot. Refresh the complete snapshot so unrelated agent statuses are diff --git a/app/flowix-web/assets/skill-library.svg b/app/flowix-web/assets/skill-library.svg new file mode 100644 index 000000000..2e876bc76 --- /dev/null +++ b/app/flowix-web/assets/skill-library.svg @@ -0,0 +1,7 @@ + diff --git a/app/flowix-web/entrypoints/desktop.tsx b/app/flowix-web/entrypoints/desktop.tsx index 541a2e4a0..d1349e11f 100644 --- a/app/flowix-web/entrypoints/desktop.tsx +++ b/app/flowix-web/entrypoints/desktop.tsx @@ -1,6 +1,7 @@ import { createRoot } from "react-dom/client"; import App from "@app/app"; +import { installDesktopShutdown } from "@app/desktop-shutdown"; import "@/styles/index.css"; import "sonner/dist/styles.css"; import { initTauriClient } from "@platform/tauri/client"; @@ -17,4 +18,6 @@ try { console.error("[desktop entry] Failed to initialize Tauri:", error); } +installDesktopShutdown(); + createRoot(document.getElementById("root")!).render(); diff --git a/app/flowix-web/features/agent/agent-setup.ts b/app/flowix-web/features/agent/agent-setup.ts index 23d54664f..e4fa167ce 100644 --- a/app/flowix-web/features/agent/agent-setup.ts +++ b/app/flowix-web/features/agent/agent-setup.ts @@ -4,10 +4,8 @@ import { isAgentTypeComingSoon } from '@/lib/agent-types'; const AGENT_SETUP_URLS: Partial> = { codex: 'https://developers.openai.com/codex/cli', - claude: 'https://code.claude.com/docs/en/setup', - gemini: 'https://google-gemini.github.io/gemini-cli/docs/cli/headless.html', + claudecode: 'https://code.claude.com/docs/en/setup', hermes: 'https://hermes-agent.nousresearch.com/docs/user-guide/cli', - openclaw: 'https://docs.openclaw.ai/cli', opencode: 'https://opencode.ai/docs/cli/', }; diff --git a/app/flowix-web/features/agent/components/agent-background-terminals.tsx b/app/flowix-web/features/agent/components/agent-background-terminals.tsx index 46e6b95d0..7c4bebde8 100644 --- a/app/flowix-web/features/agent/components/agent-background-terminals.tsx +++ b/app/flowix-web/features/agent/components/agent-background-terminals.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { ArrowBendDownRightIcon, TrashSimpleIcon } from '@phosphor-icons/react'; +import { ArrowBendDownRightIcon } from '@phosphor-icons/react'; import { useI18n } from '@/lib/i18n'; import { agentClient } from '@features/agent/store/agent-client'; @@ -36,7 +36,7 @@ function readTerminals(value: unknown): BackgroundTerminal[] { }); } -export function AgentBackgroundTerminals({ threadId, agentType, enabled, queuedMessages = [], onPromoteFollowUp, onDeleteQueuedMessage }: { threadId: string | null; agentType: 'codex' | 'deepseek-harness'; enabled: boolean; queuedMessages?: readonly QueuedAgentMessage[]; onPromoteFollowUp?: (messageId: string) => void; onDeleteQueuedMessage?: (messageId: string) => void }) { +export function AgentBackgroundTerminals({ threadId, agentType, enabled, queuedMessages = [], onPromoteFollowUp, onDeleteQueuedMessage }: { threadId: string | null; agentType: 'codex' | 'dsh'; enabled: boolean; queuedMessages?: readonly QueuedAgentMessage[]; onPromoteFollowUp?: (messageId: string) => void; onDeleteQueuedMessage?: (messageId: string) => void }) { const { t } = useI18n(); const [terminals, setTerminals] = useState([]); const [expanded, setExpanded] = useState(false); @@ -145,19 +145,19 @@ function QueuedMessages({ messages, label, onPromoteFollowUp, onDeleteQueuedMess {message.deliveryStatus === 'starting' && ( {t('agent.queue.sendingSoon')} )} - {message.deliveryMode === 'follow_up' && message.deliveryStatus !== 'starting' && onPromoteFollowUp && ( - - )} {message.deliveryMode === 'steer' && onDeleteQueuedMessage && ( {message.deliveryStatus === 'cleanup_failed' ? t('agent.queue.cleanupFailed') : t('agent.queue.sendingSoon')} )} {onDeleteQueuedMessage && message.canDelete !== false && } + {message.deliveryMode === 'follow_up' && message.deliveryStatus !== 'starting' && onPromoteFollowUp && ( + + )} )}
diff --git a/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts b/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts index e0f1c0046..e5413acb1 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts +++ b/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts @@ -1,3 +1,4 @@ +import { ensureAgentWorkspaceReady } from "@features/agent/store/agent-workspace-store"; import type { AgentTypeKey, RuntimeConfig } from "@/types/agent"; import { useAgentSessionStore } from "@features/agent/store/agent-session-store"; import { buildInitialInstanceRuntimeConfig } from "@features/agent/store/initial-runtime-config"; @@ -64,6 +65,7 @@ export async function ensureAgentConversationDetailThread(input: { runtimeConfig: instance.runtimeConfig ?? buildInitialInstanceRuntimeConfig(ensured.typeKey), }); + await ensureAgentWorkspaceReady(); const runtimeConfig = ensureConversationWorkspaceSnapshot(input.instanceId); markConversationWorkspaceStarted(input.instanceId); return { threadId: ensured.threadId, title: ensured.title, runtimeConfig }; diff --git a/app/flowix-web/features/agent/components/agent-conversation-detail.tsx b/app/flowix-web/features/agent/components/agent-conversation-detail.tsx index 49fe42a1f..37cf94dbd 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-detail.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-detail.tsx @@ -2,13 +2,16 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type UIEventHandler, type WheelEventHandler } from 'react'; +import type { SurfacePresentationUpdate } from '@features/workspace/model/surface-contract'; import { DEFAULT_AGENT_TYPE_KEY } from '@/lib/agent-types'; import type { AgentTypeKey } from '@/types/agent'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/lib/toast'; import { createLogger } from '@/lib/logger'; import { agent } from '@platform/tauri/client/agent'; +import { useAgentConversationSurface } from './use-agent-conversation-surface'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import { createAndOpenAgentConversation } from '@features/agent/use-cases/new-conversation'; import { canActOnQueuedMessage } from '@features/agent/store/follow-up-queue-slice'; import type { AgentConversationInstance } from '@features/agent/store/agent-conversation-types'; import { useNoteStore } from '@features/memo/store/note-store'; @@ -23,7 +26,8 @@ import { ComposerSubmissionController, getComposerRuntimeState, ComposerDraftController, - ComposerImageController, + ComposerAttachmentController, + ComposerAttachmentDocument, ComposerAddMenuController, getCurrentNotebookComposerFolders, createAgentComposerDom, @@ -41,7 +45,7 @@ import { AgentBackgroundTerminals } from '@features/agent/components/agent-backg import { CodexApprovalQueue } from '@features/agent/components/codex-approval-queue'; import { buildInitialInstanceRuntimeConfig } from '@features/agent/store/initial-runtime-config'; import { defaultThreadTitle } from '@features/agent/store/thread-titles'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { OverlayScrollbar } from '@shared/ui/overlay-scrollbar'; import { agentFileScopePathForRuntime, @@ -61,6 +65,13 @@ import { Button } from '@shared/ui/button'; import { createRunId } from '@features/agent/events/agent-event-mapper'; import { completedRunUserMessageId } from '@features/agent/events/message-identity'; import { createAgentMessageAttachments, prepareUserMessage } from '@features/agent/store/user-message'; +import { isAgentThreadCardImageAttachment } from '@features/agent/thread-card/composer/composer-attachment-types'; +import { + getAgentMessageTypographyCssVars, + useAgentMessageFontStore, +} from '@features/agent/store/agent-message-font-store'; +import { ensureDownloadedFontRegistered } from '@features/preferences/public/font-api'; +import { getTypographyFontId } from '@/lib/typography-font-mode'; const BOTTOM_FOLLOW_THRESHOLD_PX = 96; const TOP_HISTORY_LOAD_THRESHOLD_PX = 48; @@ -68,6 +79,12 @@ const SCROLL_DELTA_EPSILON_PX = 0.5; const INPUT_DRAFT_MAX_CHARS = 500; const EMPTY_MESSAGES: ThreadState['messages'] = []; const EMPTY_PENDING_CODEX_MESSAGES: readonly [] = []; + +function isMissingHistorySession(error: string | null | undefined): boolean { + if (!error) return false; + return Boolean(error.match(/session\s+["'][^"']+["']\s+not found/i) + ?? error.match(/session\s+not found:\s*["']?[\w.-]+["']?/i)); +} const DETAIL_DRAFT_KEY_PREFIX = 'flowix:agent-conversation-draft:'; const logger = createLogger('agent-conversation-detail'); @@ -115,48 +132,26 @@ function persistDetailDraft(instanceId: string, draft: string | null): void { */ export function AgentConversationDetail({ instanceId, + reportPresentation, }: { instanceId: string; + reportPresentation?: (update: SurfacePresentationUpdate) => void; }) { const { t } = useI18n(); - const instance = useAgentSessionStore((state) => state.getInstance(instanceId)); - const [loadState, setLoadState] = useState<{ - instanceId: string; - status: 'loading' | 'not-found' | 'error'; - } | null>(null); - const [retryCount, setRetryCount] = useState(0); - useEffect(() => { - if (instance) return; - - let cancelled = false; - setLoadState({ instanceId, status: 'loading' }); - void useAgentSessionStore.getState().hydrateInstance(instanceId) - .then((restored) => { - if (!cancelled && !restored) setLoadState({ instanceId, status: 'not-found' }); - }) - .catch(() => { - if (!cancelled) setLoadState({ instanceId, status: 'error' }); - }); - return () => { - cancelled = true; - }; - }, [instance, instanceId, retryCount]); + const { instance, status, retry } = useAgentConversationSurface(instanceId, reportPresentation); if (instance) { return ; } - const status = loadState?.instanceId === instanceId ? loadState.status : 'loading'; + if (reportPresentation) return null; return ( {t(status === 'error' ? 'status.agent.loadConversationsFailed' : 'status.agent.conversationNotFound')} - @@ -174,6 +169,17 @@ function AgentConversationDetailContent({ instance: AgentConversationInstance; }) { const { language, t } = useI18n(); + const selectedNotebookId = useNoteStore((state) => state.selectedNotebook?.id ?? null); + const messageFontMode = useAgentMessageFontStore((state) => state.fontMode); + const messageSizeMode = useAgentMessageFontStore((state) => state.sizeMode); + useEffect(() => { + if (messageFontMode === 'standard') return; + const fontId = getTypographyFontId(messageFontMode); + void ensureDownloadedFontRegistered(fontId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + toast.error(t('preferences.format.fontDownloadFailed', { message })); + }); + }, [messageFontMode, t]); const threadId = instance.threadId; const renderThreadId = threadId; const projection = useAgentSessionStore((state) => ( @@ -202,12 +208,14 @@ function AgentConversationDetailContent({ ); const initialHistoryStatus = projection?.pagination.initialStatus ?? 'idle'; const initialHistoryError = projection?.pagination.initialError; + const missingSession = isMissingHistorySession(initialHistoryError); const isInitialHistoryLoading = shouldShowInitialHistorySkeleton( threadId, messages.length, initialHistoryStatus, ); const showHistoryError = initialHistoryStatus === 'error' && messages.length === 0; + const showHistoryWarning = initialHistoryStatus === 'error' && messages.length > 0; const domRef = useRef(null); const bodyRef = useRef(null); const composerMountRef = useRef(null); @@ -215,7 +223,7 @@ function AgentConversationDetailContent({ const inputRef = useRef(null); const messagesControllerRef = useRef(null); const composerControllerRef = useRef(null); - const composerImagesControllerRef = useRef(null); + const composerAttachmentsControllerRef = useRef(null); const externalSettingsRef = useRef(null); const addMenuRef = useRef(null); const notebookAgentSettingsDialogRef = useRef(null); @@ -242,9 +250,9 @@ function AgentConversationDetailContent({ const currentInstance = instanceRef.current; const sourceThreadId = threadIdRef.current; const agentType = currentInstance?.agentType; - if (!currentInstance || !sourceThreadId || (agentType !== 'codex' && agentType !== 'deepseek-harness' && agentType !== 'pi')) return; + if (!currentInstance || !sourceThreadId || (agentType !== 'codex' && agentType !== 'dsh' && agentType !== 'pi')) return; if (agentType === 'codex' && !message.codexTurnId) return; - if (agentType === 'deepseek-harness' && message.sourceSequence === undefined) return; + if (agentType === 'dsh' && message.sourceSequence === undefined) return; if (agentType === 'pi' && !message.messageId) return; if (destroyedRef.current) return; @@ -252,7 +260,7 @@ function AgentConversationDetailContent({ try { const result = agentType === 'codex' ? await agent.forkCodexThread(sourceThreadId, message.codexTurnId!) - : agentType === 'deepseek-harness' + : agentType === 'dsh' ? await agent.forkDeepSeekHarnessThread(sourceThreadId, message.sourceSequence!) : await agent.forkPiThread(sourceThreadId, message.messageId!); const fork = useAgentSessionStore.getState().createInstance({ @@ -265,7 +273,7 @@ function AgentConversationDetailContent({ }, source: { kind: 'dedicated', notebookId: currentInstance.source.notebookId ?? null }, }); - await selectAndOpenAgentConversation(fork.instanceId); + await openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: fork.instanceId } }); } catch (error) { logger.error(`Failed to fork ${agentType} conversation`, { error }); toast.error(error instanceof Error ? error.message : String(error)); @@ -310,7 +318,7 @@ function AgentConversationDetailContent({ }, [instanceId]); const ensureDshCommandConversation = useCallback(async (command: string) => { - if (typeKeyRef.current !== 'deepseek-harness') { + if (typeKeyRef.current !== 'dsh') { throw new Error('This slash command is only available for DSH'); } const currentInstance = instanceRef.current; @@ -322,7 +330,7 @@ function AgentConversationDetailContent({ } const ensured = await ensureAgentConversationDetailThread({ instanceId: currentInstance.instanceId, - typeKey: 'deepseek-harness', + typeKey: 'dsh', prompt: command, runtimeHandleId: runtimeHandleRef.current, }); @@ -375,7 +383,7 @@ function AgentConversationDetailContent({ const submit = useCallback(async () => { const composer = composerControllerRef.current; - const images = composerImagesControllerRef.current; + const images = composerAttachmentsControllerRef.current; const currentInstance = instanceRef.current; if (!composer || !images || !currentInstance) return; const isDisposed = () => destroyedRef.current || composerControllerRef.current !== composer; @@ -397,7 +405,9 @@ function AgentConversationDetailContent({ content: snapshot.prompt, agentType: currentInstance.agentType, isFirstMessage: true, - attachments: createAgentMessageAttachments(snapshot.images.map((image) => image.path)), + attachments: createAgentMessageAttachments( + snapshot.attachments.filter(isAgentThreadCardImageAttachment), + ), }); userMessage.id = completedRunUserMessageId(currentInstance.agentType, runId); userMessage.renderKey = userMessage.id; @@ -465,7 +475,6 @@ function AgentConversationDetailContent({ composerMount.append(composerParts.composer); const { composer, - composerImages, composerActions, composerRoleIcon: composerRoleButton, input, @@ -516,10 +525,9 @@ function AgentConversationDetailContent({ persistDetailDraft(instanceId, draft); }, }); - const composerImagesController = new ComposerImageController({ + const composerAttachmentsController = new ComposerAttachmentController({ input, - container: composerImages, - initialImages: [], + initialAttachments: [], onChange: () => undefined, onStateChange: () => composerControllerRef.current?.setSendButtonState(), onError: (message) => toast.error(message), @@ -574,7 +582,7 @@ function AgentConversationDetailContent({ t: (key) => tRef.current(key), createThreadCacheSkeleton: () => createThreadCacheSkeleton(tRef.current('editor.threadCard.loadingThreadCache')), createExternalAgentEmptySettings: () => - externalSettings.createEmptySettings({ showControls: false }), + externalSettings.createEmptySettings({ presentation: "expanded" }), onForkMessage: forkFromMessage, }, composerOptions: { @@ -584,11 +592,20 @@ function AgentConversationDetailContent({ getUserHistoryMessages: () => getAgentThreadCardUserHistoryMessagesFromMessages(messagesRef.current), getSendLabel: (label) => tRef.current(`editor.threadCard.${label}`), getRuntimeState: composerRuntimeState, - getHasAttachments: () => composerImagesController.hasImages, - getHasPendingAttachments: () => composerImagesController.hasPending, + getHasAttachments: () => composerAttachmentsController.hasAttachments, + getHasPendingAttachments: () => composerAttachmentsController.hasPending, agentType: typeKeyRef.current, + getSkillsScopeId: () => { + const current = instanceRef.current; + const runtime = current?.runtimeConfig; + return runtime?.notebookId + ?? runtime?.workspaceSnapshot?.notebookId + ?? current?.source.notebookId + ?? runtime?.workspaceSnapshot?.notebookPath + ?? runtime?.cwd; + }, listDshSkills: async () => { - if (typeKeyRef.current !== 'deepseek-harness') return []; + if (typeKeyRef.current !== 'dsh') return []; const ensured = await ensureDshCommandConversation('/skill'); return listDshSkills({ threadId: ensured.threadId, @@ -632,6 +649,9 @@ function AgentConversationDetailContent({ }); messageController = surface.messages; const composerController = surface.composer; + composerAttachmentsController.connectDocument( + new ComposerAttachmentDocument(composerController.editorInstance), + ); const notePicker = new NotePickerController({ trigger: composerRoleButton, popover: rolePopover, @@ -647,7 +667,7 @@ function AgentConversationDetailContent({ popover: addPopover, notePopover: rolePopover, notePicker, - images: composerImagesController, + attachments: composerAttachmentsController, t: (key) => tRef.current(key), isDestroyed: () => destroyedRef.current, openNotebookAgentSettings: () => { @@ -672,7 +692,7 @@ function AgentConversationDetailContent({ messagesControllerRef.current = messageController; composerControllerRef.current = composerController; surfaceRef.current = surface; - composerImagesControllerRef.current = composerImagesController; + composerAttachmentsControllerRef.current = composerAttachmentsController; addMenuRef.current = addMenu; composerController.updateMultiLineState(); // Paint the selected thread's initial state in the same layout pass. The @@ -686,7 +706,7 @@ function AgentConversationDetailContent({ body.removeEventListener('click', handleMessageLinkClick); composerController.flushPendingDraft(); surface.dispose(); - composerImagesController.dispose(); + composerAttachmentsController.dispose(); notePicker.dispose(); addMenu.dispose(); externalSettings.dispose(); @@ -698,7 +718,7 @@ function AgentConversationDetailContent({ messagesControllerRef.current = null; composerControllerRef.current = null; surfaceRef.current = null; - composerImagesControllerRef.current = null; + composerAttachmentsControllerRef.current = null; addMenuRef.current = null; pendingFirstMessageRef.current = null; }; @@ -742,7 +762,10 @@ function AgentConversationDetailContent({ ); return ( -
+
{document.documentElement.dataset.platform === 'mac' ? ( @@ -781,34 +804,74 @@ function AgentConversationDetailContent({ tone="agent" message={( - {t('status.agent.loadHistoryFailed')} - {initialHistoryError && ( - - {initialHistoryError} - + {missingSession ? t('status.agent.historySessionNotFound') : t('status.agent.loadHistoryFailed')} + {missingSession ? ( + + ) : ( + )} - )} />
)} + {showHistoryWarning && ( +
+ {missingSession ? t('status.agent.historySessionNotFound') : t('status.agent.historyRefreshFailed')} + {missingSession ? ( + + ) : ( + + )} +
+ )}
{instance.agentType === 'codex' && } ({ ...message, actionsDisabled: !canActOnQueuedMessage( diff --git a/app/flowix-web/features/agent/components/agent-conversation-identity.tsx b/app/flowix-web/features/agent/components/agent-conversation-identity.tsx new file mode 100644 index 000000000..f3d6a0e49 --- /dev/null +++ b/app/flowix-web/features/agent/components/agent-conversation-identity.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { AgentTypeKey } from '@/types/agent'; +import { useI18n } from '@/lib/i18n'; +import { useTitleEditCaret } from '@shared/hooks/use-title-edit-caret'; +import { getAgentType } from '@/lib/agent-types'; +import { useAgentDataStore } from '@features/agent/store/agent-data-store'; +import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import { AgentIcon } from '@features/agent/components/agent-icon'; +import { BadgeHoverCard } from '@features/agent/thread-card/badge-hover-card'; +import { computeAgentThreadCardBadgeData } from '@features/agent/thread-card/runtime/run-status-presenter'; +import { createRuntimeInfoRequester } from '@features/agent/thread-card/runtime/runtime-info-requester'; +import { getResolvedExternalSessionId } from '@features/agent/services/external-agent-runtime-service'; +import { getAgentConversationRuntimeCwd } from '@features/agent/conversation-presentation'; + +export interface AgentConversationIdentityProps { + instanceId: string | null; + title: string; + typeKey: AgentTypeKey; + threadId?: string | null; + sessionId?: string | null; + cwd?: string; + renameRequest?: number; + className?: string; +} + +/** Shared Agent icon, runtime badge, conversation title, and title editing. */ +export function AgentConversationIdentity({ + instanceId, + title: fallbackTitle, + typeKey: fallbackTypeKey, + threadId: fallbackThreadId, + sessionId: fallbackSessionId, + cwd: fallbackCwd, + renameRequest = 0, + className = '', +}: AgentConversationIdentityProps) { + const { t } = useI18n(); + const instance = useAgentSessionStore((state) => + instanceId ? state.getInstance(instanceId) : null, + ); + const threadId = instance?.threadId ?? fallbackThreadId ?? null; + const projection = useAgentSessionStore((state) => + threadId ? state.threadProjections[threadId] : undefined, + ); + const codexModel = useAgentDataStore((state) => state.agents.codex.user.model?.id ?? 'inherit'); + const piCodeMode = useAgentDataStore((state) => state.agents.pi.user.mode === 'code'); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(''); + const lastRenameRequest = useRef(renameRequest); + const { inputRef: titleInputRef, captureCaret } = useTitleEditCaret(isEditingTitle); + + const typeKey = instance?.agentType ?? fallbackTypeKey; + const title = instance?.title?.trim() || fallbackTitle || t('common.untitled'); + const productThreadId = threadId ?? ''; + const sessionId = instance?.providerSessionId ?? fallbackSessionId ?? ( + productThreadId ? getResolvedExternalSessionId(productThreadId) : null + ); + const cwd = getAgentConversationRuntimeCwd(instance) ?? fallbackCwd; + const agent = getAgentType(typeKey); + + const badgeData = useMemo(() => computeAgentThreadCardBadgeData({ + threadState: projection ? { + lastRun: projection.runs.lastRun, + activeRunId: projection.runs.activeRunId, + runs: projection.runs.runs, + } : undefined, + codexModel, + typeKey, + }), [codexModel, projection, typeKey]); + + useEffect(() => { + if (lastRenameRequest.current === renameRequest) return; + lastRenameRequest.current = renameRequest; + setTitleDraft(title); + setIsEditingTitle(true); + }, [renameRequest, title]); + + const commitTitle = () => { + const nextTitle = titleDraft.trim(); + if (nextTitle && nextTitle !== title && instanceId) { + void useAgentSessionStore.getState().renameAgentConversation({ + instanceId, + threadId, + title: nextTitle, + typeKey, + }); + } + setIsEditingTitle(false); + }; + + return ( +
+ + { + const current = instanceId + ? useAgentSessionStore.getState().getInstance(instanceId) + : null; + return current?.threadId ?? fallbackThreadId; + }, + () => { + const current = instanceId + ? useAgentSessionStore.getState().getInstance(instanceId) + : null; + const currentThreadId = current?.threadId ?? fallbackThreadId; + return current?.providerSessionId ?? fallbackSessionId ?? ( + currentThreadId ? getResolvedExternalSessionId(currentThreadId) : null + ); + }, + )} + agentType={typeKey} + piMode={typeKey === 'pi' ? (piCodeMode ? 'code' : 'writing') : undefined} + cwd={cwd} + /> + + + {isEditingTitle ? ( +
+ setTitleDraft(event.target.value)} + onBlur={commitTitle} + onKeyDown={(event) => { + if (event.key === 'Enter' && (event.nativeEvent.isComposing || event.keyCode === 229)) return; + if (event.key === 'Enter') { + event.preventDefault(); + commitTitle(); + } + if (event.key === 'Escape') setIsEditingTitle(false); + }} + /> +
+ ) : ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + captureCaret(event.currentTarget, event.clientX, event.clientY); + setTitleDraft(title); + setIsEditingTitle(true); + }} + > + {title} + + )} +
+ ); +} diff --git a/app/flowix-web/features/agent/components/agent-conversation-list.tsx b/app/flowix-web/features/agent/components/agent-conversation-list.tsx index 50804c2c5..18b8bfcbf 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-list.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-list.tsx @@ -11,24 +11,21 @@ import { import { MessageCirclePlus, MoreHorizontal } from 'lucide-react'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; import type { AgentConversationInstance } from '@features/agent/store/agent-conversation-types'; -import { createAndOpenAgentConversation } from '@features/agent/public/shell-api'; +import { + createAndOpenAgentConversation, + requestNewConversationAgentSelection, +} from '@features/agent/use-cases/new-conversation'; import { useWorkspaceRestoreStore } from '@features/workspace/store/workspace-restore-store'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; -import { openBrowserColumnAgentConversation } from '@features/workspace/use-cases/browser-column-navigation'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { useNoteStore } from '@features/memo/store/note-store'; import { isAgentConversationRunning } from '@features/agent/store/conversation-run-index'; -import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; +import { useNewConversationAgentSelection } from '@features/agent/hooks/use-new-conversation-agent-selection'; import { AGENT_TYPES, getAgentType, isAgentTypeSelectable, - isAlwaysVisibleNewConversationAgent, } from '@/lib/agent-types'; -import type { AgentTypeKey } from '@/types/agent'; -import { - isAgentRuntimeInstalledState, - normalizeAgentRuntimeStatus, -} from '@features/agent/runtime/agent-runtime-status'; +import type { AgentType, AgentTypeKey } from '@/types/agent'; import { formatTimeAgo } from '@/lib/format-time-ago'; import { createLogger } from '@/lib/logger'; import { toast } from '@/lib/toast'; @@ -41,8 +38,8 @@ import { import { useI18n } from '@/lib/i18n'; import { OverlayScrollbar } from '@shared/ui/overlay-scrollbar'; import { - ListSurfaceInlineLoadingState, ListSurfaceLoadingState, + ListSurfaceSpinner, ListSurfaceViewport, } from '@shared/ui/list-surface'; import { @@ -125,9 +122,6 @@ export function AgentConversationList({ isActive = true }: AgentConversationList // Rows can therefore read their running state in O(1) without rebuilding an // index by scanning every loaded conversation. const conversationRunIndex = useAgentSessionStore((state) => state.threadRunSignatures); - const agentRuntimeStatusByType = useAgentRuntimeStore((state) => state.statusByType); - const agentRuntimeIsChecking = useAgentRuntimeStore((state) => state.isChecking); - const refreshAgentRuntimeIfStale = useAgentRuntimeStore((state) => state.refreshIfStale); const currentNotebookId = useNoteStore((state) => state.selectedNotebook?.id ?? null); const selectedInstanceId = useWorkspaceRestoreStore( (state) => state.agentConversation.selectedInstanceId, @@ -181,17 +175,6 @@ export function AgentConversationList({ isActive = true }: AgentConversationList setShowScrollTopHint(false); }, [isActive]); - useEffect(() => { - const handleOpenCreateMenu = () => setNewConversationMenuOpen(true); - window.addEventListener('flowix:open-agent-create-menu', handleOpenCreateMenu); - return () => window.removeEventListener('flowix:open-agent-create-menu', handleOpenCreateMenu); - }, []); - - useEffect(() => { - if (!newConversationMenuOpen) return; - void refreshAgentRuntimeIfStale(); - }, [newConversationMenuOpen, refreshAgentRuntimeIfStale]); - const toggleFavorite = useCallback((instanceId: string) => { setFavoriteIds(toggleFavoriteConversation(instanceId)); setOpenMenuId(null); @@ -299,8 +282,8 @@ export function AgentConversationList({ isActive = true }: AgentConversationList const counts = new Map(); if (Object.keys(agentTypeCounts).length > 0) { for (const type of AGENT_TYPES) { - const count = agentTypeCounts[type.key]; - if (count && isAgentTypeSelectable(type.key)) counts.set(type.key, count); + const count = agentTypeCounts[type.id]; + if (count && isAgentTypeSelectable(type.id)) counts.set(type.id, count); } } else { for (const instance of scopedConversations) { @@ -309,14 +292,14 @@ export function AgentConversationList({ isActive = true }: AgentConversationList } } return AGENT_TYPES - .filter((type) => counts.has(type.key)) - .map((type) => ({ type, count: counts.get(type.key) ?? 0 })); + .filter((type) => counts.has(type.id)) + .map((type) => ({ type, count: counts.get(type.id) ?? 0 })); }, [agentTypeCounts, scopedConversations]); // 对话可能在筛选期间变成空集 (例如全部删除) ── 若当前筛选的类型已不再激活, // 自动回退到"全部",避免列表卡在空状态。 const effectiveFilter: AgentTypeKey | null = - filterType && activeAgentTypes.some((entry) => entry.type.key === filterType) + filterType && activeAgentTypes.some((entry) => entry.type.id === filterType) ? filterType : null; @@ -412,20 +395,16 @@ export function AgentConversationList({ isActive = true }: AgentConversationList } }; - const displayName = (type: (typeof AGENT_TYPES)[number]): string => + const displayName = (type: AgentType): string => type.nameKey ? t(type.nameKey as Parameters[0]) : type.name; - const newConversationAgentTypes = useMemo( - () => AGENT_TYPES.filter((type) => { - if (!isAgentTypeSelectable(type.key)) return false; - if (isAlwaysVisibleNewConversationAgent(type.key)) return true; - return isAgentRuntimeInstalledState(normalizeAgentRuntimeStatus( - agentRuntimeStatusByType[type.key], - agentRuntimeIsChecking, - )); - }), - [agentRuntimeIsChecking, agentRuntimeStatusByType], - ); + const newConversationSelection = useNewConversationAgentSelection(); + + useEffect(() => { + if (newConversationMenuOpen && newConversationSelection.kind !== 'choose') { + setNewConversationMenuOpen(false); + } + }, [newConversationMenuOpen, newConversationSelection]); const prepareConversation = useCallback((instance: AgentConversationInstance) => { // The durable list snapshot may arrive before the Zustand session store @@ -456,19 +435,18 @@ export function AgentConversationList({ isActive = true }: AgentConversationList prepareConversation(instance); - await selectAndOpenAgentConversation(instance.instanceId); + await openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: instance.instanceId } }); }, [markThreadRead, prepareConversation]); const openConversationInBrowserColumn = useCallback((instance: AgentConversationInstance) => { prepareConversation(instance); - void openBrowserColumnAgentConversation(instance.instanceId).catch((error) => { + void openWorkspaceSurface({ host: 'browser-column', target: { kind: 'agent-conversation', instanceId: instance.instanceId } }).catch((error) => { logger.error('Failed to open conversation in browser column', { error }); toast.error(error instanceof Error ? error.message : String(error)); }); }, [prepareConversation]); - // 独立对话没有文档来源,但归属当前选中的 - // notebook。notebook 未选中时不可新建 (cwd 无法解析到笔记本路径)。 + // 独立对话没有文档来源,但归属当前选中的 notebook。 const createConversation = useCallback( (typeKey: AgentTypeKey) => { if (!currentNotebookId) return; @@ -477,6 +455,21 @@ export function AgentConversationList({ isActive = true }: AgentConversationList [currentNotebookId], ); + const requestNewConversation = useCallback(() => { + const selection = requestNewConversationAgentSelection(); + if (selection.kind === 'create') { + createConversation(selection.agentType); + setNewConversationMenuOpen(false); + return; + } + setNewConversationMenuOpen(selection.kind === 'choose'); + }, [createConversation]); + + useEffect(() => { + window.addEventListener('flowix:open-agent-create-menu', requestNewConversation); + return () => window.removeEventListener('flowix:open-agent-create-menu', requestNewConversation); + }, [requestNewConversation]); + return (
{/* 标题行 ── 与 MemoList / FolderFileTree 共用同一套中间列头部结构: @@ -493,19 +486,19 @@ export function AgentConversationList({ isActive = true }: AgentConversationList aria-label={t('document.agent.filterByAgent')} > {activeAgentTypes.map(({ type, count }) => { - const active = effectiveFilter === type.key; + const active = effectiveFilter === type.id; const label = t('document.agent.filterByAgentCount', { name: displayName(type), count, }); return ( ); })} @@ -523,12 +516,15 @@ export function AgentConversationList({ isActive = true }: AgentConversationList
{ + if (open) requestNewConversation(); + else setNewConversationMenuOpen(false); + }} > +
) : scopedConversations.length === 0 ? (
@@ -595,13 +580,15 @@ export function AgentConversationList({ isActive = true }: AgentConversationList loadError ? (
{t('status.agent.loadConversationsFailed')} - +
) : ( - +
+ +
) )}
@@ -624,12 +611,16 @@ export function AgentConversationList({ isActive = true }: AgentConversationList if (item.kind === 'loading') { return ( - + > + +
); } @@ -642,7 +633,7 @@ export function AgentConversationList({ isActive = true }: AgentConversationList role="alert" > {t('status.agent.loadConversationsFailed')} - +
); } @@ -674,7 +665,7 @@ export function AgentConversationList({ isActive = true }: AgentConversationList 'relative flex h-4 w-4 shrink-0 items-center justify-center rounded-full', running && 'agent-conversation-list__icon--running', )}> - + {(running || (!!instance.threadId && latestCompletedRunIds[instance.threadId] !== readThroughRunIds[instance.threadId] && !(conversationDetailOpen && selectedInstanceId === instance.instanceId))) && ( diff --git a/app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx b/app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx new file mode 100644 index 000000000..64913d400 --- /dev/null +++ b/app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { MoreHorizontal } from 'lucide-react'; +import { + ArchiveIcon, + PencilSimpleIcon, + SquareSplitHorizontalIcon, + StarIcon, + TrashSimpleIcon, +} from '@phosphor-icons/react'; +import { useI18n } from '@/lib/i18n'; +import { toast } from '@/lib/toast'; +import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import type { AgentConversationInstance } from '@features/agent/store/agent-conversation-types'; +import { AgentMessageFontSwitcher, useAgentMessageFontSwitch } from '@features/agent/components/agent-message-font-switcher'; +import { + isFavoriteConversation, + subscribeToFavoriteConversationChanges, + toggleFavoriteConversation, +} from '@features/agent/conversation-favorites'; +import { openBrowserColumnAgentConversation } from '@features/workspace/use-cases/browser-column-navigation'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@shared/ui/dropdown-menu'; + +const itemClass = 'group h-7 items-center justify-start gap-2 rounded-lg px-2 py-0 text-left hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]'; + +/** Shared Agent conversation actions used by both dedicated and fullscreen embedded conversations. */ +export function AgentConversationMoreMenu({ + instance, + iconButtonClass, + onRename, +}: { + instance: AgentConversationInstance; + iconButtonClass: string; + onRename: () => void; +}) { + const { t } = useI18n(); + return ( + + + + + + + + + ); +} + +export function AgentConversationMoreMenuItems({ + instance, + onRename, +}: { + instance: AgentConversationInstance; + onRename: () => void; +}) { + const { t } = useI18n(); + const fontSwitch = useAgentMessageFontSwitch(); + const [isFavorite, setIsFavorite] = useState(() => isFavoriteConversation(instance.instanceId)); + + useEffect(() => { + setIsFavorite(isFavoriteConversation(instance.instanceId)); + return subscribeToFavoriteConversationChanges(() => { + setIsFavorite(isFavoriteConversation(instance.instanceId)); + }); + }, [instance.instanceId]); + + const removeConversation = async (action: 'archive' | 'delete') => { + if (action === 'delete' && !window.confirm(t('agent.chat.conversation.deleteConfirm'))) return; + try { + const session = useAgentSessionStore.getState(); + if (instance.threadId) { + await (action === 'archive' + ? session.archiveThread(instance.threadId) + : session.deleteThread(instance.threadId)); + } else { + session.removeInstance(instance.instanceId); + } + toast.success(t(action === 'archive' ? 'status.agent.archiveSuccess' : 'status.agent.deleteSuccess')); + } catch (error) { + toast.error(error instanceof Error + ? error.message + : t(action === 'archive' ? 'status.agent.archiveFailed' : 'status.agent.deleteFailed')); + } + }; + + return ( + <> + + void openBrowserColumnAgentConversation(instance.instanceId).catch((error) => + toast.error(error instanceof Error ? error.message : String(error)))} + className={itemClass} + > + {t('workColumn.context.openInBrowserColumn')} + + setIsFavorite(toggleFavoriteConversation(instance.instanceId).has(instance.instanceId))} + className={itemClass} + > + + {isFavorite ? t('agent.chat.conversation.unfavorite') : t('agent.chat.conversation.favorite')} + + + {t('agent.chat.conversation.rename')} + + void removeConversation('archive')} className={itemClass}> + {t('document.agent.archiveConversation')} + + void removeConversation('delete')} + className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-transparent hover:text-[var(--destructive)]" + > + {t('document.agent.deleteConversation')} + + + ); +} diff --git a/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx b/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx index b684e8374..83d1ad6cf 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx @@ -8,7 +8,7 @@ import { type ConversationRunSummary, } from '@features/agent/store/conversation-run-index'; import { useWorkspaceRestoreStore } from '@features/workspace/store/workspace-restore-store'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { getAgentType } from '@/lib/agent-types'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/lib/toast'; @@ -87,7 +87,7 @@ export function AgentConversationStatusBar() { const openConversation = useCallback((instance: AgentConversationInstance) => { if (instance.threadId) markThreadRead(instance.threadId); - void selectAndOpenAgentConversation(instance.instanceId).catch((error) => { + void openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: instance.instanceId } }).catch((error) => { toast.error(error instanceof Error ? error.message : t('status.agent.conversationNotFound')); }); }, [markThreadRead, t]); @@ -130,7 +130,7 @@ export function AgentConversationStatusBar() { aria-label={title} > diff --git a/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx b/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx index c57f8fb92..c2e7003ac 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx @@ -1,18 +1,12 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { - ArchiveIcon, FileTextIcon, LinkBreakIcon, - PencilSimpleIcon, - SquareSplitHorizontalIcon, - StarIcon, - TrashSimpleIcon, } from '@phosphor-icons/react'; -import { useTitleEditCaret } from '@shared/hooks/use-title-edit-caret'; import { useI18n } from '@/lib/i18n'; import { isWindowsPlatform } from '@features/shortcuts'; import { SidebarToggleIcon } from '@shared/icons/sidebar-toggle-icon'; @@ -21,118 +15,23 @@ import { DOCUMENT_TITLEBAR_ICON_BUTTON_MAC, DOCUMENT_TITLEBAR_ICON_BUTTON_WIN, } from '@features/document/components/document-titlebar-shared'; -import { DEFAULT_AGENT_TYPE_KEY, getAgentType } from '@/lib/agent-types'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; -import { useAgentAccessStore } from '@features/agent/store/agent-access-store'; -import { openNoteByNotebookPath, openNoteByPhysicalPath } from '@features/memo/use-cases/open-by-target'; -import { openExternalTarget } from '@features/workspace/use-cases/workspace-navigation'; +import { openNoteByNotebookPath, openNoteByPhysicalPath } from '@features/memo/public/note-navigation-api'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { getAgentConversationPresentation } from '@features/agent/conversation-presentation'; -import { AgentIcon } from '@features/agent/components/agent-icon'; -import { BadgeHoverCard } from '@features/agent/thread-card/badge-hover-card'; -import { computeAgentThreadCardBadgeData } from '@features/agent/thread-card/runtime/run-status-presenter'; -import { getResolvedExternalSessionId } from '@features/agent/services/external-agent-runtime-service'; -import { createRuntimeInfoRequester } from '@features/agent/thread-card/runtime/runtime-info-requester'; import { toast } from '@/lib/toast'; import { WorkColumnTitlebarShell } from '@features/shell/components/work-column-titlebar-shell'; -import { openBrowserColumnAgentConversation } from '@features/workspace/use-cases/browser-column-navigation'; -import { - isFavoriteConversation, - subscribeToFavoriteConversationChanges, - toggleFavoriteConversation, -} from '@features/agent/conversation-favorites'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@shared/ui/dropdown-menu'; +import { AgentConversationMoreMenu } from '@features/agent/components/agent-conversation-more-menu'; +import { AgentConversationIdentity } from '@features/agent/components/agent-conversation-identity'; function AgentConversationHeader({ instanceId }: { instanceId: string }) { const { t } = useI18n(); const isWindows = isWindowsPlatform(); const instance = useAgentSessionStore((state) => state.getInstance(instanceId)); - const projection = useAgentSessionStore((state) => { - const threadId = state.getInstance(instanceId)?.threadId; - return threadId ? state.threadProjections[threadId] : undefined; - }); - const codexModel = useAgentSessionStore((state) => state.sessionMeta.settings.agentCodexModel); - const piCodeMode = useAgentAccessStore((state) => - state.config.defaults?.runtime?.pi?.piCodeMode ?? false, - ); - const [isEditingTitle, setIsEditingTitle] = useState(false); - const { inputRef: titleInputRef, captureCaret: captureTitleCaret } = useTitleEditCaret(isEditingTitle); - const [titleDraft, setTitleDraft] = useState(''); - const [isFavorite, setIsFavorite] = useState(() => isFavoriteConversation(instanceId)); - - useEffect(() => { - setIsFavorite(isFavoriteConversation(instanceId)); - return subscribeToFavoriteConversationChanges(() => { - setIsFavorite(isFavoriteConversation(instanceId)); - }); - }, [instanceId]); - - // archive/delete 由 store 端到端完成 (含 closeAgentConversation), instance - // 消失时父级会立刻 unmount 这个组件; 不再渲染兜底空态、不再做"instance 缺 - // 失时" 的 hooks 防御。 + const [renameRequest, setRenameRequest] = useState(0); const presentation = instance ? getAgentConversationPresentation(instance, t('common.untitled')) : null; - const agent = getAgentType(instance?.agentType ?? DEFAULT_AGENT_TYPE_KEY); - const productThreadId = instance?.threadId ?? ''; - const providerSessionId = instance?.providerSessionId ?? ( - productThreadId ? getResolvedExternalSessionId(productThreadId) : null - ); - - const badgeData = useMemo(() => computeAgentThreadCardBadgeData({ - threadState: projection ? { - lastRun: projection.runs.lastRun, - activeRunId: projection.runs.activeRunId, - runs: projection.runs.runs, - } : undefined, - codexModel, - typeKey: instance?.agentType ?? DEFAULT_AGENT_TYPE_KEY, - }), [codexModel, instance?.agentType, projection]); - - const onRemove = useCallback(async ( - target: NonNullable, - action: 'archive' | 'delete', - ) => { - if (action === 'delete' && !window.confirm(t('agent.chat.conversation.deleteConfirm'))) return; - - try { - const session = useAgentSessionStore.getState(); - if (target.threadId) { - await (action === 'archive' - ? session.archiveThread(target.threadId) - : session.deleteThread(target.threadId)); - } else { - session.removeInstance(target.instanceId); - } - toast.success(t(action === 'archive' ? 'status.agent.archiveSuccess' : 'status.agent.deleteSuccess')); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t(action === 'archive' ? 'status.agent.archiveFailed' : 'status.agent.deleteFailed'), - ); - } - }, [t]); - - const onOpenInBrowserColumn = useCallback((target: NonNullable) => { - void openBrowserColumnAgentConversation(target.instanceId).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)); - }); - }, []); - - const onToggleFavorite = useCallback(() => { - const next = toggleFavoriteConversation(instanceId); - setIsFavorite(next.has(instanceId)); - }, [instanceId]); - - const onRename = useCallback((target: NonNullable) => { - setTitleDraft(target.title?.trim() || ''); - setIsEditingTitle(true); - }, []); const onOpenSourceDocument = useCallback(() => { if (!instance || !presentation?.hasSourceDocument) return; @@ -140,135 +39,57 @@ function AgentConversationHeader({ instanceId }: { instanceId: string }) { const open = source?.notebookId && source.relativePath ? openNoteByNotebookPath(source.notebookId, source.relativePath) : source?.documentPath - ? openNoteByPhysicalPath(source.documentPath).catch(() => openExternalTarget(source.documentPath!)) + ? openNoteByPhysicalPath(source.documentPath).catch(() => openWorkspaceSurface({ + host: 'main-third', + target: { kind: 'external', path: source.documentPath!, scopePath: null, transitionId: null }, + }).then(() => undefined)) : Promise.resolve(); void open.catch(() => toast.error(t('status.agent.originUnavailable'))); }, [instance, presentation, t]); - const commitTitle = useCallback(() => { - const title = titleDraft.trim(); - if (title && instance) { - void useAgentSessionStore.getState().renameAgentConversation({ - instanceId: instance.instanceId, - threadId: instance.threadId, - title, - typeKey: instance.agentType, - }); - } - setIsEditingTitle(false); - }, [instance, titleDraft]); - if (!instance || !presentation) return null; - const { hasSourceDocument, runtimeCwd } = presentation; const actionButtonClass = isWindows ? DOCUMENT_TITLEBAR_ICON_BUTTON_WIN : DOCUMENT_TITLEBAR_ICON_BUTTON_MAC; return ( -
- - useAgentSessionStore.getState().getInstance(instanceId)?.threadId, - () => { - const current = useAgentSessionStore.getState().getInstance(instanceId); - return current?.providerSessionId ?? ( - current?.threadId - ? getResolvedExternalSessionId(current.threadId) - : null - ); - }, - )} - agentType={instance.agentType} - piMode={instance.agentType === 'pi' ? (piCodeMode ? 'code' : 'writing') : undefined} - cwd={runtimeCwd} - /> - - - {isEditingTitle ? ( -
- setTitleDraft(event.target.value)} onBlur={commitTitle} - onKeyDown={(event) => { - // Enter is also emitted while an IME is confirming its current - // candidate. Let the composition finish before allowing the - // title editor to commit and blur. - if (event.key === 'Enter' && (event.nativeEvent.isComposing || event.keyCode === 229)) return; - if (event.key === 'Enter') { event.preventDefault(); commitTitle(); } - if (event.key === 'Escape') setIsEditingTitle(false); - }} /> -
- ) : ( -
event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - captureTitleCaret(event.currentTarget, event.clientX, event.clientY); - setTitleDraft(instance.title?.trim() || ''); - setIsEditingTitle(true); - }} - > - {presentation.title} -
- )} +
+
- {hasSourceDocument ? <> - - : ( - - - )} - - - - - - onOpenInBrowserColumn(instance)} className="group h-7 items-center justify-start gap-2 rounded-lg px-2 py-0 text-left hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]"> - {t('workColumn.context.openInBrowserColumn')} - - - - {isFavorite ? t('agent.chat.conversation.unfavorite') : t('agent.chat.conversation.favorite')} - - onRename(instance)} className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]"> - {t('agent.chat.conversation.rename')} - - void onRemove(instance, 'archive')} className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]"> - {t('document.agent.archiveConversation')} - - void onRemove(instance, 'delete')} className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-transparent hover:text-[var(--destructive)]"> - {t('document.agent.deleteConversation')} - - - + {presentation.hasSourceDocument ? ( + + ) : ( + + + )} + setRenameRequest((request) => request + 1)} + />
); diff --git a/app/flowix-web/features/agent/components/agent-icon.tsx b/app/flowix-web/features/agent/components/agent-icon.tsx index 864e5520f..7a913f447 100644 --- a/app/flowix-web/features/agent/components/agent-icon.tsx +++ b/app/flowix-web/features/agent/components/agent-icon.tsx @@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'; export { isThemeAdaptiveAgentIcon, THEME_ADAPTIVE_AGENT_ICON_KEYS }; const DEFAULT_AGENT_ICON_COLORS: Partial> = { - 'deepseek-harness': '#484848', + dsh: '#484848', }; export interface AgentIconProps { diff --git a/app/flowix-web/features/agent/components/agent-message-font-switcher.tsx b/app/flowix-web/features/agent/components/agent-message-font-switcher.tsx new file mode 100644 index 000000000..55487533f --- /dev/null +++ b/app/flowix-web/features/agent/components/agent-message-font-switcher.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useI18n } from '@/lib/i18n'; +import { toast } from '@/lib/toast'; +import { subscribe } from '@platform/tauri/event-bus'; +import type { FontDownloadProgress } from '@platform/tauri/client/general'; +import { ensureDownloadedFontRegistered } from '@features/preferences/public/font-api'; +import { TypographyFontSwitcher } from '@shared/ui/typography-font-switcher'; +import { FontSizeSwitcher } from '@shared/ui/font-size-switcher'; +import { getTypographyFontId } from '@/lib/typography-font-mode'; +import { + useAgentMessageFontStore, + type AgentMessageFontMode, + type AgentMessageSizeMode, +} from '@features/agent/store/agent-message-font-store'; + +export function useAgentMessageFontSwitch() { + const { t } = useI18n(); + const fontMode = useAgentMessageFontStore((state) => state.fontMode); + const sizeMode = useAgentMessageFontStore((state) => state.sizeMode); + const setFontMode = useAgentMessageFontStore((state) => state.setFontMode); + const [downloadingMode, setDownloadingMode] = useState | null>(null); + const [percent, setPercent] = useState(null); + const requestId = useRef(0); + const downloadingFontId = useRef(null); + + useEffect(() => subscribe('font-download-progress', (progress) => { + if (progress.fontId === downloadingFontId.current) setPercent(progress.percent); + }), []); + + useEffect(() => () => { + requestId.current += 1; + downloadingFontId.current = null; + }, []); + + async function selectFontMode(mode: AgentMessageFontMode) { + const request = ++requestId.current; + const fontId = getTypographyFontId(mode); + downloadingFontId.current = mode === 'standard' ? null : fontId; + setDownloadingMode(mode === 'standard' ? null : mode); + setPercent(null); + + try { + if (mode !== 'standard') await ensureDownloadedFontRegistered(fontId); + if (requestId.current !== request) return; + setFontMode(mode); + } catch (error) { + if (requestId.current !== request) return; + const message = error instanceof Error ? error.message : String(error); + toast.error(t('preferences.format.fontDownloadFailed', { message })); + } finally { + if (requestId.current === request) { + downloadingFontId.current = null; + setDownloadingMode(null); + setPercent(null); + } + } + } + + return { fontMode, sizeMode, downloadingMode, percent, selectFontMode }; +} + +export function AgentMessageFontSwitcher({ + fontMode, + sizeMode, + downloadingMode, + percent, + onSelect, +}: { + fontMode: AgentMessageFontMode; + sizeMode: AgentMessageSizeMode; + downloadingMode: Exclude | null; + percent: number | null; + onSelect: (mode: AgentMessageFontMode) => void; +}) { + const setSizeMode = useAgentMessageFontStore((state) => state.setSizeMode); + return ( +
+ + +
+ ); +} diff --git a/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx b/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx index 76bc46c6c..ed0702e96 100644 --- a/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx +++ b/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx @@ -4,8 +4,7 @@ import type { ReactNode } from 'react'; import { AGENT_TYPES, isAgentTypeComingSoon } from '@/lib/agent-types'; import { cn } from '@/lib/utils'; import { useI18n, type I18nKey } from '@/lib/i18n'; -import type { AgentTypeKey } from '@/types/agent'; -import type { AgentRuntimeAvailability } from '@platform/tauri/client'; +import type { AgentRuntimeSnapshot, AgentTypeKey } from '@/types/agent'; import { normalizeAgentRuntimeStatus, type AgentRuntimeStatusView, @@ -14,7 +13,7 @@ import { Button } from '@shared/ui/button'; import { useAgentVisibilityPreferences } from '@features/preferences/public/runtime-api'; import { AgentIcon } from '@features/agent/components/agent-icon'; -type AgentRuntimeStatusByType = Partial>; +type AgentRuntimeStatusByType = Partial>; interface AgentRuntimeStatusListProps { statusByType: AgentRuntimeStatusByType; @@ -42,9 +41,8 @@ interface AgentRuntimeStatusListProps { */ onCardClick?: (typeKey: AgentTypeKey) => void; /** - * 需要隐藏的 agent key 集合 ── 调用方传进来后, 这些 agent 既不渲染行 - * 也不计入后续的 `renderAfterRow` / `onCardClick` 调用。偏好设置用来 - * 临时下线尚未发布的 agent (openclaw / gemini) 而不影响其他入口。 + * 需要隐藏的 agent key 集合。隐藏项既不渲染,也不会传给 + * `renderAfterRow` / `onCardClick`。 */ hiddenKeys?: ReadonlySet; } @@ -56,7 +54,7 @@ export function getAgentRuntimeStatusText({ t, }: { typeKey?: AgentTypeKey; - status: AgentRuntimeAvailability | undefined; + status: AgentRuntimeSnapshot | undefined; isChecking: boolean; t?: (key: I18nKey) => string; }): string { @@ -97,9 +95,9 @@ export function AgentRuntimeStatusList({ }: AgentRuntimeStatusListProps) { const isVisible = (typeKey: AgentTypeKey) => !hiddenKeys?.has(typeKey); - const visibleAgentTypes = AGENT_TYPES.filter((type) => isVisible(type.key)); + const visibleAgentTypes = AGENT_TYPES.filter((type) => isVisible(type.id)); const { t } = useI18n(); - const { enabledByType: agentVisibility, setEnabled } = useAgentVisibilityPreferences(); + const { enabledByType: agentEnabledByType, setEnabled } = useAgentVisibilityPreferences(); // 解析 i18n 文案 ── 卡片有 nameKey/descKey 就走 t(), 缺省回退到 type.name // / type.desc 的硬编码英文 (供编辑器节点等非 React 上下文用)。 @@ -108,7 +106,7 @@ export function AgentRuntimeStatusList({ const displayDesc = (type: (typeof AGENT_TYPES)[number]): string => type.descKey ? t(type.descKey as Parameters[0]) : type.desc; - const setAgentSlashEnabled = (typeKey: AgentTypeKey, enabled: boolean) => { + const setAgentEnabled = (typeKey: AgentTypeKey, enabled: boolean) => { void setEnabled(typeKey, enabled); }; @@ -116,21 +114,21 @@ export function AgentRuntimeStatusList({ return (
{visibleAgentTypes.map((type) => { - const status = statusByType[type.key]; - const comingSoon = isAgentTypeComingSoon(type.key); + const status = statusByType[type.id]; + const comingSoon = isAgentTypeComingSoon(type.id); const runtimeStatus = normalizeAgentRuntimeStatus(status, isChecking); const unavailable = comingSoon || isRuntimeUnavailable(runtimeStatus); - const statusText = getAgentRuntimeStatusText({ typeKey: type.key, status, isChecking, t }); + const statusText = getAgentRuntimeStatusText({ typeKey: type.id, status, isChecking, t }); const canSetup = !comingSoon && isRuntimeUnavailable(runtimeStatus) && Boolean(onSetupClick); const available = !comingSoon && runtimeStatus.state === 'ready'; - const slashEnabled = agentVisibility[type.key] ?? true; + const agentEnabled = agentEnabledByType[type.id] ?? true; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - onCardClick(type.key); + onCardClick(type.id); } } : undefined} className={cn( @@ -158,7 +156,7 @@ export function AgentRuntimeStatusList({
setAgentSlashEnabled(type.key, !slashEnabled)} + aria-checked={agentEnabled} + aria-label={t('preferences.agents.enableAgent', { name: displayName(type) })} + onClick={() => setAgentEnabled(type.id, !agentEnabled)} className={cn( 'relative h-5 w-9 rounded-full border transition-colors outline-none', 'focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2', - slashEnabled + agentEnabled ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border)] bg-[var(--muted)]' )} @@ -205,11 +203,11 @@ export function AgentRuntimeStatusList({ - {headerAction?.(type.key)} + {headerAction?.(type.id)}
) : canSetup ? (
onSetupClick?.(type.key)} + onClick={() => onSetupClick?.(type.id)} > {t('agent.status.setup')} - {headerAction?.(type.key)} + {headerAction?.(type.id)}
) : (
{statusText} - {headerAction?.(type.key)} + {headerAction?.(type.id)}
)}
- {renderAfterRow?.(type.key)} + {renderAfterRow?.(type.id)}
); })} @@ -253,19 +251,19 @@ export function AgentRuntimeStatusList({ return (
{visibleAgentTypes.map((type) => { - const status = statusByType[type.key]; - const comingSoon = isAgentTypeComingSoon(type.key); + const status = statusByType[type.id]; + const comingSoon = isAgentTypeComingSoon(type.id); const runtimeStatus = normalizeAgentRuntimeStatus(status, isChecking); const unavailable = comingSoon || isRuntimeUnavailable(runtimeStatus); - const statusText = getAgentRuntimeStatusText({ typeKey: type.key, status, isChecking, t }); + const statusText = getAgentRuntimeStatusText({ typeKey: type.id, status, isChecking, t }); const interactive = !comingSoon && isRuntimeUnavailable(runtimeStatus) && Boolean(onSetupClick); return (
+ {sectionActions} { - setCreateMenuOpen(open); - if (open) void refreshAgentRuntimeIfStale(); + if (!open) { + setCreateMenuOpen(false); + return; + } + const selection = requestNewConversationAgentSelection(); + if (selection.kind === 'create') { + createConversation(selection.agentType); + setCreateMenuOpen(false); + return; + } + setCreateMenuOpen(selection.kind === 'choose'); }} >
))} - {!collapsed && tasks.length > 5 && ( - - )} !open && !renameSaving && setRenameTarget(null)}> {t('agent.chat.conversation.rename')} diff --git a/app/flowix-web/features/agent/components/conversation-list-pagination.ts b/app/flowix-web/features/agent/components/conversation-list-pagination.ts index c50f49470..a906db7de 100644 --- a/app/flowix-web/features/agent/components/conversation-list-pagination.ts +++ b/app/flowix-web/features/agent/components/conversation-list-pagination.ts @@ -59,8 +59,6 @@ function isFallbackTitle(instance: AgentConversationInstance): boolean { 'hermes 会话', 'opencode session', 'opencode 会话', - 'openclaw session', - 'openclaw 会话', ]).has(title); } diff --git a/app/flowix-web/features/agent/components/use-agent-conversation-surface.ts b/app/flowix-web/features/agent/components/use-agent-conversation-surface.ts new file mode 100644 index 000000000..8811b1cf3 --- /dev/null +++ b/app/flowix-web/features/agent/components/use-agent-conversation-surface.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useI18n } from '@/lib/i18n'; +import { getAgentType } from '@/lib/agent-types'; +import { getAgentConversationPresentation } from '@features/agent/conversation-presentation'; +import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import type { SurfacePresentationUpdate, SurfaceState } from '@features/workspace/model/surface-contract'; + +/** Domain hydration and display data shared by both workspace hosts. */ +export function useAgentConversationSurface(instanceId: string, reportPresentation?: (update: SurfacePresentationUpdate) => void) { + const { t } = useI18n(); + const instance = useAgentSessionStore((state) => state.getInstance(instanceId)); + const [loadState, setLoadState] = useState<{ + instanceId: string; + status: 'loading' | 'not-found' | 'error'; + } | null>(null); + const [retryCount, setRetryCount] = useState(0); + useEffect(() => { + if (instance) return; + + let cancelled = false; + setLoadState({ instanceId, status: 'loading' }); + void useAgentSessionStore.getState().hydrateInstance(instanceId) + .then((restored) => { + if (!cancelled && !restored) setLoadState({ instanceId, status: 'not-found' }); + }) + .catch(() => { + if (!cancelled) setLoadState({ instanceId, status: 'error' }); + }); + return () => { + cancelled = true; + }; + }, [instance, instanceId, retryCount]); + + const retry = useCallback(() => { + setLoadState({ instanceId, status: 'loading' }); + setRetryCount((count) => count + 1); + }, [instanceId]); + const status = instance ? 'ready' : loadState?.instanceId === instanceId ? loadState.status : 'loading'; + const title = instance ? getAgentConversationPresentation(instance, t('common.untitled')).title : t('common.untitled'); + const icon = instance ? getAgentType(instance.agentType).icon : undefined; + useEffect(() => { + const actions = [{ id: 'retry', label: { key: 'error.retry' }, enabled: true, run: retry }]; + const state: SurfaceState = status === 'ready' ? { status: 'ready' } + : status === 'loading' ? { status: 'loading', phase: 'content', message: { key: 'status.agent.loadingConversations' }, retainContent: false } + : status === 'not-found' ? { status: 'unavailable', reason: 'missing', message: { key: 'status.agent.conversationNotFound' }, actions } + : { status: 'error', message: { key: 'status.agent.loadConversationsFailed' }, actions }; + reportPresentation?.({ title, icon, state }); + }, [icon, reportPresentation, retry, status, title]); + + return { instance, status, retry }; +} diff --git a/app/flowix-web/features/agent/components/use-conversation-list-data.ts b/app/flowix-web/features/agent/components/use-conversation-list-data.ts index bd41811b1..2b46356dd 100644 --- a/app/flowix-web/features/agent/components/use-conversation-list-data.ts +++ b/app/flowix-web/features/agent/components/use-conversation-list-data.ts @@ -14,6 +14,15 @@ import { const logger = createLogger('conversation-list-data'); const CONVERSATION_PAGE_SIZE = 30; +function firstPageQueryKey( + notebookId: string | null, + agentType: AgentTypeKey | null, + lifecycleVersion: number, + retry: number, +): string { + return `${notebookId ?? ''}\u001f${agentType ?? ''}\u001f${lifecycleVersion}\u001f${retry}`; +} + interface ConversationListDataOptions { notebookId: string | null; agentType: AgentTypeKey | null; @@ -31,6 +40,7 @@ export function useConversationListData({ ); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(false); + const [settledQueryKey, setSettledQueryKey] = useState(null); const [firstPageRetry, setFirstPageRetry] = useState(0); const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(false); @@ -46,7 +56,24 @@ export function useConversationListData({ // hydrated wholesale just to render the sidebar. useEffect(() => { let active = true; - const queryKey = `${notebookId ?? ''}\u001f${agentType ?? ''}`; + const queryKey = firstPageQueryKey(notebookId, agentType, lifecycleVersion, firstPageRetry); + if (!notebookId) { + requestSequenceRef.current += 1; + loadingMoreRef.current = false; + listQueryKeyRef.current = queryKey; + nextCursorRef.current = null; + setConversationPage(EMPTY_CONVERSATION_PAGE_STATE); + setHasMore(false); + setLoadError(false); + setLoadMoreError(false); + setIsLoadingMore(false); + setAgentTypeCounts({}); + setIsLoading(false); + setSettledQueryKey(queryKey); + return () => { + active = false; + }; + } const query = { notebookId, agentType, cursor: null } as const; const queryChanged = listQueryKeyRef.current !== queryKey; // Invalidate any in-flight page request before starting a new first page. @@ -62,6 +89,7 @@ export function useConversationListData({ setLoadMoreError(false); } setLoadError(false); + setSettledQueryKey(null); // Lifecycle changes can trigger a fresh first page after the view is // mounted, keeping deletion/archive state aligned with the backend. setIsLoading(true); @@ -80,7 +108,10 @@ export function useConversationListData({ if (active) setLoadError(true); }) .finally(() => { - if (active) setIsLoading(false); + if (active) { + setIsLoading(false); + setSettledQueryKey(queryKey); + } }); return () => { active = false; @@ -92,6 +123,12 @@ export function useConversationListData({ // rows themselves remain cursor-paginated. useEffect(() => { let active = true; + if (!notebookId) { + setAgentTypeCounts({}); + return () => { + active = false; + }; + } void agentClient.listConversationTypeCountsByNotebook(notebookId) .then((counts) => { if (!active) return; @@ -108,11 +145,11 @@ export function useConversationListData({ }, [notebookId, lifecycleVersion]); const loadMoreConversations = useCallback(async (retry = false) => { - if (loadingMoreRef.current || !hasMore || isLoading || loadError || (loadMoreError && !retry)) return; + if (!notebookId || loadingMoreRef.current || !hasMore || isLoading || loadError || (loadMoreError && !retry)) return; loadingMoreRef.current = true; setIsLoadingMore(true); setLoadMoreError(false); - const queryKey = `${notebookId ?? ''}\u001f${agentType ?? ''}`; + const queryKey = firstPageQueryKey(notebookId, agentType, lifecycleVersion, firstPageRetry); const requestSequence = ++requestSequenceRef.current; try { const query = { notebookId, agentType, cursor: nextCursorRef.current }; @@ -142,7 +179,16 @@ export function useConversationListData({ setIsLoadingMore(false); } } - }, [notebookId, agentType, hasMore, isLoading, loadError, loadMoreError]); + }, [ + notebookId, + agentType, + lifecycleVersion, + firstPageRetry, + hasMore, + isLoading, + loadError, + loadMoreError, + ]); const retryFirstPage = useCallback(() => { setFirstPageRetry((value) => value + 1); @@ -160,6 +206,8 @@ export function useConversationListData({ loadMoreError, hasMore, agentTypeCounts, + settledQueryKey, + queryKey: firstPageQueryKey(notebookId, agentType, lifecycleVersion, firstPageRetry), loadMore: loadMoreConversations, retryFirstPage, updateTitle, diff --git a/app/flowix-web/features/agent/config/agent-model-options.ts b/app/flowix-web/features/agent/config/agent-model-options.ts new file mode 100644 index 000000000..4167271ef --- /dev/null +++ b/app/flowix-web/features/agent/config/agent-model-options.ts @@ -0,0 +1,16 @@ +import type { AgentTypeKey } from "@/types/agent"; +import { CODEX_MODEL_OPTIONS, formatModelDisplayLabel } from "./codex-options"; +import { normalizeAgentId } from "@/lib/agent-types"; + +/** Built-in models shown before an Agent catalog has loaded. */ +export function getFallbackAgentModelOptions(agentType: AgentTypeKey): Array<{ id: string; label: string }> { + const id = normalizeAgentId(agentType); + if (id === "codex") { + return CODEX_MODEL_OPTIONS.map(({ id, label }) => ({ id, label })); + } + if (id === "claudecode") { + return ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"] + .map((id) => ({ id, label: formatModelDisplayLabel(id) })); + } + return []; +} diff --git a/app/flowix-web/features/agent/events/agent-event-mapper.test.ts b/app/flowix-web/features/agent/events/agent-event-mapper.test.ts index b99e956d4..afdaa1bba 100644 --- a/app/flowix-web/features/agent/events/agent-event-mapper.test.ts +++ b/app/flowix-web/features/agent/events/agent-event-mapper.test.ts @@ -20,7 +20,7 @@ describe("agent event mapper", () => { id: "user-1", text: "question", timestamp: 456, - agent_type: "claude", + agent_type: "claudecode", run_id: "run-1", }, state(), @@ -29,9 +29,9 @@ describe("agent event mapper", () => { expect(event).toMatchObject({ kind: "user_message", - id: "msg:claude:run-1:user:user-1", + id: "msg:claudecode:run-1:user:user-1", text: "question", - messageId: "msg:claude:run-1:user:user-1", + messageId: "msg:claudecode:run-1:user:user-1", sourceTimestamp: 456, sourceSequence: 0, }); @@ -46,7 +46,7 @@ describe("agent event mapper", () => { text: "目标执行中:在吗(第 1/256 轮)", timestamp: 456, message_type: "goal-round", - agent_type: "deepseek-harness", + agent_type: "dsh", run_id: "run-1", }, state(), @@ -66,7 +66,7 @@ describe("agent event mapper", () => { kind: "text", thread_id: "flowix-thread", text: "hello", - agent_type: "deepseek-harness", + agent_type: "dsh", run_id: "run-1", }, state(), @@ -150,7 +150,7 @@ describe("agent event mapper", () => { kind: "error", thread_id: "claude-thread", run_id: "run-1", - agent_type: "claude", + agent_type: "claudecode", message: "Claude Code CLI exited with status exit status: 1", error_details: { category: "rate_limited", @@ -166,7 +166,7 @@ describe("agent event mapper", () => { expect(event).toMatchObject({ kind: "error", - messageId: "msg:claude:run-1:error:error", + messageId: "msg:claudecode:run-1:error:error", message: "Claude Code CLI exited with status exit status: 1", errorDetails: { category: "rate_limited", @@ -226,7 +226,7 @@ describe("agent event mapper", () => { kind: "reasoning", thread_id: "thread-1", text: "thinking", - agent_type: "deepseek-harness", + agent_type: "dsh", }, state(), () => 123, @@ -238,7 +238,7 @@ describe("agent event mapper", () => { { kind: "usage", thread_id: "thread-1", - agent_type: "deepseek-harness", + agent_type: "dsh", usage: { input_tokens: 10, output_tokens: 4 }, }, state(), @@ -253,7 +253,7 @@ describe("agent event mapper", () => { kind: "reasoning", thread_id: "claude-thread", text: "first", - agent_type: "claude", + agent_type: "claudecode", run_id: "run-1", message_id: "reasoning-provider-message-1-block-0", }, @@ -264,7 +264,7 @@ describe("agent event mapper", () => { kind: "reasoning", thread_id: "claude-thread", text: "second", - agent_type: "claude", + agent_type: "claudecode", run_id: "run-1", message_id: "reasoning-provider-message-2-block-0", }, @@ -272,7 +272,7 @@ describe("agent event mapper", () => { ); expect(first.messageId).toBe( - "msg:claude:run-1:reasoning:reasoning-run-1", + "msg:claudecode:run-1:reasoning:reasoning-run-1", ); expect(second.messageId).toBe(first.messageId); }); @@ -283,7 +283,7 @@ describe("agent event mapper", () => { kind: "text", thread_id: "claude-thread", text: "fragment", - agent_type: "claude", + agent_type: "claudecode", run_id: "run-1", message_id: "assistant-d9193ae4-86b5-47a6-9e85-1bb4ef0acc1c-block-1", @@ -295,7 +295,7 @@ describe("agent event mapper", () => { expect(event).toMatchObject({ kind: "text_delta", - agentType: "claude", + agentType: "claudecode", text: "fragment", }); expect(event.messageId).toBeUndefined(); @@ -307,7 +307,7 @@ describe("agent event mapper", () => { kind: "text", thread_id: "claude-thread", text: "fragment", - agent_type: "claude", + agent_type: "claudecode", run_id: "run-1", message_id: "assistant-06bbbb1512785f3d1da3e1f495c31702-block-1", message_phase: "updated", @@ -317,7 +317,7 @@ describe("agent event mapper", () => { ); expect(event.messageId).toBe( - "msg:claude:run-1:assistant:assistant-06bbbb1512785f3d1da3e1f495c31702-block-1", + "msg:claudecode:run-1:assistant:assistant-06bbbb1512785f3d1da3e1f495c31702-block-1", ); }); @@ -354,7 +354,7 @@ describe("agent event mapper", () => { args: "", status: "pending", timestamp: 456, - agent_type: "deepseek-harness", + agent_type: "dsh", run_id: "dsh-command-run-1", }, state(), diff --git a/app/flowix-web/features/agent/events/agent-event-mapper.ts b/app/flowix-web/features/agent/events/agent-event-mapper.ts index eee5242eb..d0c95b8ef 100644 --- a/app/flowix-web/features/agent/events/agent-event-mapper.ts +++ b/app/flowix-web/features/agent/events/agent-event-mapper.ts @@ -32,7 +32,7 @@ function resolveTextMessageId( // on every delta. Dropping only that known-bad delta id lets pendingAssistantId // join contiguous text while tool calls still form an explicit boundary. if ( - agentType === "claude" && + agentType === "claudecode" && contentMode === "delta" && messageId && CLAUDE_ENVELOPE_TEXT_MESSAGE_ID.test(messageId) @@ -127,7 +127,7 @@ export function mapAgentChunkToEvent( base.agentType, base.runId, "reasoning", - base.agentType === "claude" && + base.agentType === "claudecode" && !base.messageId?.startsWith("msg:") ? `reasoning-${base.runId}` : base.messageId, diff --git a/app/flowix-web/features/agent/events/codex-message-identity.ts b/app/flowix-web/features/agent/events/codex-message-identity.ts new file mode 100644 index 000000000..6bbe7411f --- /dev/null +++ b/app/flowix-web/features/agent/events/codex-message-identity.ts @@ -0,0 +1,49 @@ +/** Shared marker used when Codex omits a user item's native ID. */ +export const CODEX_MISSING_USER_ITEM_ID = "flowix:codex:missing-user-item"; + +export function codexCommandMessageId(turnId: string): string { + return `flowix:codex:command:${turnId}`; +} + +/** Temporary identity while the provider has not attached a turn ID yet. */ +export function codexPendingCommandMessageId(commandId: string): string { + return `flowix:codex:command:pending:${commandId}`; +} + +export function codexToolCallMessageId( + turnId: string | undefined, + callId: string, +): string { + return `flowix:codex:tool-call:${turnId?.trim() ? turnId : "unbound"}:${callId}`; +} + +export function codexCustomToolOutputMessageId( + turnId: string | undefined, + callId: string, +): string { + return `flowix:codex:tool-output:${turnId?.trim() ? turnId : "unbound"}:${callId}`; +} + +/** + * Fallback for a completed provider item that has no native id. Hash UTF-8 + * bytes so Rust history projection and the browser live projection produce + * the same value. The occurrence distinguishes repeated identical items in + * the same turn. + */ +export function codexFallbackItemMessageId( + turnId: string, + role: string, + content: string, + occurrence: number, +): string { + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (const byte of new TextEncoder().encode(content)) { + first = Math.imul(first ^ byte, 0x01000193) >>> 0; + second = Math.imul(second ^ byte, 0x85ebca6b) >>> 0; + } + const fingerprint = `${first.toString(16).padStart(8, "0")}${second + .toString(16) + .padStart(8, "0")}`; + return `flowix:codex:item:${turnId}:${role}:${fingerprint}:${occurrence}`; +} diff --git a/app/flowix-web/features/agent/events/message-identity.test.ts b/app/flowix-web/features/agent/events/message-identity.test.ts index c6586afda..e6ca6ff04 100644 --- a/app/flowix-web/features/agent/events/message-identity.test.ts +++ b/app/flowix-web/features/agent/events/message-identity.test.ts @@ -5,7 +5,7 @@ import { } from "@features/agent/events/message-identity"; describe("canonical agent message identity", () => { - it.each(["claude", "hermes", "opencode", "deepseek-harness"] as const)( + it.each(["claudecode", "hermes", "opencode", "dsh"] as const)( "uses the shared rule for %s", (agentType) => { expect( @@ -44,8 +44,15 @@ describe("canonical agent message identity", () => { expect( canonicalAgentMessageId("codex", "run-1", "assistant", canonical), ).toBe(canonical); - expect(completedRunUserMessageId("deepseek-harness", "run-1")).toBe( - "msg:deepseek-harness:run-1:user:user-run-1", + expect(completedRunUserMessageId("dsh", "run-1")).toBe( + "msg:dsh:run-1:user:user-run-1", ); }); + + it("preserves the shared Codex turn-error identity", () => { + const turnErrorId = "msg:codex:turn-error:turn-1"; + expect( + canonicalAgentMessageId("codex", "run-1", "error", turnErrorId), + ).toBe(turnErrorId); + }); }); diff --git a/app/flowix-web/features/agent/events/message-identity.ts b/app/flowix-web/features/agent/events/message-identity.ts index 81dba2866..d73724cee 100644 --- a/app/flowix-web/features/agent/events/message-identity.ts +++ b/app/flowix-web/features/agent/events/message-identity.ts @@ -2,11 +2,11 @@ import type { AgentTypeKey } from "@/types/agent"; const CANONICAL_EXTERNAL_AGENTS = new Set([ "codex", - "claude", + "claudecode", "hermes", "pi", "opencode", - "deepseek-harness", + "dsh", ]); export function canonicalAgentMessageId( diff --git a/app/flowix-web/features/agent/hooks/use-agent-events.ts b/app/flowix-web/features/agent/hooks/use-agent-events.ts index f25b92537..22a1fcb6c 100644 --- a/app/flowix-web/features/agent/hooks/use-agent-events.ts +++ b/app/flowix-web/features/agent/hooks/use-agent-events.ts @@ -6,6 +6,7 @@ import { } from '@features/agent/store/agent-session-store'; import { isProjectionRunActive } from '@features/agent/store/session-reducer'; import { getInterestedThreadIds } from '@features/agent/store/thread-interest'; +import { runStartupStage3Task } from '@/lib/startup-stage3'; export async function reconcileAgentRunsAndRefreshEndedHistory(): Promise { // Canonical run state lives in AgentSessionStore projections. @@ -56,7 +57,9 @@ export async function reconcileAgentRunsAndRefreshEndedHistory(): Promise refreshTargets.set(threadId, agentType); } - for (const interestedThreadId of getInterestedThreadIds()) { + const interestedThreadIds = getInterestedThreadIds(); + const priorityThreadIds = new Set(interestedThreadIds); + for (const interestedThreadId of interestedThreadIds) { refreshTargets.set( interestedThreadId, after.sessionMeta.threadTypes[interestedThreadId] ?? @@ -70,16 +73,23 @@ export async function reconcileAgentRunsAndRefreshEndedHistory(): Promise .map((target) => [target.threadId, target]), ); - await Promise.allSettled( - [...refreshTargets].map(([threadId, agentType]) => { - const completed = completedTargets.get(threadId); - return completed - ? useAgentSessionStore - .getState() - .reconcileCompletedRun(agentType, threadId, completed.runId) - : useAgentSessionStore.getState().loadMessages(agentType, threadId); - }), - ); + const targets = [...refreshTargets]; + const priorityTargets = targets.filter(([threadId]) => priorityThreadIds.has(threadId)); + const backgroundTargets = targets.filter(([threadId]) => !priorityThreadIds.has(threadId)); + const refreshTarget = ([threadId, agentType]: [string, typeof after.sessionMeta.activeAgentTypeKey]) => { + const completed = completedTargets.get(threadId); + return completed + ? useAgentSessionStore + .getState() + .reconcileCompletedRun(agentType, threadId, completed.runId) + : useAgentSessionStore.getState().loadMessages(agentType, threadId); + }; + + // Refresh visible conversations first; catch up hidden sessions two at a time. + await Promise.allSettled(priorityTargets.map(refreshTarget)); + for (let index = 0; index < backgroundTargets.length; index += 2) { + await Promise.allSettled(backgroundTargets.slice(index, index + 2).map(refreshTarget)); + } } /** @@ -88,13 +98,15 @@ export async function reconcileAgentRunsAndRefreshEndedHistory(): Promise * This is mounted by AgentWindowEffects in content Webviews, but not * preferences. The bridge itself is idempotent within each Webview realm. */ -export function useAgentEvents(): void { +export function useAgentEvents(waitForWorkspaceRestore?: () => Promise): void { useEffect(() => { let disposed = false; + let startupRecoveryReady = false; + let reconcileAfterStartupRecovery = false; let lastReconcileAt = 0; const reconcileRunningRuns = async (force = false) => { - if (disposed) return; + if (disposed || !startupRecoveryReady) return; const now = Date.now(); if (!force && now - lastReconcileAt < 1000) return; lastReconcileAt = now; @@ -105,18 +117,29 @@ export function useAgentEvents(): void { // Listener readiness is a recovery boundary. Force a snapshot even if // the normal focus/visibility throttle ran recently, because stream_end // may have been missed while Tauri registration was unavailable. + if (!startupRecoveryReady) { + reconcileAfterStartupRecovery = true; + return; + } void reconcileRunningRuns(true).catch((err) => { console.warn('[useAgentEvents] post-listen reconciliation failed:', err); }); }); - (async () => { - try { - await reconcileRunningRuns(); - } catch (err) { - console.warn('[useAgentEvents] agent_running_threads failed:', err); - } - })(); + void runStartupStage3Task('agent-session-recovery', async () => { + await waitForWorkspaceRestore?.(); + await reconcileAgentRunsAndRefreshEndedHistory(); + }) + .then(() => { + if (disposed) return; + startupRecoveryReady = true; + if (reconcileAfterStartupRecovery) { + reconcileAfterStartupRecovery = false; + void reconcileRunningRuns(true).catch((err) => { + console.warn('[useAgentEvents] deferred post-listen reconciliation failed:', err); + }); + } + }); const handleVisibilityChange = () => { if (document.visibilityState !== 'visible') return; @@ -138,5 +161,5 @@ export function useAgentEvents(): void { window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, []); + }, [waitForWorkspaceRestore]); } diff --git a/app/flowix-web/features/agent/hooks/use-new-conversation-agent-selection.ts b/app/flowix-web/features/agent/hooks/use-new-conversation-agent-selection.ts new file mode 100644 index 000000000..a0e50f0ba --- /dev/null +++ b/app/flowix-web/features/agent/hooks/use-new-conversation-agent-selection.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; +import { useAgentVisibilityPreferences } from '@features/preferences/public/runtime-api'; +import { getNewConversationAgentSelection } from '@features/agent/runtime/new-conversation-agent-selection'; + +export function useNewConversationAgentSelection() { + const statusByType = useAgentRuntimeStore((state) => state.statusByType); + const isChecking = useAgentRuntimeStore((state) => state.isChecking); + const { enabledByType } = useAgentVisibilityPreferences(); + + return useMemo( + () => getNewConversationAgentSelection( + statusByType, + isChecking, + (typeKey) => enabledByType[typeKey] ?? true, + ), + [enabledByType, isChecking, statusByType], + ); +} diff --git a/app/flowix-web/features/agent/message/agent-message.ts b/app/flowix-web/features/agent/message/agent-message.ts index ba2f9df07..230f2c4db 100644 --- a/app/flowix-web/features/agent/message/agent-message.ts +++ b/app/flowix-web/features/agent/message/agent-message.ts @@ -8,6 +8,7 @@ import { formatDeepSeekHarnessReconnectError, isDeepSeekHarnessReconnectError, } from "@features/agent/message/error-format"; +import { cloudModelAccessErrorMessage } from "@platform/tauri/errors"; import { translate, type AppLanguage, type I18nKey } from "@/lib/i18n"; import { getAgentToolInputSummary as getFallbackAgentToolInputSummary } from "@features/agent/tool-display"; @@ -105,7 +106,7 @@ export function getAgentMessageVisibleContent( if (isDeepSeekHarnessReconnectError(message)) { return formatDeepSeekHarnessReconnectError(message, language); } - return formatAgentErrorMessage( + const formattedError = formatAgentErrorMessage( formatExternalAgentErrorMessage( message.content || "", message.errorDetails, @@ -114,6 +115,10 @@ export function getAgentMessageVisibleContent( message.errorDetails?.source === "dsh-history", ), ); + return cloudModelAccessErrorMessage( + formattedError, + (key, params) => translate(language, key, params), + ); } return message.content || ""; diff --git a/app/flowix-web/features/agent/message/format.ts b/app/flowix-web/features/agent/message/format.ts index 865fd21fb..8a5534c5a 100644 --- a/app/flowix-web/features/agent/message/format.ts +++ b/app/flowix-web/features/agent/message/format.ts @@ -14,11 +14,10 @@ export function truncateStart(path: string, maxChars: number = 20): string { /** * Extracts a compact file name for tool message summaries. - * Removes the memo id suffix in the #xxxxxx format and then the extension. + * Removes the file extension from a path's final component. */ export function extractFileName(path: string): string { - // v3 改造: filename 已经是磁盘文件名 (xxx.md), 不再带 #memoid 后缀, - // 因此 memoIdPattern 分支移除 ── 直接按扩展名规则剥掉 .md / .txt 等。 + // Strip the extension from the on-disk filename. const fileName = path.split("/").pop() ?? path.split("\\").pop() ?? path; const lastDot = fileName.lastIndexOf("."); diff --git a/app/flowix-web/features/agent/message/tools.test.ts b/app/flowix-web/features/agent/message/tools.test.ts index 9f614b0c5..b9aec7d2a 100644 --- a/app/flowix-web/features/agent/message/tools.test.ts +++ b/app/flowix-web/features/agent/message/tools.test.ts @@ -99,7 +99,7 @@ describe("DeepSeek Harness tool metadata", () => { ["cordis_inspect_self", "Cordis 插件", TOOL_ICON_PATHS.plug], ["todo_write", "计划", TOOL_ICON_PATHS.checks], ] as const)("maps %s to label and icon via wildcard", (toolName, label, iconPath) => { - const lookup = { agentType: "deepseek-harness" as const, toolName }; + const lookup = { agentType: "dsh" as const, toolName }; expect(getToolLabel(lookup, "zh-CN")).toBe(label); expect(getToolIconPath(lookup)).toBe(iconPath); }); diff --git a/app/flowix-web/features/agent/public/app-api.ts b/app/flowix-web/features/agent/public/app-api.ts index d9936ebda..865ec406a 100644 --- a/app/flowix-web/features/agent/public/app-api.ts +++ b/app/flowix-web/features/agent/public/app-api.ts @@ -1,3 +1,4 @@ +import { useAgentDataStore } from "@features/agent/store/agent-data-store"; import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; export { invalidateDshModelConfigs } from '@features/agent/store/dsh-model-config-store'; @@ -8,3 +9,7 @@ export function useAppAgentRuntimeViewModel() { const refreshAgentRuntime = useAgentRuntimeStore((state) => state.refresh); return { refreshAgentRuntime }; } + +export function useAgentPersistenceError(): string | null { + return useAgentDataStore((state) => state.persistenceError); +} diff --git a/app/flowix-web/features/agent/public/dsh-onboarding-api.ts b/app/flowix-web/features/agent/public/dsh-onboarding-api.ts deleted file mode 100644 index 61ef92e7e..000000000 --- a/app/flowix-web/features/agent/public/dsh-onboarding-api.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { agent } from '@platform/tauri/client'; - -export { AgentIcon } from '@features/agent/components/agent-icon'; - -export function getLocalAgentRuntimeStatus() { - return agent.runtimeStatus(); -} - diff --git a/app/flowix-web/features/agent/public/runtime-status-api.ts b/app/flowix-web/features/agent/public/runtime-status-api.ts index 530c0540f..6b4aefc0e 100644 --- a/app/flowix-web/features/agent/public/runtime-status-api.ts +++ b/app/flowix-web/features/agent/public/runtime-status-api.ts @@ -1,3 +1,6 @@ +import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; +import type { AgentRuntimeSnapshot, AgentTypeKey } from '@/types/agent'; + export { isAgentRuntimeInstalledState, normalizeAgentRuntimeStatus, @@ -6,3 +9,32 @@ export { type AgentRuntimeStatusView, type AgentRuntimeStatusViewByType, } from '@features/agent/runtime/agent-runtime-status'; +export { getAvailableAgentTypes } from '@features/agent/runtime/agent-availability'; + +export interface AgentRuntimeStatusSnapshot { + statusByType: Partial>; + isChecking: boolean; + /** True after at least one runtime status check has succeeded. */ + hasChecked: boolean; +} + +export function getAgentRuntimeStatusSnapshot(): AgentRuntimeStatusSnapshot { + const { statusByType, isChecking, lastCheckedAt } = useAgentRuntimeStore.getState(); + return { statusByType, isChecking, hasChecked: lastCheckedAt !== null }; +} + +/** Notify consumers when reported status or check progress changes. */ +export function subscribeAgentRuntimeStatus(listener: () => void): () => void { + return useAgentRuntimeStore.subscribe((state, previous) => { + if (state.statusByType !== previous.statusByType + || state.isChecking !== previous.isChecking + || state.lastCheckedAt !== previous.lastCheckedAt) { + listener(); + } + }); +} + +/** Start a background status refresh only when the cached report is stale. */ +export function refreshAgentRuntimeStatusIfStale(): Promise { + return useAgentRuntimeStore.getState().refreshIfStale(); +} diff --git a/app/flowix-web/features/agent/public/shell-api.ts b/app/flowix-web/features/agent/public/shell-api.ts index 93085ec29..539cba0e3 100644 --- a/app/flowix-web/features/agent/public/shell-api.ts +++ b/app/flowix-web/features/agent/public/shell-api.ts @@ -4,78 +4,17 @@ export { AgentConversationStatusBar } from '@features/agent/components/agent-con export { AgentIcon } from '@features/agent/components/agent-icon'; export { AgentTasksSection } from '@features/agent/components/agent-tasks-section'; -import { buildInitialInstanceRuntimeConfig } from '@features/agent/store/initial-runtime-config'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; -import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; -import { useNoteStore } from '@features/memo/store/note-store'; -import type { AgentTypeKey } from '@/types/agent'; export { isAgentRuntimeInstalledState, normalizeAgentRuntimeStatus } from '@features/agent/runtime/agent-runtime-status'; +export { + createAndOpenAgentConversation, + createAndOpenPiConversation, + createBrowserColumnAgentConversation, + requestNewConversationAgentSelection, +} from '@features/agent/use-cases/new-conversation'; export function useBrowserColumnAgentHeaderModel() { - const statusByType = useAgentRuntimeStore((state) => state.statusByType); - const isChecking = useAgentRuntimeStore((state) => state.isChecking); - const refreshIfStale = useAgentRuntimeStore((state) => state.refreshIfStale); const conversationInstances = useAgentSessionStore((state) => state.conversationRegistry.instances); - return { statusByType, isChecking, refreshIfStale, conversationInstances }; -} - -export function createBrowserColumnAgentConversation(typeKey: AgentTypeKey, notebookId: string): string { - return useAgentSessionStore.getState().createInstance({ - agentType: typeKey, - title: '', - threadId: null, - source: { kind: 'dedicated', notebookId, documentPath: null }, - runtimeConfig: buildInitialInstanceRuntimeConfig(typeKey), - }).instanceId; -} - -export function createAndOpenAgentConversation(typeKey: AgentTypeKey, notebookId: string): void { - const instance = useAgentSessionStore.getState().createInstance({ - agentType: typeKey, - title: '', - threadId: null, - source: { - kind: 'dedicated', - notebookId, - documentPath: null, - }, - runtimeConfig: buildInitialInstanceRuntimeConfig(typeKey), - }); - void selectAndOpenAgentConversation(instance.instanceId, { blankIfMissing: true }); -} - -/** Create and open a blank, notebook-scoped DSH conversation. */ -export function createAndOpenDshConversation(): void { - const notebookId = useNoteStore.getState().selectedNotebook?.id ?? null; - const instance = useAgentSessionStore.getState().createInstance({ - agentType: 'deepseek-harness', - title: '', - threadId: null, - source: { - kind: 'dedicated', - notebookId, - documentPath: null, - }, - runtimeConfig: buildInitialInstanceRuntimeConfig('deepseek-harness'), - }); - void selectAndOpenAgentConversation(instance.instanceId, { blankIfMissing: true }); -} - -/** Create and open a blank, notebook-scoped Pi conversation. */ -export function createAndOpenPiConversation(): void { - const notebookId = useNoteStore.getState().selectedNotebook?.id ?? null; - const instance = useAgentSessionStore.getState().createInstance({ - agentType: 'pi', - title: '', - threadId: null, - source: { - kind: 'dedicated', - notebookId, - documentPath: null, - }, - runtimeConfig: buildInitialInstanceRuntimeConfig('pi'), - }); - void selectAndOpenAgentConversation(instance.instanceId, { blankIfMissing: true }); + return { conversationInstances }; } diff --git a/app/flowix-web/features/agent/public/workspace-api.ts b/app/flowix-web/features/agent/public/workspace-api.ts index 12e5d3dad..8aa352850 100644 --- a/app/flowix-web/features/agent/public/workspace-api.ts +++ b/app/flowix-web/features/agent/public/workspace-api.ts @@ -1,7 +1,5 @@ -import { resolveNotebookAgentFiles } from '@/lib/agent-access-defaults'; -import { useAgentAccessStore } from '@features/agent/store/agent-access-store'; +import { useAgentWorkspaceStore } from '@features/agent/store/agent-workspace-store'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; -import { normalizeWorkspacePath } from '@features/agent/runtime/workspace-path'; export interface WorkspaceAgentRepository { path: string; @@ -21,12 +19,10 @@ export function hasWorkspaceAgentConversation(instanceId: string): boolean { /** Resolve resource roots without exposing Agent access configuration stores. */ export function getWorkspaceAgentResourceFolders(notebookId: string | null): string[] { - const access = useAgentAccessStore.getState(); - return resolveNotebookAgentFiles( - access.config, - access.notebookConfigs, - notebookId, - )?.folders ?? []; + if (!notebookId) return []; + return useAgentWorkspaceStore.getState().workspace.byNotebookId[notebookId] + ?.addDirs.filter((directory) => directory.enabled && !directory.missing) + .map((directory) => directory.path) ?? []; } /** Resolve the same notebook-scoped repository list used by Agent access settings. */ @@ -34,29 +30,12 @@ export function getWorkspaceAgentRepositories( notebookId: string | null, ): WorkspaceAgentRepository[] { if (!notebookId) return []; - const access = useAgentAccessStore.getState(); - const paths = resolveNotebookAgentFiles( - access.config, - access.notebookConfigs, - notebookId, - )?.folders ?? []; - const localDirectories = access.notebookConfigs[notebookId]?.addDirs ?? []; - return paths.map((path) => { - const key = normalizeWorkspacePath(path).toLowerCase(); - const local = localDirectories.find( - (item) => normalizeWorkspacePath(item.path).toLowerCase() === key, - ); - const entry = access.config.entries.find( - (item) => normalizeWorkspacePath(item.path).toLowerCase() === key, - ); - const trimmed = path.replace(/[\\/]+$/, ''); - return { - path, - name: local?.label?.trim() - || entry?.name?.trim() - || trimmed.split(/[\\/]/).pop() - || trimmed, - missing: entry ? entry.missing === true : true, - }; - }); + const notebook = useAgentWorkspaceStore.getState().workspace.byNotebookId[notebookId]; + return (notebook?.addDirs ?? []) + .filter((directory) => directory.enabled && !directory.missing) + .map((directory) => ({ + path: directory.path, + name: directory.label, + missing: directory.missing, + })); } diff --git a/app/flowix-web/features/agent/runtime/agent-availability.ts b/app/flowix-web/features/agent/runtime/agent-availability.ts new file mode 100644 index 000000000..99c89228b --- /dev/null +++ b/app/flowix-web/features/agent/runtime/agent-availability.ts @@ -0,0 +1,18 @@ +import type { AgentRuntimeSnapshot, AgentType, AgentTypeKey } from '@/types/agent'; +import { AGENT_TYPES, isAgentTypeSelectable } from '@/lib/agent-types'; +import { normalizeAgentRuntimeStatus } from './agent-runtime-status'; + +type RuntimeStatusByType = Partial>; + +/** Agent choices shared by menus require a ready runtime and an enabled preference. */ +export function getAvailableAgentTypes( + statusByType: RuntimeStatusByType, + isChecking: boolean, + isEnabled: (typeKey: AgentTypeKey) => boolean, +): AgentType[] { + return AGENT_TYPES.filter((type) => ( + isAgentTypeSelectable(type.id) + && isEnabled(type.id) + && normalizeAgentRuntimeStatus(statusByType[type.id], isChecking).state === 'ready' + )); +} diff --git a/app/flowix-web/features/agent/runtime/agent-runtime-spec.test.ts b/app/flowix-web/features/agent/runtime/agent-runtime-spec.test.ts index 6bf5b2312..62903f401 100644 --- a/app/flowix-web/features/agent/runtime/agent-runtime-spec.test.ts +++ b/app/flowix-web/features/agent/runtime/agent-runtime-spec.test.ts @@ -25,8 +25,8 @@ describe("workspace capabilities", () => { expect(codex.switchBetweenRuns).toBe(false); expect(codex.switchRequiresRuntimeRestart).toBe(false); expect(codex.preservesConversationSession).toBe(true); - expect(getAgentRuntimeSpec("deepseek-harness").workspace.switchBetweenRuns).toBe(false); - for (const type of ["claude", "opencode"] as const) { + expect(getAgentRuntimeSpec("dsh").workspace.switchBetweenRuns).toBe(false); + for (const type of ["claudecode", "opencode"] as const) { expect(getAgentRuntimeSpec(type).workspace.switchBetweenRuns).toBe(false); } }); @@ -70,7 +70,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("完全没输入时返回 cwd=undefined / workspacePaths=[] (dispatch 据此判断是否拦截)", () => { const result = buildAgentRuntimeConfig({ - typeKey: "claude", + typeKey: "claudecode", permissionMode: "workspace-write", codexModel: "inherit", codexReasoningEffort: "low", @@ -81,7 +81,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("资料目录始终是 add-dir, cwd 固定为当前笔记本", () => { const result = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "D:\\当前笔记本", permissionMode: "workspace-write", codexModel: "inherit", @@ -101,7 +101,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("没有显式主空间时 cwd 仍固定为笔记本", () => { const result = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "D:\\当前笔记本", permissionMode: "workspace-write", codexModel: "inherit", @@ -118,7 +118,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("资料列表为空 (没添加资料), cwd 退到当前笔记本路径", () => { const result = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "D:\\当前笔记本", permissionMode: "workspace-write", codexModel: "inherit", @@ -131,7 +131,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("未传 defaultFiles 时, 仅当前笔记本作为 cwd", () => { const result = buildAgentRuntimeConfig({ - typeKey: "claude", + typeKey: "claudecode", notebookPath: "D:\\当前笔记本", permissionMode: "workspace-write", codexModel: "inherit", @@ -189,7 +189,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("DeepSeek Harness exposes model before mode and permission and sends the card overrides", () => { const result = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "/tmp/notebook", permissionMode: "workspace-write", codexModel: "inherit", @@ -203,7 +203,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 expect(result.deepseekHarness?.mode).toBe("code"); expect(result.deepseekHarness?.model).toBe("deepseek-v4-pro"); expect(result.deepseekHarness?.providerId).toBe("provider-b"); - expect(getAgentRuntimeSpec("deepseek-harness").emptySettings).toEqual([ + expect(getAgentRuntimeSpec("dsh").emptySettings).toEqual([ "model", "mode", "permission", @@ -212,7 +212,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("DeepSeek Harness inherit 时不序列化模型, 选定后透传卡片模型", () => { const inherited = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "/tmp/project", permissionMode: "workspace-write", codexModel: "inherit", @@ -221,7 +221,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 expect(inherited.deepseekHarness?.model).toBe("inherit"); const selected = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "/tmp/project", permissionMode: "workspace-write", codexModel: "deepseek-v4-pro", @@ -231,13 +231,13 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 }); it("DeepSeek Harness supports empty-card runtime settings for files (空状态设置区仍可用)", () => { - expect(supportsAgentEmptySettings("deepseek-harness")).toBe(true); + expect(supportsAgentEmptySettings("dsh")).toBe(true); expect(supportsAgentEmptySettings("codex")).toBe(true); - expect(supportsAgentEmptySettings("claude")).toBe(true); - expect(supportsAgentEmptySettings("deepseek-harness")).toBe(true); - expect(supportsAgentRuntimeSetting("deepseek-harness", "model")).toBe(true); - expect(supportsAgentRuntimeSetting("deepseek-harness", "mode")).toBe(true); - expect(supportsAgentRuntimeSetting("deepseek-harness", "permission")).toBe(true); + expect(supportsAgentEmptySettings("claudecode")).toBe(true); + expect(supportsAgentEmptySettings("dsh")).toBe(true); + expect(supportsAgentRuntimeSetting("dsh", "model")).toBe(true); + expect(supportsAgentRuntimeSetting("dsh", "mode")).toBe(true); + expect(supportsAgentRuntimeSetting("dsh", "permission")).toBe(true); }); it("omits yolo from Codex App Server access options", () => { @@ -246,7 +246,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 "workspace-write", "read-only", ]); - expect(getAgentAccessOptions("claude").map((option) => option.id)).not.toContain( + expect(getAgentAccessOptions("claudecode").map((option) => option.id)).not.toContain( "yolo", ); }); @@ -266,7 +266,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("migrates legacy Claude yolo selection to full access", () => { const result = buildAgentRuntimeConfig({ - typeKey: "claude", + typeKey: "claudecode", notebookPath: "/tmp/project", permissionMode: "yolo", codexModel: "inherit", @@ -294,7 +294,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 it("DeepSeek Harness 卡片显式选择的权限仍生效", () => { const explicit = buildAgentRuntimeConfig({ - typeKey: "deepseek-harness", + typeKey: "dsh", notebookPath: "/tmp/project", permissionMode: "workspace-write", codexModel: "inherit", @@ -307,7 +307,7 @@ describe("buildAgentRuntimeConfig — 「资料列表 + 当前笔记本」派生 }); it("DeepSeek Harness 权限选项不含 yolo, 默认完全访问", () => { - expect(getAgentAccessOptions("deepseek-harness").map((o) => o.id)).toEqual([ + expect(getAgentAccessOptions("dsh").map((o) => o.id)).toEqual([ "danger-full-access", "workspace-write", "read-only", diff --git a/app/flowix-web/features/agent/runtime/agent-runtime-spec.ts b/app/flowix-web/features/agent/runtime/agent-runtime-spec.ts index 8aa735591..aa8541205 100644 --- a/app/flowix-web/features/agent/runtime/agent-runtime-spec.ts +++ b/app/flowix-web/features/agent/runtime/agent-runtime-spec.ts @@ -4,6 +4,7 @@ import type { AgentCodexServiceTier, AgentPermissionMode, AgentRuntimeConfig, + AgentId, AgentTypeKey, FilesConfig, RuntimeConfig, @@ -16,6 +17,7 @@ import { } from "@features/agent/config/codex-options"; import { resolvePrimaryWorkspace } from "@features/agent/runtime/primary-workspace"; import { normalizeWorkspacePath } from "@features/agent/runtime/workspace-path"; +import { normalizeAgentId } from "@/lib/agent-types"; export type AgentRuntimeSettingKind = | "model" @@ -31,6 +33,25 @@ export interface AgentAccessOption { labelKey?: I18nKey; } +export interface AgentModeOption { + id: string; + label: string; + labelKey?: I18nKey; + descriptionKey?: I18nKey; +} + +export const DEEPSEEK_HARNESS_MODE_OPTIONS = [ + { id: "standard", label: "Standard", labelKey: "agent.mode.standard", descriptionKey: "agent.mode.standard.description" }, + { id: "code", label: "Code", labelKey: "agent.mode.code", descriptionKey: "agent.mode.code.description" }, + { id: "minimal", label: "Minimal", labelKey: "agent.mode.minimal", descriptionKey: "agent.mode.minimal.description" }, + { id: "cordis", label: "Cordis", labelKey: "agent.mode.cordis", descriptionKey: "agent.mode.cordis.description" }, +] as const satisfies readonly AgentModeOption[]; + +export const PI_MODE_OPTIONS = [ + { id: "writing", label: "Writing", labelKey: "agent.mode.writing" }, + { id: "code", label: "Code", labelKey: "agent.mode.piCode" }, +] as const satisfies readonly AgentModeOption[]; + export interface BuildAgentRuntimeConfigInput { typeKey: AgentTypeKey; /** 当前笔记本路径 (= systemReminderDirectory), always used as cwd. */ @@ -52,6 +73,7 @@ export interface AgentRuntimeSpec { typeKey: AgentTypeKey; emptySettings: readonly AgentRuntimeSettingKind[]; accessOptions: readonly AgentAccessOption[]; + modeOptions: readonly AgentModeOption[]; workspace: { selectBeforeFirstRun: boolean; /** Whether selection remains editable while the current run is active. */ @@ -82,8 +104,6 @@ const DSH_ACCESS_OPTIONS: readonly AgentAccessOption[] = [ const CLAUDE_ACCESS_OPTIONS = SHARED_ACCESS_OPTIONS; -const NO_ACCESS_OPTIONS: readonly AgentAccessOption[] = []; - export function normalizeCodexPermissionMode( mode: AgentPermissionMode | undefined, ): AgentPermissionMode { @@ -109,11 +129,12 @@ export function normalizeDshPermissionMode( : "danger-full-access"; } -const AGENT_RUNTIME_SPECS: Record = { +const AGENT_RUNTIME_SPECS: Record = { codex: { typeKey: "codex", emptySettings: ["model", "reasoning", "permission"], accessOptions: CODEX_APP_SERVER_ACCESS_OPTIONS, + modeOptions: [], // The notebook owns cwd; conversation snapshots freeze it at first send. workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: false, preservesConversationSession: true }, buildRuntimeConfig: ({ @@ -134,10 +155,11 @@ const AGENT_RUNTIME_SPECS: Record = { }, }), }, - claude: { - typeKey: "claude", + claudecode: { + typeKey: "claudecode", emptySettings: ["model", "permission"], accessOptions: CLAUDE_ACCESS_OPTIONS, + modeOptions: [], workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: false, preservesConversationSession: false }, buildRuntimeConfig: ({ cwd, workspacePaths, permissionMode, codexModel }) => ({ // 统一归一化: 持久化或历史数据中的 yolo 视作 danger-full-access, @@ -145,37 +167,21 @@ const AGENT_RUNTIME_SPECS: Record = { claude: { cwd, workspacePaths, permissionMode: normalizeCodexPermissionMode(permissionMode), model: codexModel }, }), }, - gemini: { - typeKey: "gemini", - emptySettings: [], - accessOptions: NO_ACCESS_OPTIONS, - workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: false, preservesConversationSession: false }, - buildRuntimeConfig: ({ cwd, workspacePaths }) => ({ - gemini: { cwd, workspacePaths }, - }), - }, hermes: { typeKey: "hermes", emptySettings: ["permission"], accessOptions: HERMES_ACCESS_OPTIONS, + modeOptions: [], workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: false, preservesConversationSession: false }, buildRuntimeConfig: ({ cwd, workspacePaths, permissionMode }) => ({ hermes: { cwd, workspacePaths, permissionMode }, }), }, - openclaw: { - typeKey: "openclaw", - emptySettings: [], - accessOptions: NO_ACCESS_OPTIONS, - workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: false, preservesConversationSession: false }, - buildRuntimeConfig: ({ cwd, workspacePaths }) => ({ - openclaw: { cwd, workspacePaths }, - }), - }, opencode: { typeKey: "opencode", emptySettings: ["model", "permission"], accessOptions: SHARED_ACCESS_OPTIONS, + modeOptions: [], workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: false, preservesConversationSession: false }, buildRuntimeConfig: ({ cwd, workspacePaths, permissionMode, codexModel }) => ({ opencode: { cwd, workspacePaths, permissionMode, model: codexModel }, @@ -188,6 +194,7 @@ const AGENT_RUNTIME_SPECS: Record = { // codemode follows the shared Pi preference across conversations. emptySettings: ["model", "mode", "permission"], accessOptions: SHARED_ACCESS_OPTIONS, + modeOptions: PI_MODE_OPTIONS, workspace: { selectBeforeFirstRun: false, switchWhileRunning: false, switchBetweenRuns: false, switchRequiresRuntimeRestart: true, preservesConversationSession: true }, buildRuntimeConfig: ({ cwd, workspacePaths, permissionMode, codexModel }) => ({ pi: { @@ -199,14 +206,15 @@ const AGENT_RUNTIME_SPECS: Record = { }, }), }, - "deepseek-harness": { - typeKey: "deepseek-harness", + dsh: { + typeKey: "dsh", // DSH follows the native llm-pi-ai provider configuration but allows a per-card // model override (runtime_config.deepseek_harness.model, "inherit" falls // back to the global dsh-settings model). Its tool Agent preset and // per-card permission mode are configurable here as well. emptySettings: ["model", "mode", "permission"], accessOptions: DSH_ACCESS_OPTIONS, + modeOptions: DEEPSEEK_HARNESS_MODE_OPTIONS, // Harness session resume restores the session's original cwd. Until DSH // exposes a resume-with-cwd capability, changing workspace would be // misleading, even if Flowix can restart the transport. @@ -229,7 +237,7 @@ const AGENT_RUNTIME_SPECS: Record = { }; export function getAgentRuntimeSpec(typeKey: AgentTypeKey): AgentRuntimeSpec { - return AGENT_RUNTIME_SPECS[typeKey]; + return AGENT_RUNTIME_SPECS[normalizeAgentId(typeKey) ?? "codex"]; } export function supportsAgentRuntimeSetting( @@ -252,6 +260,12 @@ export function getAgentAccessOptions( return getAgentRuntimeSpec(typeKey).accessOptions; } +export function getAgentModeOptions( + typeKey: AgentTypeKey, +): readonly AgentModeOption[] { + return getAgentRuntimeSpec(typeKey).modeOptions; +} + export function buildAgentRuntimeConfig({ typeKey, notebookPath, @@ -265,7 +279,7 @@ export function buildAgentRuntimeConfig({ workspaceSnapshot, }: BuildAgentRuntimeConfigInput): AgentRuntimeConfig { // cwd is always the selected notebook path. `defaultFiles.folders` are - // notebook-local add-dir roots from `.flowix/agent.json`; they are sent as + // notebook add-dir roots from local.db; they are sent as // workspacePaths and are kept separate from cwd. Once a run starts, the // persisted workspace snapshot remains authoritative for later turns. const frozenPaths = (workspaceSnapshot?.workspacePaths ?? []) @@ -301,7 +315,8 @@ export function buildAgentRuntimeConfig({ codexServiceTier: instanceRuntimeConfig?.serviceTier ?? codexServiceTier, }); - if (typeKey === "deepseek-harness" && runtimeConfig.deepseekHarness) { + runtimeConfig.notebookId = workspaceSnapshot?.notebookId ?? instanceRuntimeConfig?.notebookId; + if (normalizeAgentId(typeKey) === "dsh" && runtimeConfig.deepseekHarness) { runtimeConfig.deepseekHarness.mode = instanceRuntimeConfig?.deepseekHarness?.mode ?? "standard"; runtimeConfig.deepseekHarness.providerId = diff --git a/app/flowix-web/features/agent/runtime/agent-runtime-status.test.ts b/app/flowix-web/features/agent/runtime/agent-runtime-status.test.ts index 3843ec3e6..c8da19118 100644 --- a/app/flowix-web/features/agent/runtime/agent-runtime-status.test.ts +++ b/app/flowix-web/features/agent/runtime/agent-runtime-status.test.ts @@ -17,10 +17,10 @@ describe('normalizeAgentRuntimeStatus', () => { }); }); - it('maps an unavailable runtime without installation to not-installed', () => { + it('maps a missing installation to not-installed', () => { expect(normalizeAgentRuntimeStatus({ - available: false, - installed: false, + installation: 'missing', + readiness: 'unknown', reason: 'CLI not configured', })).toEqual({ state: 'not-installed', @@ -30,8 +30,8 @@ describe('normalizeAgentRuntimeStatus', () => { it('keeps installed-but-unready distinct from not-installed', () => { expect(normalizeAgentRuntimeStatus({ - available: false, - installed: true, + installation: 'installed', + readiness: 'blocked', reason: 'No model configured', })).toEqual({ state: 'not-ready', @@ -39,26 +39,30 @@ describe('normalizeAgentRuntimeStatus', () => { }); }); - it('supports legacy payloads that only contain available', () => { - expect(normalizeAgentRuntimeStatus({ available: true })).toEqual({ - state: 'ready', - reason: null, - }); - expect(normalizeAgentRuntimeStatus({ available: false })).toEqual({ - state: 'not-installed', - reason: null, + it('keeps unknown detection separate from a missing installation', () => { + expect(normalizeAgentRuntimeStatus({ + installation: 'unknown', + readiness: 'unknown', + reason: 'Detection pending', + })).toEqual({ + state: 'unknown', + reason: 'Detection pending', }); + expect(normalizeAgentRuntimeStatus({ + installation: 'installed', + readiness: 'ready', + })).toEqual({ state: 'ready', reason: null }); }); }); describe('normalizeAgentRuntimeStatusByType', () => { it('normalizes the known entries without inventing missing entries', () => { expect(normalizeAgentRuntimeStatusByType({ - codex: { available: true, installed: true }, - claude: { available: false, installed: true, reason: 'Needs setup' }, + codex: { installation: 'installed', readiness: 'ready' }, + claudecode: { installation: 'installed', readiness: 'blocked', reason: 'Needs setup' }, })).toEqual({ codex: { state: 'ready', reason: null }, - claude: { state: 'not-ready', reason: 'Needs setup' }, + claudecode: { state: 'not-ready', reason: 'Needs setup' }, }); }); }); diff --git a/app/flowix-web/features/agent/runtime/agent-runtime-status.ts b/app/flowix-web/features/agent/runtime/agent-runtime-status.ts index 7c1792a8e..76c7d15e4 100644 --- a/app/flowix-web/features/agent/runtime/agent-runtime-status.ts +++ b/app/flowix-web/features/agent/runtime/agent-runtime-status.ts @@ -1,13 +1,10 @@ -import type { AgentRuntimeAvailability } from '@platform/tauri/client'; -import type { AgentTypeKey } from '@/types/agent'; +import type { AgentTypeKey, AgentRuntimeSnapshot } from '@/types/agent'; /** * Small, UI-facing state model for one local Agent runtime. * - * `AgentRuntimeAvailability` is the IPC shape and intentionally stays close - * to the backend. Consumers should eventually depend on this normalized state - * instead of reconstructing meaning from `available`, `installed`, and - * free-form `reason` independently. + * Runtime status is split into installation presence and launch readiness. + * UI state is derived here so consumers don't reinterpret those facts. */ export type AgentRuntimeState = | 'unknown' @@ -27,14 +24,9 @@ export function isAgentRuntimeInstalledState(status: AgentRuntimeStatusView): bo return status.state === 'ready' || status.state === 'not-ready'; } -/** - * Convert the backend availability shape into the shared UI vocabulary. - * - * The optional `installed` field keeps this compatible with older IPC payloads: - * before the field exists, `available` is the best available installation hint. - */ +/** Convert the backend installation/readiness protocol into shared UI states. */ export function normalizeAgentRuntimeStatus( - status: AgentRuntimeAvailability | undefined, + status: AgentRuntimeSnapshot | undefined, isChecking = false, ): AgentRuntimeStatusView { if (!status) { @@ -44,19 +36,22 @@ export function normalizeAgentRuntimeStatus( }; } - const installed = status.installed ?? status.available; + if (status.installation === 'missing') { + return { state: 'not-installed', reason: status.reason ?? null }; + } + + if (status.installation === 'unknown' || status.readiness === 'unknown') { + return { state: isChecking ? 'checking' : 'unknown', reason: status.reason ?? null }; + } + return { - state: !installed - ? 'not-installed' - : status.available - ? 'ready' - : 'not-ready', + state: status.readiness === 'ready' ? 'ready' : 'not-ready', reason: status.reason ?? null, }; } export function normalizeAgentRuntimeStatusByType( - statusByType: Partial>, + statusByType: Partial>, isChecking = false, ): AgentRuntimeStatusViewByType { return Object.fromEntries( diff --git a/app/flowix-web/features/agent/runtime/new-conversation-agent-selection.ts b/app/flowix-web/features/agent/runtime/new-conversation-agent-selection.ts new file mode 100644 index 000000000..9d530f473 --- /dev/null +++ b/app/flowix-web/features/agent/runtime/new-conversation-agent-selection.ts @@ -0,0 +1,29 @@ +import type { AgentRuntimeSnapshot, AgentType, AgentTypeKey } from '@/types/agent'; +import { getAgentType } from '@/lib/agent-types'; +import { getAvailableAgentTypes } from './agent-availability'; + +type RuntimeStatusByType = Partial>; + +export type NewConversationAgentSelection = + | { kind: 'create'; agentType: AgentTypeKey; options: AgentType[] } + | { kind: 'choose'; options: AgentType[] } + | { kind: 'none'; options: [] }; + +/** Prefer ready, enabled runtimes; use Pi as a fallback unless the user disabled it. */ +export function getNewConversationAgentSelection( + statusByType: RuntimeStatusByType, + isChecking: boolean, + isEnabled: (typeKey: AgentTypeKey) => boolean, +): NewConversationAgentSelection { + const availableAgents = getAvailableAgentTypes(statusByType, isChecking, isEnabled); + const options = availableAgents.length > 0 + ? availableAgents + : isEnabled('pi') ? [getAgentType('pi')] : []; + + if (options.length === 0) return { kind: 'none', options: [] }; + + if (options.length === 1) { + return { kind: 'create', agentType: options[0]!.id, options }; + } + return { kind: 'choose', options }; +} diff --git a/app/flowix-web/features/agent/runtime/pi-history.ts b/app/flowix-web/features/agent/runtime/pi-history.ts index abb36809d..0acf9f93c 100644 --- a/app/flowix-web/features/agent/runtime/pi-history.ts +++ b/app/flowix-web/features/agent/runtime/pi-history.ts @@ -30,6 +30,22 @@ function toolResultText(message: JsonRecord): string { return message.isError === true ? `[error] ${text || "Tool failed"}` : text; } +function codemodeChildCallIds(messages: JsonRecord[]): Set { + const ids = new Set(); + for (const message of messages) { + if (message.role !== "toolResult" || message.toolName !== "codemode") continue; + for (const container of [record(message.nestedCalls), record(message.details)]) { + const calls = container?.calls; + if (!Array.isArray(calls)) continue; + for (const value of calls) { + const id = record(value)?.id; + if (typeof id === "string" && id) ids.add(id); + } + } + } + return ids; +} + function bashExecutionText(message: JsonRecord): string { const output = typeof message.output === "string" ? message.output : ""; const exitCode = typeof message.exitCode === "number" ? message.exitCode : null; @@ -59,6 +75,7 @@ export function parsePiHistoryMessages( if (!Array.isArray(values)) return []; const native = values.map(record).filter((value): value is JsonRecord => value !== null); const resultsByCallId = new Map(); + const nestedCodemodeCallIds = codemodeChildCallIds(native); for (const message of native) { if (message.role === "toolResult" && typeof message.toolCallId === "string") { resultsByCallId.set(message.toolCallId, message); @@ -118,7 +135,11 @@ export function parsePiHistoryMessages( rows.push(textRow); } else if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking) { rows.push({ order: blockIndex, type: "thinking", content: block.thinking }); - } else if (block.type === "toolCall" && typeof block.id === "string") { + } else if ( + block.type === "toolCall" && + typeof block.id === "string" && + !nestedCodemodeCallIds.has(block.id) + ) { rows.push({ order: blockIndex, type: "tool", block }); } }); diff --git a/app/flowix-web/features/agent/runtime/primary-workspace.ts b/app/flowix-web/features/agent/runtime/primary-workspace.ts index da4a3f1fb..e85dd469d 100644 --- a/app/flowix-web/features/agent/runtime/primary-workspace.ts +++ b/app/flowix-web/features/agent/runtime/primary-workspace.ts @@ -1,6 +1,6 @@ /** * Resolve the sole primary workspace. A notebook is always the agent cwd; - * folders from `.flowix/agent.json` are add-dir roots and never participate + * folders from `local.db` are add-dir roots and never participate * in cwd selection. The `defaultFiles` argument remains for API compatibility * with persisted/legacy callers, but is intentionally ignored here. */ diff --git a/app/flowix-web/features/agent/runtime/workspace-snapshot.test.ts b/app/flowix-web/features/agent/runtime/workspace-snapshot.test.ts index f41242b93..7ff41d2c2 100644 --- a/app/flowix-web/features/agent/runtime/workspace-snapshot.test.ts +++ b/app/flowix-web/features/agent/runtime/workspace-snapshot.test.ts @@ -63,9 +63,25 @@ vi.mock("@features/memo/store/note-store", () => ({ }, })); -vi.mock("@features/agent/store/agent-access-store", () => ({ - useAgentAccessStore: { getState: () => ({ config: state.accessConfig }) }, -})); +vi.mock("@features/agent/store/agent-workspace-store", async (importOriginal) => { + const actual = await importOriginal(); + const getState = () => ({ + ...actual.useAgentWorkspaceStore.getState(), + workspaceActions: { + ...actual.useAgentWorkspaceStore.getState().workspaceActions, + getNotebookFiles: () => ({ + workspace: null, + folders: state.accessConfig.defaults.files["nb-1"].folders, + notebooks: [], + }), + }, + }); + return { ...actual, useAgentWorkspaceStore: Object.assign( + (selector: Parameters[0]) => selector ? selector(getState()) : getState(), + actual.useAgentWorkspaceStore, + { getState }, + ) }; +}); describe("conversation workspace snapshot", () => { beforeEach(() => { diff --git a/app/flowix-web/features/agent/runtime/workspace-snapshot.ts b/app/flowix-web/features/agent/runtime/workspace-snapshot.ts index c43e1a39f..96fed679e 100644 --- a/app/flowix-web/features/agent/runtime/workspace-snapshot.ts +++ b/app/flowix-web/features/agent/runtime/workspace-snapshot.ts @@ -1,8 +1,7 @@ +import { useAgentWorkspaceStore } from "@features/agent/store/agent-workspace-store"; import type { RuntimeConfig, WorkspaceSnapshot } from "@/types/agent"; -import { resolveNotebookAgentFiles } from "@/lib/agent-access-defaults"; import { resolvePrimaryWorkspace } from "@features/agent/runtime/primary-workspace"; import { normalizeWorkspacePath } from "@features/agent/runtime/workspace-path"; -import { useAgentAccessStore } from "@features/agent/store/agent-access-store"; import { useAgentSessionStore } from "@features/agent/store/agent-session-store"; import { useNoteStore } from "@features/memo/store/note-store"; import { @@ -146,12 +145,7 @@ export function ensureConversationWorkspaceSnapshot( let snapshot = migrateLegacyWorkspace(runtimeConfig, notebookId, notebookPath); if (!snapshot) { - const accessState = useAgentAccessStore.getState(); - const defaultFiles = resolveNotebookAgentFiles( - accessState.config, - accessState.notebookConfigs, - notebookId, - ); + const defaultFiles = useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles(notebookId); const primary = resolvePrimaryWorkspace({ defaultFiles, notebookPath }); if (primary.kind === "empty") { // Do not permanently freeze an empty startup/hydration race. diff --git a/app/flowix-web/features/agent/services/agent-catalog-service.ts b/app/flowix-web/features/agent/services/agent-catalog-service.ts new file mode 100644 index 000000000..ce99e7c37 --- /dev/null +++ b/app/flowix-web/features/agent/services/agent-catalog-service.ts @@ -0,0 +1,186 @@ +import type { AgentCodexReasoningEffort, AgentTypeKey } from "@/types/agent"; +import { getFallbackAgentModelOptions } from "@features/agent/config/agent-model-options"; +import { formatModelDisplayLabel } from "@features/agent/config/codex-options"; +import { listCodexSkillCatalog } from "@features/agent/services/codex-slash-command-service"; +import { listPiSkills } from "@features/agent/services/pi-slash-command-service"; +import { + type AgentCatalogModel, + type AgentCatalogSkill, + type AgentModelCatalog, + useAgentDataStore, +} from "@features/agent/store/agent-data-store"; +import { agent, deepseekHarness, piModels } from "@platform/tauri/client"; + +const SUPPORTED_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"]; + +/** Loads a provider catalog through the shared persistent cache. */ +export function loadAgentModelCatalog( + agentType: AgentTypeKey, + options: { force?: boolean } = {}, +): Promise { + const store = useAgentDataStore.getState(); + return store.loadModelCatalog( + agentType, + () => fetchAgentModelCatalog(agentType), + { force: options.force }, + ).catch((error) => { + const cached = useAgentDataStore.getState().agents[agentType].catalog.models; + if (cached.items.length) return cached; + const fallback = getStaticModelCatalog(agentType); + if (!fallback.items.length) throw error; + store.setModelCatalog(agentType, fallback); + return fallback; + }); +} + +/** Start catalog refreshes after runtime discovery without blocking the UI. */ +export function prefetchAvailableAgentModels( + available: Partial>, +): void { + for (const [agentType, isAvailable] of Object.entries(available) as [AgentTypeKey, boolean][]) { + if (!isAvailable) continue; + void loadAgentModelCatalog(agentType).catch((error) => { + console.warn(`[agent-catalog] failed to load ${agentType} models`, error); + }); + } +} + +/** Warm project skills for notebook-scoped Agent adapters that need no session. */ +export function prefetchNotebookSkills( + agentType: "codex" | "pi", + notebookId: string, + notebookPath: string, +): void { + void loadNotebookSkillCatalog(agentType, notebookId, notebookPath).catch((error) => { + console.warn(`[agent-catalog] failed to load ${agentType} skills`, error); + }); +} + +/** Load a notebook-scoped skill catalog through the shared persistent cache. */ +export function loadNotebookSkillCatalog( + agentType: "codex" | "pi", + notebookId: string, + notebookPath: string, + options: { force?: boolean } = {}, +): Promise { + const path = notebookPath.trim(); + if (!path) return Promise.resolve([]); + const loader = agentType === "codex" + ? () => listCodexSkillCatalog(path) + : () => listPiSkills(path); + return useAgentDataStore.getState().loadSkillCatalog( + agentType, + notebookId, + async () => [...await loader()] as AgentCatalogSkill[], + { force: options.force }, + ); +} + +export function invalidateAgentModelCatalog(agentType: AgentTypeKey): void { + useAgentDataStore.getState().invalidateModelCatalog(agentType); +} + +/** Invalidate and immediately refresh a model catalog after its config changes. */ +export function refreshAgentModelCatalog(agentType: AgentTypeKey): Promise { + invalidateAgentModelCatalog(agentType); + return loadAgentModelCatalog(agentType, { force: true }); +} + +export function invalidateNotebookSkills(agentType: AgentTypeKey, notebookId: string): void { + useAgentDataStore.getState().invalidateSkillCatalog(agentType, notebookId); +} + +async function fetchAgentModelCatalog(agentType: AgentTypeKey): Promise { + if (agentType === "pi") { + const [providers, models] = await Promise.all([piModels.list(), agent.listSupportedModels("pi")]); + const defaultProvider = providers.find((provider) => provider.defaultModelId); + const providerById = new Map(providers.map((provider) => [provider.id, provider])); + const seen = new Set(); + const items = models.flatMap((model): AgentCatalogModel[] => { + const separator = model.indexOf("::"); + if (separator < 1) return []; + const providerId = model.slice(0, separator); + const id = model.slice(separator + 2); + const key = `${providerId}\u0000${id}`; + if (!id || seen.has(key)) return []; + seen.add(key); + const provider = providerById.get(providerId); + const modelConfig = provider?.models.find((entry) => entry.id === id); + return [{ + id, + label: modelConfig?.name?.trim() || formatModelDisplayLabel(id), + ...(modelConfig?.subtitle?.trim() ? { subtitle: modelConfig.subtitle.trim() } : {}), + ...(modelConfig?.creditMultiplier != null ? { creditMultiplier: modelConfig.creditMultiplier } : {}), + ...(modelConfig?.providerIconUrl || provider?.iconUrl + ? { iconUrl: modelConfig?.providerIconUrl || provider?.iconUrl } + : {}), + providerId, + providerName: provider?.displayName ?? providerId, + }]; + }); + return { + items, + defaultModel: defaultProvider?.defaultModelId ?? "", + defaultProviderId: defaultProvider?.id, + }; + } + + if (agentType === "dsh") { + const configs = await deepseekHarness.list(); + const preference = useAgentDataStore.getState().agents[agentType].user.model; + const firstConfig = configs[0]?.model; + const defaultModel = preference?.id && preference.id !== "inherit" + ? preference + : { id: firstConfig?.model.trim() ?? "", providerId: firstConfig?.providerId?.trim() || undefined }; + const seen = new Set(); + const items = configs.flatMap((file): AgentCatalogModel[] => { + const config = file.model; + const providerId = config.providerId?.trim() || undefined; + const providerName = config.displayName?.trim() || config.provider; + const models = config.models?.length + ? config.models + : config.model.trim() ? [{ id: config.model, name: "" }] : []; + return models + .map((model) => ({ id: model.id.trim(), label: formatModelDisplayLabel(model.id), providerId, providerName })) + .filter((model) => model.id.length > 0) + .filter((model) => { + const key = `${model.providerId ?? model.providerName}\u0000${model.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }); + return { items, defaultModel: defaultModel.id, defaultProviderId: defaultModel.providerId }; + } + + if (agentType === "codex") { + const [catalog, defaultModel] = await Promise.all([ + agent.listCodexModelCatalog(), + agent.getCodexDefaultModel().catch(() => ""), + ]); + const items = catalog.map((model) => ({ + id: model.id, + label: formatModelDisplayLabel(model.id), + ...(model.reasoningEfforts?.length + ? { reasoningEfforts: model.reasoningEfforts.filter((effort): effort is AgentCodexReasoningEffort => + SUPPORTED_REASONING_EFFORTS.includes(effort), + ) } + : {}), + })); + return { + items: items.length ? items : getStaticModelCatalog("codex").items, + defaultModel: defaultModel.trim(), + }; + } + + const models = await agent.listSupportedModels(agentType); + const items = [...new Set(models.map((model) => model.trim()).filter(Boolean))] + .map((id) => ({ id, label: formatModelDisplayLabel(id) })); + return { + items: items.length ? items : getStaticModelCatalog(agentType).items, + }; +} + +function getStaticModelCatalog(agentType: AgentTypeKey): AgentModelCatalog { + return { items: getFallbackAgentModelOptions(agentType) }; +} diff --git a/app/flowix-web/features/agent/services/codex-slash-command-service.test.ts b/app/flowix-web/features/agent/services/codex-slash-command-service.test.ts index 5cc06833a..a7d4adbef 100644 --- a/app/flowix-web/features/agent/services/codex-slash-command-service.test.ts +++ b/app/flowix-web/features/agent/services/codex-slash-command-service.test.ts @@ -79,7 +79,7 @@ describe("Codex command lifecycle", () => { let projection = useAgentSessionStore.getState().threadProjections[threadId]; expect(projection?.messages).toMatchObject([ { - id: `codex-command:live:${lifecycle.commandId}`, + id: `flowix:codex:command:pending:${lifecycle.commandId}`, content: command, isLoading: true, }, diff --git a/app/flowix-web/features/agent/services/codex-slash-command-service.ts b/app/flowix-web/features/agent/services/codex-slash-command-service.ts index ac06d0828..37ddfc028 100644 --- a/app/flowix-web/features/agent/services/codex-slash-command-service.ts +++ b/app/flowix-web/features/agent/services/codex-slash-command-service.ts @@ -119,10 +119,16 @@ function asCatalogItem(value: unknown): CodexSkillCatalogItem | null { /** Read project-scoped Codex skills without creating a model turn. */ export async function listCodexSkills(cwd: string): Promise { + if (!cwd.trim()) return []; + return parseCodexSkills(await listCodexSkillCatalog(cwd)); +} + +/** Read the full Codex skill catalog, including source metadata used by settings. */ +export async function listCodexSkillCatalog(cwd: string): Promise { if (!cwd.trim()) return []; const capabilities = await agent.getCodexProjectCapabilities(cwd); if (!capabilities.skills.ok) throw new Error(capabilities.skills.error ?? "Failed to load Codex skills"); - return parseCodexSkills(capabilities.skills.value); + return parseCodexSkillCatalog(capabilities.skills.value); } /** Native Codex commands occupy the same thread as model turns and must keep diff --git a/app/flowix-web/features/agent/services/composer-submission-service.ts b/app/flowix-web/features/agent/services/composer-submission-service.ts index 4469b3a72..d64be4674 100644 --- a/app/flowix-web/features/agent/services/composer-submission-service.ts +++ b/app/flowix-web/features/agent/services/composer-submission-service.ts @@ -3,8 +3,10 @@ import { createLogger } from "@/lib/logger"; import { toast } from "@/lib/toast"; import { dialogs } from "@platform/tauri/client/desktop"; import { useAgentSessionStore } from "@features/agent/store/agent-session-store"; +import { createAgentMessageAttachments } from "@features/agent/store/user-message"; import { markConversationWorkspaceStarted } from "@features/agent/runtime/workspace-snapshot"; import type { ComposerSubmissionSnapshot } from "@features/agent/thread-card/composer/composer-submission-controller"; +import { isAgentThreadCardImageAttachment } from "@features/agent/thread-card/composer/composer-attachment-types"; import type { ComposerSubmissionKind } from "@features/agent/thread-card/composer/composer-submission-state"; import { runDshCommand } from "./dsh-command-service"; import { @@ -31,17 +33,20 @@ export async function acceptComposerSubmission( kind: ComposerSubmissionKind, documentContext?: string, ): Promise { - const imagePaths = snapshot.images.map((image) => image.path); + const imageAttachments = snapshot.attachments.filter(isAgentThreadCardImageAttachment); + const imagePaths = imageAttachments.map((image) => image.path); if (kind === "message") { await useAgentSessionStore.getState().sendMessageToThread( bound.threadId, snapshot.prompt, bound.typeKey, { instanceId: bound.instanceId, conversationTitle: bound.title, runtimeConfig: bound.runtimeConfig, isFirstMessage: bound.isFirstMessage, runId: bound.runId, - currentNoteContent: documentContext, imagePaths, rejectOnFailure: true, + currentNoteContent: documentContext, imagePaths, + attachments: createAgentMessageAttachments(imageAttachments), + rejectOnFailure: true, }, ); - } else if (bound.typeKey === "deepseek-harness") { + } else if (bound.typeKey === "dsh") { await runDshCommand({ command: snapshot.prompt, imagePaths, rejectOnFailure: true, ensureConversation: async () => bound, diff --git a/app/flowix-web/features/agent/services/dsh-command-service.test.ts b/app/flowix-web/features/agent/services/dsh-command-service.test.ts index c45af4b7f..576552fb2 100644 --- a/app/flowix-web/features/agent/services/dsh-command-service.test.ts +++ b/app/flowix-web/features/agent/services/dsh-command-service.test.ts @@ -12,14 +12,13 @@ vi.mock("@features/agent/store/agent-session-store", () => ({ reloadMessagesFromHistory: mocks.reload, }) }, })); -vi.mock("@features/agent/store/agent-access-store", () => ({ - useAgentAccessStore: { getState: () => ({ config: { entries: [] } }) }, -})); vi.mock("@features/memo/store/note-store", () => ({ useNoteStore: { getState: () => ({ selectedNotebook: null, notebooks: [] }) }, })); vi.mock("@features/agent/runtime/agent-runtime-spec", () => ({ buildAgentRuntimeConfig: () => ({}), + getAgentAccessOptions: () => [], + getAgentModeOptions: () => [], })); import { runDshCommand } from "./dsh-command-service"; diff --git a/app/flowix-web/features/agent/services/dsh-command-service.ts b/app/flowix-web/features/agent/services/dsh-command-service.ts index a57da855c..8f1099812 100644 --- a/app/flowix-web/features/agent/services/dsh-command-service.ts +++ b/app/flowix-web/features/agent/services/dsh-command-service.ts @@ -1,3 +1,5 @@ +import { resolveAgentPreferenceRuntimeConfig } from "@features/agent/store/agent-user-preferences"; +import { useAgentWorkspaceStore } from "@features/agent/store/agent-workspace-store"; import type { ChatMessage } from "@/types"; import { errorMessage } from "@/lib/error-message"; import type { AgentRuntimeConfig, AgentTypeKey, RuntimeConfig } from "@/types/agent"; @@ -6,13 +8,12 @@ import { normalizeWorkspaceSnapshot, } from "@features/agent/runtime/workspace-snapshot"; import { normalizeConversationWorkspaceState } from "@features/agent/runtime/conversation-workspace"; -import { resolveNotebookAgentFiles } from "@/lib/agent-access-defaults"; -import { useAgentAccessStore } from "@features/agent/store/agent-access-store"; import { useAgentSessionStore } from "@features/agent/store/agent-session-store"; +import { ensureAgentPreferencesReady } from "@features/agent/store/agent-data-store"; import { useNoteStore } from "@features/memo/store/note-store"; import { agentClient } from "@features/agent/store/agent-client"; -export const DSH_AGENT_TYPE = "deepseek-harness" as const satisfies AgentTypeKey; +export const DSH_AGENT_TYPE = "dsh" as const satisfies AgentTypeKey; export function isDshCompactCommand(command: string): boolean { return /^\/compact(?:[\t\n\r ]*)$/iu.test(command.trim()); @@ -73,8 +74,7 @@ export function buildDshCommandMessage( instanceRuntimeConfig?: RuntimeConfig | null, imagePaths: string[] = [], ): DshCommandMessage { - const session = useAgentSessionStore.getState(); - const settings = session.sessionMeta.settings; + const preferences = resolveAgentPreferenceRuntimeConfig(DSH_AGENT_TYPE, instanceRuntimeConfig ?? undefined); const workspaceSnapshot = normalizeConversationWorkspaceState(instanceRuntimeConfig)?.desired ?? normalizeWorkspaceSnapshot(instanceRuntimeConfig?.workspaceSnapshot); @@ -82,18 +82,17 @@ export function buildDshCommandMessage( const notebook = notebookId ? useNoteStore.getState().notebooks.find((item) => item.id === notebookId) : useNoteStore.getState().selectedNotebook; - const accessState = useAgentAccessStore.getState(); const defaultFiles = !workspaceSnapshot && notebookId - ? resolveNotebookAgentFiles(accessState.config, accessState.notebookConfigs, notebookId) + ? useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles(notebookId) : undefined; const runtimeConfig = buildAgentRuntimeConfig({ typeKey: DSH_AGENT_TYPE, notebookPath: workspaceSnapshot?.notebookPath ?? notebook?.path, - permissionMode: settings.agentPermissionMode, - codexModel: settings.agentCodexModel, - codexReasoningEffort: settings.agentCodexReasoningEffort, - instanceRuntimeConfig: instanceRuntimeConfig ?? undefined, + permissionMode: preferences.access?.sandbox ?? "danger-full-access", + codexModel: preferences.model?.key ?? "inherit", + codexReasoningEffort: preferences.reasoningEffort ?? "medium", + instanceRuntimeConfig: preferences, defaultFiles, workspaceSnapshot, }); @@ -113,6 +112,7 @@ export async function executeDshCommand(input: { runtimeConfig?: RuntimeConfig | null; imagePaths?: string[]; }): Promise { + await ensureAgentPreferencesReady(DSH_AGENT_TYPE); return agentClient.executeDeepSeekHarnessCommand( input.threadId, input.command, diff --git a/app/flowix-web/features/agent/services/external-agent-runtime-adapters.ts b/app/flowix-web/features/agent/services/external-agent-runtime-adapters.ts index f96583c27..5896b3cfc 100644 --- a/app/flowix-web/features/agent/services/external-agent-runtime-adapters.ts +++ b/app/flowix-web/features/agent/services/external-agent-runtime-adapters.ts @@ -3,7 +3,7 @@ import type { AgentTypeKey } from '@/types/agent'; export type ExternalAgentTypeKey = Extract< AgentTypeKey, - 'codex' | 'claude' | 'hermes' | 'opencode' | 'deepseek-harness' + 'codex' | 'claudecode' | 'hermes' | 'opencode' | 'dsh' >; export interface ExternalAgentRuntimeAdapter { @@ -15,7 +15,8 @@ export interface ExternalAgentRuntimeAdapter { function createPrefixRuntimeAdapter( typeKey: ExternalAgentTypeKey, - resolveSessionId: (threadId: string) => Promise + resolveSessionId: (threadId: string) => Promise, + legacyPrefixes: readonly string[] = [], ): ExternalAgentRuntimeAdapter { return { typeKey, @@ -23,9 +24,11 @@ function createPrefixRuntimeAdapter( return `${typeKey}-local-${instanceId}`; }, isLocalThreadId(threadId) { - return threadId.startsWith(`${typeKey}-local-`) || + return [typeKey, ...legacyPrefixes].some((prefix) => + threadId.startsWith(`${prefix}-local-`) || // Legacy local ids created before instance-backed routing. - threadId.startsWith(`${typeKey}-pending-`); + threadId.startsWith(`${prefix}-pending-`), + ); }, resolveSessionId, }; @@ -33,12 +36,13 @@ function createPrefixRuntimeAdapter( const externalAgentRuntimeAdapters: Record = { codex: createPrefixRuntimeAdapter('codex', (threadId) => agent.getCodexSessionId(threadId)), - claude: createPrefixRuntimeAdapter('claude', (threadId) => agent.getClaudeSessionId(threadId)), + claudecode: createPrefixRuntimeAdapter('claudecode', (threadId) => agent.getClaudeSessionId(threadId), ['claude']), hermes: createPrefixRuntimeAdapter('hermes', (threadId) => agent.getHermesSessionId(threadId)), opencode: createPrefixRuntimeAdapter('opencode', (threadId) => agent.getOpenCodeSessionId(threadId)), - 'deepseek-harness': createPrefixRuntimeAdapter( - 'deepseek-harness', + dsh: createPrefixRuntimeAdapter( + 'dsh', (threadId) => agent.getDeepSeekHarnessSessionId(threadId), + ['deepseek-harness'], ), }; @@ -47,10 +51,10 @@ export function getExternalAgentRuntimeAdapter( ): ExternalAgentRuntimeAdapter | null { if ( typeKey === 'codex' || - typeKey === 'claude' || + typeKey === 'claudecode' || typeKey === 'hermes' || typeKey === 'opencode' || - typeKey === 'deepseek-harness' + typeKey === 'dsh' ) { return externalAgentRuntimeAdapters[typeKey]; } diff --git a/app/flowix-web/features/agent/services/external-agent-runtime-service.test.ts b/app/flowix-web/features/agent/services/external-agent-runtime-service.test.ts index 82754455c..e8fae7322 100644 --- a/app/flowix-web/features/agent/services/external-agent-runtime-service.test.ts +++ b/app/flowix-web/features/agent/services/external-agent-runtime-service.test.ts @@ -12,7 +12,7 @@ const sessionStoreMock = vi.hoisted(() => ({ state: { sessionMeta: { activeThreadIds: {} as Record, - activeAgentTypeKey: "deepseek-harness" as const, + activeAgentTypeKey: "dsh" as const, threadTypes: {}, externalSessionResolutions: {} as Record, lastRunningRunsReconciledAt: null, @@ -112,7 +112,7 @@ describe('external agent runtime service', () => { await expect(resolveExternalSessionId('codex-local-inst-1', 'codex')) .resolves.toBe('codex-real-session'); - await expect(resolveExternalSessionId('claude-local-inst-1', 'claude')) + await expect(resolveExternalSessionId('claude-local-inst-1', 'claudecode')) .resolves.toBe('claude-real-session'); expect(agent.getCodexSessionId).toHaveBeenCalledWith('codex-local-inst-1'); diff --git a/app/flowix-web/features/agent/store/agent-access-store.test.ts b/app/flowix-web/features/agent/store/agent-access-store.test.ts deleted file mode 100644 index 54300f596..000000000 --- a/app/flowix-web/features/agent/store/agent-access-store.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const agentAccessMock = vi.hoisted(() => ({ - config: { - version: 1, - entries: [] as Array>, - } as { - version: number; - entries: Array>; - defaults?: { runtime?: { pi?: { piCodeMode?: boolean } } }; - }, - get: vi.fn(), - set: vi.fn(), - addFolderFromPicker: vi.fn(), -})); - -vi.mock("@platform/tauri/client", () => ({ - agentAccess: { - get: agentAccessMock.get, - set: agentAccessMock.set, - addFolderFromPicker: agentAccessMock.addFolderFromPicker, - }, -})); - -type TestEntry = { - id: string; - kind: "folder" | "notebook"; - path: string; - name: string; - enabled: boolean; - workspace: boolean; - missing: boolean; - addedAt: number; - updatedAt: number; -}; - -function makeFolder( - overrides: Partial & { id: string; path: string }, -): TestEntry { - return { - id: overrides.id, - kind: "folder", - path: overrides.path, - name: overrides.name ?? overrides.path, - enabled: overrides.enabled ?? true, - workspace: overrides.workspace ?? false, - missing: overrides.missing ?? false, - addedAt: overrides.addedAt ?? 1, - updatedAt: overrides.updatedAt ?? 1, - }; -} - -function entriesWithWorkspace(items: TestEntry[]): { - version: 1; - entries: TestEntry[]; -} { - return { version: 1, entries: items }; -} - -describe("agent-access-store workspace selection", () => { - beforeEach(async () => { - vi.clearAllMocks(); - agentAccessMock.config = { version: 1, entries: [] }; - agentAccessMock.get.mockImplementation(async () => agentAccessMock.config); - agentAccessMock.set.mockImplementation(async (config) => { - agentAccessMock.config = config; - }); - agentAccessMock.addFolderFromPicker.mockImplementation(async () => null); - const { useAgentAccessStore } = - await import("@features/agent/store/agent-access-store"); - useAgentAccessStore.setState({ - config: { version: 1, entries: [] }, - isLoading: false, - hasLoaded: false, - loadError: null, - }); - }); - - it('exposes initial load failures and clears them after retry', async () => { - const { useAgentAccessStore } = await import('@features/agent/store/agent-access-store'); - agentAccessMock.get.mockRejectedValueOnce(new Error('read failed')); - await useAgentAccessStore.getState().loadInitial(); - expect(useAgentAccessStore.getState()).toMatchObject({ - hasLoaded: false, isLoading: false, loadError: 'read failed', - }); - agentAccessMock.config = { - version: 1, entries: [], defaults: { runtime: { pi: { piCodeMode: true } } }, - }; - await useAgentAccessStore.getState().loadInitial(); - expect(useAgentAccessStore.getState()).toMatchObject({ - hasLoaded: true, isLoading: false, loadError: null, - config: agentAccessMock.config, - }); - - agentAccessMock.get.mockRejectedValueOnce(new Error('refresh failed')); - await useAgentAccessStore.getState().loadInitial(); - expect(useAgentAccessStore.getState()).toMatchObject({ - hasLoaded: true, isLoading: false, loadError: 'refresh failed', - config: agentAccessMock.config, - }); - }); - - it.each([true, false])('keeps the newest overlapping snapshot (newest resolves first: %s)', async (newestFirst) => { - const { useAgentAccessStore } = await import('@features/agent/store/agent-access-store'); - let resolveOld!: (config: typeof agentAccessMock.config) => void; - let resolveNew!: (config: typeof agentAccessMock.config) => void; - agentAccessMock.get - .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; })) - .mockReturnValueOnce(new Promise((resolve) => { resolveNew = resolve; })); - const oldLoad = useAgentAccessStore.getState().loadInitial(); - const newLoad = useAgentAccessStore.getState().loadInitial(); - const oldConfig = { version: 1 as const, entries: [], defaults: { runtime: { pi: { piCodeMode: false } } } }; - const newConfig = { version: 1 as const, entries: [], defaults: { runtime: { pi: { piCodeMode: true } } } }; - if (newestFirst) { - resolveNew(newConfig); - await newLoad; - resolveOld(oldConfig); - await oldLoad; - } else { - resolveOld(oldConfig); - await oldLoad; - expect(useAgentAccessStore.getState().hasLoaded).toBe(false); - resolveNew(newConfig); - await newLoad; - } - expect(useAgentAccessStore.getState()).toMatchObject({ - config: newConfig, hasLoaded: true, isLoading: false, loadError: null, - }); - }); - - it("does NOT auto-promote a folder to workspace on initial load", async () => { - // 新契约: workspace 完全由 setWorkspace 显式触发, loadInitial 不会把 - // 第一个 enabled folder 自动升为主空间。 磁盘原值原样落库。 - const { useAgentAccessStore } = - await import("@features/agent/store/agent-access-store"); - agentAccessMock.config = entriesWithWorkspace([ - makeFolder({ - id: "folder-1", - path: "D:\\projects\\first", - name: "First", - }), - makeFolder({ - id: "folder-2", - path: "D:\\projects\\second", - name: "Second", - }), - ]); - - await useAgentAccessStore.getState().loadInitial(); - - const entries = useAgentAccessStore.getState().config.entries; - expect(entries.every((entry) => entry.workspace === false)).toBe(true); - }); - - it("keeps a Pi writing selection made while initial settings are loading", async () => { - const { useAgentAccessStore } = - await import("@features/agent/store/agent-access-store"); - let resolveGet!: (config: typeof agentAccessMock.config) => void; - agentAccessMock.get.mockReturnValueOnce( - new Promise((resolve) => { resolveGet = resolve; }), - ); - - const load = useAgentAccessStore.getState().loadInitial(); - await useAgentAccessStore.getState().setDefaultRuntime("pi", { - piCodeMode: false, - }); - resolveGet({ - version: 1, - entries: [{ id: "loaded-entry" }], - defaults: { runtime: { pi: { piCodeMode: true } } }, - }); - await load; - - expect( - useAgentAccessStore.getState().config.defaults?.runtime?.pi?.piCodeMode, - ).toBe(false); - expect(useAgentAccessStore.getState().config.entries).toEqual([ - { id: "loaded-entry" }, - ]); - expect(agentAccessMock.config.defaults?.runtime?.pi?.piCodeMode).toBe(false); - }); - - - it("addFolder does not persist the removed workspace field", async () => { - const { useAgentAccessStore } = - await import("@features/agent/store/agent-access-store"); - useAgentAccessStore.setState({ - config: { version: 1, entries: [] }, - isLoading: false, - }); - - const result = await useAgentAccessStore - .getState() - .addFolder("D:\\projects\\new"); - expect(result.ok).toBe(true); - - const entries = useAgentAccessStore.getState().config.entries; - expect(entries).toHaveLength(1); - expect(entries[0]?.enabled).toBe(true); - expect(entries[0]?.workspace).toBeUndefined(); - }); - - it("does not write legacy global file defaults without a notebook id", async () => { - const { useAgentAccessStore } = - await import("@features/agent/store/agent-access-store"); - const result = await useAgentAccessStore.getState().setDefaultFiles(undefined, { - workspace: null, - folders: ["D:\\projects\\legacy"], - notebooks: [], - }); - expect(result).toBe(false); - expect(agentAccessMock.set).not.toHaveBeenCalled(); - expect(useAgentAccessStore.getState().config.defaults?.files).toBeUndefined(); - }); -}); diff --git a/app/flowix-web/features/agent/store/agent-access-store.ts b/app/flowix-web/features/agent/store/agent-access-store.ts deleted file mode 100644 index 78ceed8ab..000000000 --- a/app/flowix-web/features/agent/store/agent-access-store.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { createLogger } from '@/lib/logger'; -const logger = createLogger('agent-access-store'); -/** - * Agent 可访问目录 store ── zustand 镜像后端 - * `~/.flowix/agent-access.json` 的整份 config。 与 `user-settings-store` - * 不同, 本 store 没有 persist (后端是真源), 走 IPC + 跨窗口事件同步。 - * - * 写操作 (`addFolder`) 走乐观更新: 本地先 - * 改 `entries` 再 `await agentAccess.set` 整份, 失败时 `loadInitial` - * 回滚到磁盘真值。 跨窗口同步靠 app.tsx 顶层挂的 `listenToAgentAccessChanges`, - * 收到事件后从磁盘拉整份覆盖内存。 - */ - -import { create } from "zustand"; -import { agentAccess } from "@platform/tauri/client"; -import type { - AgentAccessConfig, - AgentAccessDefaultRuntime, - AgentAccessEntry, - NotebookAgentConfig, -} from "@/lib/types/agent-access"; -import type { AgentTypeKey, FilesConfig } from "@/types/agent"; - -// This store mirrors `~/.flowix/agent-access.json`. -// It owns defaults for newly created agent-thread-card instances and keeps the -// global entries list (folder metadata pool: name / missing / bookmark). -// Real conversation runs derive cwd from the current notebook and add-dir -// roots from that notebook's `.flowix/agent.json` (see -// agent-runtime-spec::buildAgentRuntimeConfig), not from instance.files. - -export type AgentAccessErrorCode = - "not-selected" | "already-tracked" | "save-failed"; - -export interface AgentAccessState { - config: AgentAccessConfig; - notebookConfigs: Record; - isLoading: boolean; - hasLoaded: boolean; - loadError: string | null; - - /** 从磁盘拉整份 config ── 启动 / 跨窗口事件 / 写失败回滚都走它。 */ - loadInitial: () => Promise; -/** - * 加一个 folder。 走后端 picker 让用户挑本地目录, 后端同时保存 - * macOS security-scoped bookmark。路径已存在 (notebook 同路径或 - * 已加的 folder) 时后端返回 `PathConflict`, UI 弹 toast 但不动 store。 - */ - addFolderFromPicker: () => Promise< - | { ok: true; entry: AgentAccessEntry } - | { ok: false; code: AgentAccessErrorCode } - >; - - /** - * 直接以给定路径加 folder ── 跳过 dialog picker, 给测试 / 偏好窗口 - * 等场景复用。 UI 层用 `addFolderFromPicker`。 - */ - addFolder: ( - path: string, - name?: string, - ) => Promise< - | { ok: true; entry: AgentAccessEntry } - | { ok: false; code: AgentAccessErrorCode } - >; - - setDefaultRuntime: ( - agentType: AgentTypeKey, - patch: AgentAccessDefaultRuntime, - ) => Promise; - /** Persist notebook add-dir roots to `.flowix/agent.json`. */ - setDefaultFiles: ( - notebookId: string | null | undefined, - files: FilesConfig, - ) => Promise; -} - -const EMPTY_CONFIG: AgentAccessConfig = { version: 1, entries: [], defaults: {} }; -let loadGeneration = 0; - -function mergeConcurrentConfigWrite( - loaded: AgentAccessConfig, - initial: AgentAccessConfig, - latest: AgentAccessConfig, -): AgentAccessConfig { - if (latest === initial) return loaded; - - const entries = latest.entries === initial.entries - ? loaded.entries - : [...new Map( - [...loaded.entries, ...latest.entries].map((entry) => [entry.id, entry]), - ).values()]; - - let defaults = loaded.defaults; - if (latest.defaults !== initial.defaults && latest.defaults) { - const runtime = { ...loaded.defaults?.runtime }; - for (const agentType of Object.keys(latest.defaults.runtime ?? {}) as AgentTypeKey[]) { - runtime[agentType] = { - ...loaded.defaults?.runtime?.[agentType], - ...latest.defaults.runtime?.[agentType], - }; - } - defaults = { - ...loaded.defaults, - ...latest.defaults, - ...(loaded.defaults?.runtime || latest.defaults.runtime ? { runtime } : {}), - }; - } - - return { ...loaded, entries, defaults }; -} - -export const useAgentAccessStore = create((set, get) => ({ - config: EMPTY_CONFIG, - notebookConfigs: {}, - isLoading: false, - hasLoaded: false, - loadError: null, - - loadInitial: async () => { - const generation = ++loadGeneration; - const configAtStart = get().config; - set({ isLoading: true, loadError: null }); - try { - // Notebook-local config is the source of truth for add-dir roots. - const [config, notebookConfigs] = await Promise.all([ - agentAccess.get(), - agentAccess.getNotebookConfigs?.() ?? Promise.resolve({}), - ]); - // Initial hydration and listener-ready reconciliation may overlap. - if (generation !== loadGeneration) return; - // Preserve a local settings write made while this read was in flight. - const latestConfig = get().config; - set({ - config: mergeConcurrentConfigWrite(config, configAtStart, latestConfig), - notebookConfigs, - isLoading: false, - hasLoaded: true, - }); - } catch (e) { - if (generation !== loadGeneration) return; - logger.error("agentAccess.loadInitial failed:", { error: e }); - set({ isLoading: false, loadError: e instanceof Error ? e.message : String(e) }); - } - }, addFolder: async (path: string, name?: string) => { - const entry = makeLocalFolderEntry(path, name); - const prev = get().config; - // Global entries are metadata/authorization only; notebook add-dir - // membership is persisted separately in `.flowix/agent.json`. - const optimistic = { - ...prev, - entries: [...prev.entries, entry], - }; - set({ config: optimistic }); - try { - await agentAccess.set(optimistic); - return { ok: true, entry }; - } catch (e) { - const reason = extractReason(e); - if (reason === "path conflict") { - // 用户选了一个已经跟踪的路径, 不写盘也不留乐观条目 ── 回滚到 - // 真正的"没加"状态, 让用户看到原列表。 - set({ config: prev }); - return { ok: false, code: "already-tracked" }; - } - logger.error("agentAccess.addFolder failed, rolling back:", { error: e }); - await get().loadInitial(); - return { ok: false, code: "save-failed" }; - } - }, - - addFolderFromPicker: async () => { - try { - const entry = await agentAccess.addFolderFromPicker(); - if (!entry) { - return { ok: false, code: "not-selected" }; - } - await get().loadInitial(); - return { ok: true, entry }; - } catch (e) { - const reason = extractReason(e); - if (reason === "path conflict") { - await get().loadInitial(); - return { ok: false, code: "already-tracked" }; - } - logger.error("agentAccess.addFolderFromPicker failed:", { error: e }); - await get().loadInitial(); - return { ok: false, code: "save-failed" }; - } - }, - setDefaultRuntime: async (agentType, patch) => { - const prev = get().config; - const optimistic: AgentAccessConfig = { - ...prev, - defaults: { - ...(prev.defaults ?? {}), - runtime: { - ...(prev.defaults?.runtime ?? {}), - [agentType]: { - ...(prev.defaults?.runtime?.[agentType] ?? {}), - ...patch, - }, - }, - }, - }; - set({ config: optimistic }); - try { - await agentAccess.set(optimistic); - } catch (e) { - logger.error("agentAccess.setDefaultRuntime failed, rolling back:", { error: e }); - await get().loadInitial(); - } - }, - - setDefaultFiles: async (notebookId, files) => { - if (!notebookId) { - // Notebook scope is mandatory. Keep the old config read-only for - // migration, but never write new folder defaults to the global file. - logger.warn("agentAccess.setDefaultFiles ignored without notebookId"); - return false; - } - const prevNotebookConfig = get().notebookConfigs[notebookId] ?? { - version: 1 as const, - revision: 0, - addDirs: [], - }; - const priorByPath = new Map( - prevNotebookConfig.addDirs.map((directory) => [ - directory.path.trim().replace(/[\\/]+$/, '').toLowerCase(), - directory, - ]), - ); - const nextConfig: NotebookAgentConfig = { - version: 1, - revision: prevNotebookConfig.revision, - addDirs: files.folders.map((path) => { - const key = path.trim().replace(/[\\/]+$/, '').toLowerCase(); - const existing = priorByPath.get(key); - return existing ?? { - id: `dir_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - path, - label: path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path, - enabled: true, - }; - }), - }; - try { - const saved = await agentAccess.setNotebookConfig( - notebookId, - prevNotebookConfig.revision, - nextConfig, - ); - set((state) => ({ - notebookConfigs: { ...state.notebookConfigs, [notebookId]: saved }, - })); - return true; - } catch (e) { - logger.error("agentAccess.setDefaultFiles failed:", { error: e }); - await get().loadInitial(); - return false; - } - }, -})); -/** 在前端构造一条 Folder entry ── 路径 / 名字都是用户给的值, id 用时间戳 - * + 随机段保证与后端 `fld_<6位>` 不冲突即可 (后端写盘后会刷新 missing 字段)。 */ -function makeLocalFolderEntry(path: string, name?: string): AgentAccessEntry { - const trimmed = path.replace(/[\\/]+$/, ""); - const derived = name?.trim() || trimmed.split(/[\\/]/).pop() || trimmed; - const now = Date.now(); - return { - id: `fld_${now}_${Math.random().toString(36).slice(2, 6)}`, - kind: "folder", - path: trimmed, - name: derived, - enabled: true, - addedAt: now, - updatedAt: now, - missing: false, - }; -} - -/** 后端 IPC 失败时, Tauri 抛的 Error 里 `message` 是 `String`, 我们要 - * 识别 "path already tracked" 这条 user-facing 消息 → 走"回滚 + 友好 - * 提示"分支。 */ -function extractReason(e: unknown): string | null { - if (e && typeof e === "object" && "message" in e) { - const msg = (e as { message: unknown }).message; - if (typeof msg === "string") { - if (msg.includes("path already tracked")) return "path conflict"; - return msg; - } - } - return null; -} diff --git a/app/flowix-web/features/agent/store/agent-data-store.ts b/app/flowix-web/features/agent/store/agent-data-store.ts new file mode 100644 index 000000000..0e18a8580 --- /dev/null +++ b/app/flowix-web/features/agent/store/agent-data-store.ts @@ -0,0 +1,878 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { createLogger } from "@/lib/logger"; +import { STORAGE_KEYS } from "@/lib/constants"; +import { agentWorkspace, piModels } from "@platform/tauri/client"; +import type { + AgentCodexReasoningEffort, + AgentCodexServiceTier, + AgentPermissionMode, + AgentTypeKey, +} from "@/types/agent"; +import type { + LegacyAgentRuntimePreference, + LegacyAgentRuntimePreferences, +} from "@/lib/types/agent-workspace"; +import { + getAgentAccessOptions, + getAgentModeOptions, + type AgentAccessOption, + type AgentModeOption, +} from "@features/agent/runtime/agent-runtime-spec"; +import { getFallbackAgentModelOptions } from "@features/agent/config/agent-model-options"; +import { normalizeAgentId } from "@/lib/agent-types"; + +const logger = createLogger("agent-data-store"); +const PERSISTENCE_STATUS_EVENT = "flowix-agent-data-persistence-status"; +let lastPersistenceError: string | null = null; + +function updatePersistenceError(error: string | null): void { + if (lastPersistenceError === error) return; + lastPersistenceError = error; + if (typeof window === "undefined") return; + queueMicrotask(() => { + window.dispatchEvent(new CustomEvent(PERSISTENCE_STATUS_EVENT, { detail: { error } })); + }); +} + +// A failed browser-cache write must not prevent backend-owned workspace loading. +const agentStorage = { + getItem: (name: string): string | null => { + try { return localStorage.getItem(name); } + catch (error) { logger.warn("Agent cache read failed", { error }); updatePersistenceError(errorMessage(error)); return null; } + }, + setItem: (name: string, value: string): void => { + try { + const mergedValue = mergeSerializedAgentData(localStorage.getItem(name), value); + localStorage.setItem(name, mergedValue); + updatePersistenceError(null); + } catch (error) { + logger.warn("Agent cache write failed", { error }); + updatePersistenceError(errorMessage(error)); + } + }, + removeItem: (name: string): void => { + try { localStorage.removeItem(name); } + catch (error) { logger.warn("Agent cache removal failed", { error }); updatePersistenceError(errorMessage(error)); } + }, +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export interface AgentCatalogModel { + id: string; + label: string; + description?: string; + subtitle?: string; + creditMultiplier?: number; + iconUrl?: string; + providerId?: string; + providerName?: string; + reasoningEfforts?: AgentCodexReasoningEffort[]; +} + +export interface AgentCatalogSkill { + name: string; + description: string; + displayName?: string; + shortDescription?: string; + whenToUse?: string; + modelInvocable?: boolean; + scope?: string; + path?: string; + enabled?: boolean; +} + +export interface AgentModelCatalog { + items: AgentCatalogModel[]; + defaultModel?: string; + defaultProviderId?: string; + updatedAt?: number; +} + +export interface AgentCapabilities { + permissions: readonly AgentAccessOption[]; + modes: readonly AgentModeOption[]; +} + +export interface AgentUserPreference { + model?: { id: string; providerId?: string }; + permission?: AgentPermissionMode; + mode?: string; + reasoningEffort?: AgentCodexReasoningEffort; + reasoningByModel?: Record; + serviceTier?: AgentCodexServiceTier; +} + +interface AgentCatalogState { + models: AgentModelCatalog; + skillsByNotebookId: Record; + /** Built from the current runtime spec and omitted from the persisted snapshot. */ + capabilities: AgentCapabilities; +} + +interface AgentDataRecord { + catalog: AgentCatalogState; + user: AgentUserPreference; +} + +interface AgentDataLoadState { + piMode: { status: 'idle' | 'loading' | 'ready' | 'error'; error?: string }; +} + +export interface AgentDataStoreState { + /** One-time import of legacy runtime/session defaults. */ + legacyPreferencesMigrated: boolean; + /** Per-field revisions make user choices mergeable across app windows. */ + userPreferenceRevisions: Partial>>; + /** Transient UI status; deliberately excluded from localStorage. */ + persistenceError: string | null; + agents: Record; + loadState: AgentDataLoadState; + initializePiMode: () => Promise; + initializeLegacyPreferences: () => Promise; + setModelCatalog: (agentType: AgentTypeKey, catalog: AgentModelCatalog) => void; + invalidateModelCatalog: (agentType: AgentTypeKey) => void; + invalidateSkillCatalog: (agentType: AgentTypeKey, notebookId: string) => void; + loadModelCatalog: ( + agentType: AgentTypeKey, + loader: () => Promise, + options?: { force?: boolean; maxAgeMs?: number }, + ) => Promise; + setSkillCatalog: ( + agentType: AgentTypeKey, + notebookId: string, + items: AgentCatalogSkill[], + ) => void; + loadSkillCatalog: ( + agentType: AgentTypeKey, + notebookId: string, + loader: () => Promise, + options?: { force?: boolean; maxAgeMs?: number }, + ) => Promise; + setUserPreference: ( + agentType: AgentTypeKey, + patch: Partial, + ) => void; + rememberModelReasoning: ( + agentType: AgentTypeKey, + model: { id: string; providerId?: string } | undefined, + effort: AgentCodexReasoningEffort, + ) => void; + migrateLegacyPreferences: ( + runtime: LegacyAgentRuntimePreferences | undefined, + codexSessionSettings?: { + agentCodexModel: string; + agentPermissionMode: AgentPermissionMode; + agentCodexReasoningEffort: AgentCodexReasoningEffort; + }, + ) => void; +} + +const AGENT_TYPES: AgentTypeKey[] = [ + "codex", "claudecode", "hermes", "opencode", "pi", "dsh", +]; + +function canonicalAgentMap(source: object | undefined): Partial> { + const result: Partial> = {}; + if (!source) return result; + // Merge legacy fields first, then let explicitly canonical values win field + // conflicts while retaining any fields present only in the old record. + const entries = Object.entries(source as Record).sort(([left], [right]) => + Number(left === normalizeAgentId(left)) - Number(right === normalizeAgentId(right)), + ); + for (const [key, value] of entries) { + const id = normalizeAgentId(key); + if (!id) continue; + const previous = result[id]; + if (!previous || typeof previous !== "object" || !value || typeof value !== "object") { + result[id] = value; + continue; + } + const merged = { ...(previous as Record), ...(value as Record) }; + for (const nestedKey of Object.keys(value as Record)) { + const before = (previous as Record)[nestedKey]; + const after = (value as Record)[nestedKey]; + if (before && after && typeof before === "object" && typeof after === "object") { + merged[nestedKey] = { ...(before as object), ...(after as object) }; + } + } + result[id] = merged as T; + } + return result; +} + +function createAgentRecord(agentType: AgentTypeKey): AgentDataRecord { + return { + catalog: { + models: { items: getFallbackAgentModelOptions(agentType) }, + skillsByNotebookId: {}, + capabilities: { + permissions: getAgentAccessOptions(agentType), + modes: getAgentModeOptions(agentType), + }, + }, + user: {}, + }; +} + +function createInitialAgents(): Record { + return Object.fromEntries( + AGENT_TYPES.map((agentType) => [agentType, createAgentRecord(agentType)]), + ) as Record; +} + +const MODEL_CATALOG_MAX_AGE_MS = 5 * 60 * 1000; +const SKILL_CATALOG_MAX_AGE_MS = 60 * 1000; +const pendingModelLoads = new Map>(); +const pendingSkillLoads = new Map>(); +let pendingPiModeInitialization: Promise | null = null; +let pendingLegacyPreferencesInitialization: Promise | null = null; + +async function readLegacyPiModePreference(): Promise<'code' | 'writing' | undefined> { + const legacy = (await agentWorkspace.getLegacyPreferences())?.pi; + if (legacy?.piCodeMode !== undefined) return legacy.piCodeMode ? 'code' : 'writing'; + const mode: unknown = legacy?.mode; + return mode === 'code' || mode === 'writing' ? mode : undefined; +} + +function skillLoadKey(agentType: AgentTypeKey, notebookId: string): string { + return `${agentType}:${notebookId}`; +} + +export function modelPreferenceKey(model: { id: string; providerId?: string }): string { + return JSON.stringify([model.providerId?.trim() ?? "", model.id.trim()]); +} + +function nextPreferenceRevision(): string { + return `${Date.now().toString().padStart(13, "0")}:${Math.random().toString(36).slice(2)}`; +} + +function compareRevisions(left?: string, right?: string): number { + if (left === right) return 0; + if (!left) return -1; + if (!right) return 1; + return left.localeCompare(right); +} + +// Defaults must lose to imported preferences, which must lose to user choices. +const DEFAULT_PREFERENCE_REVISION = "0000000000000:default"; +function isImportedPreferenceRevision(revision?: string): boolean { + return revision?.startsWith("0000000000000:") ?? false; +} + +function preferenceValueChanged(left: unknown, right: unknown): boolean { + return stableSerialize(left) !== stableSerialize(right); +} + +function stampChangedPreferenceFields( + revisions: Record, + before: AgentUserPreference, + after: AgentUserPreference, +): Record { + const next = { ...revisions }; + for (const key of new Set([ + ...Object.keys(before).filter((name) => name !== "reasoningByModel"), + ...Object.keys(after).filter((name) => name !== "reasoningByModel"), + ])) { + if (preferenceValueChanged(before[key as keyof AgentUserPreference], after[key as keyof AgentUserPreference])) { + next[key] = nextPreferenceRevision(); + } + } + const beforeReasoning = before.reasoningByModel ?? {}; + const afterReasoning = after.reasoningByModel ?? {}; + for (const key of new Set([...Object.keys(beforeReasoning), ...Object.keys(afterReasoning)])) { + if (beforeReasoning[key] !== afterReasoning[key]) { + next[`reasoningByModel:${key}`] = nextPreferenceRevision(); + } + } + return next; +} + +function stampLegacyPreferenceFields( + revisions: Record, + before: AgentUserPreference, + after: AgentUserPreference, +): Record { + const next = { ...revisions }; + const legacyRevision = "0000000000000:legacy"; + for (const key of new Set([ + ...Object.keys(before).filter((name) => name !== "reasoningByModel"), + ...Object.keys(after).filter((name) => name !== "reasoningByModel"), + ])) { + if (preferenceValueChanged(before[key as keyof AgentUserPreference], after[key as keyof AgentUserPreference])) { + next[key] = legacyRevision; + } + } + const beforeReasoning = before.reasoningByModel ?? {}; + const afterReasoning = after.reasoningByModel ?? {}; + for (const key of new Set([...Object.keys(beforeReasoning), ...Object.keys(afterReasoning)])) { + if (beforeReasoning[key] !== afterReasoning[key]) { + next[`reasoningByModel:${key}`] = legacyRevision; + } + } + return next; +} + +function mergeUserPreferenceSnapshots( + left: AgentUserPreference | undefined, + right: AgentUserPreference | undefined, + leftRevisions: Record = {}, + rightRevisions: Record = {}, +): AgentUserPreference { + const leftUser = left ?? {}; + const rightUser = right ?? {}; + const merged: AgentUserPreference = {}; + const fields = new Set([ + ...Object.keys(leftUser).filter((key) => key !== "reasoningByModel"), + ...Object.keys(rightUser).filter((key) => key !== "reasoningByModel"), + ...Object.keys(leftRevisions).filter((key) => !key.startsWith("reasoningByModel:")), + ...Object.keys(rightRevisions).filter((key) => !key.startsWith("reasoningByModel:")), + ]); + for (const field of fields) { + const leftRevision = leftRevisions[field]; + const rightRevision = rightRevisions[field]; + const leftHasValue = Object.prototype.hasOwnProperty.call(leftUser, field); + const rightHasValue = Object.prototype.hasOwnProperty.call(rightUser, field); + const useRight = isImportedPreferenceRevision(rightRevision) && !leftRevision && leftHasValue + ? false + : isImportedPreferenceRevision(leftRevision) && !rightRevision && rightHasValue + ? true + : compareRevisions(leftRevision, rightRevision) <= 0; + const chosen = useRight ? rightUser : leftUser; + const hasValue = Object.prototype.hasOwnProperty.call(chosen, field); + const value = chosen[field as keyof AgentUserPreference]; + if (hasValue && value !== undefined) (merged as Record)[field] = value; + } + + const leftReasoning = leftUser.reasoningByModel ?? {}; + const rightReasoning = rightUser.reasoningByModel ?? {}; + const reasoningKeys = new Set([ + ...Object.keys(leftReasoning), + ...Object.keys(rightReasoning), + ...Object.keys(leftRevisions).filter((key) => key.startsWith("reasoningByModel:")).map((key) => key.slice("reasoningByModel:".length)), + ...Object.keys(rightRevisions).filter((key) => key.startsWith("reasoningByModel:")).map((key) => key.slice("reasoningByModel:".length)), + ]); + const reasoningByModel: Record = {}; + for (const key of reasoningKeys) { + const revisionKey = `reasoningByModel:${key}`; + const leftRevision = leftRevisions[revisionKey]; + const rightRevision = rightRevisions[revisionKey]; + const leftHasValue = Object.prototype.hasOwnProperty.call(leftReasoning, key); + const rightHasValue = Object.prototype.hasOwnProperty.call(rightReasoning, key); + const useRight = isImportedPreferenceRevision(rightRevision) && !leftRevision && leftHasValue + ? false + : isImportedPreferenceRevision(leftRevision) && !rightRevision && rightHasValue + ? true + : compareRevisions(leftRevision, rightRevision) <= 0; + const value = useRight ? rightReasoning[key] : leftReasoning[key]; + if (value !== undefined) reasoningByModel[key] = value; + } + if (Object.keys(reasoningByModel).length) merged.reasoningByModel = reasoningByModel; + return merged; +} + +function mergeRevisionMaps( + left: AgentDataStoreState["userPreferenceRevisions"] | undefined, + right: AgentDataStoreState["userPreferenceRevisions"] | undefined, +): AgentDataStoreState["userPreferenceRevisions"] { + const merged: NonNullable = {}; + left = canonicalAgentMap(left); + right = canonicalAgentMap(right); + for (const agentType of AGENT_TYPES) { + const leftFields = left?.[agentType] ?? {}; + const rightFields = right?.[agentType] ?? {}; + const fields: Record = {}; + for (const key of new Set([...Object.keys(leftFields), ...Object.keys(rightFields)])) { + fields[key] = compareRevisions(leftFields[key], rightFields[key]) <= 0 + ? rightFields[key] + : leftFields[key]; + } + if (Object.keys(fields).length) merged[agentType] = fields; + } + return merged; +} + +function mergeCatalogSnapshots( + left: AgentCatalogState | undefined, + right: AgentCatalogState | undefined, +): AgentCatalogState { + const leftCatalog: AgentCatalogState = left ?? { + models: { items: [] }, + skillsByNotebookId: {}, + capabilities: { permissions: [], modes: [] }, + }; + const rightCatalog = right ?? leftCatalog; + const leftModelsAt = leftCatalog.models.updatedAt ?? -1; + const rightModelsAt = rightCatalog.models.updatedAt ?? -1; + const skillsByNotebookId = { ...leftCatalog.skillsByNotebookId }; + for (const [notebookId, skillCatalog] of Object.entries(rightCatalog.skillsByNotebookId)) { + if ((skillCatalog.updatedAt ?? -1) >= (skillsByNotebookId[notebookId]?.updatedAt ?? -1)) { + skillsByNotebookId[notebookId] = skillCatalog; + } + } + return { + models: rightModelsAt >= leftModelsAt ? rightCatalog.models : leftCatalog.models, + skillsByNotebookId, + capabilities: rightCatalog.capabilities ?? leftCatalog.capabilities, + }; +} + +function mergeAgentSnapshots( + leftAgents: Partial> | undefined, + rightAgents: Partial> | undefined, + leftRevisions: AgentDataStoreState["userPreferenceRevisions"] | undefined, + rightRevisions: AgentDataStoreState["userPreferenceRevisions"] | undefined, +): Record { + leftAgents = canonicalAgentMap(leftAgents); + rightAgents = canonicalAgentMap(rightAgents); + const merged = createInitialAgents(); + for (const agentType of AGENT_TYPES) { + const left = leftAgents?.[agentType] ?? merged[agentType]; + const right = rightAgents?.[agentType] ?? left; + merged[agentType] = { + catalog: mergeCatalogSnapshots(left.catalog, right.catalog), + user: mergeUserPreferenceSnapshots( + left.user, + right.user, + leftRevisions?.[agentType], + rightRevisions?.[agentType], + ), + }; + } + return merged; +} + +function mergePersistedAgentSlices(left: Record, right: Record): Record { + const leftRevisions = left.userPreferenceRevisions as AgentDataStoreState["userPreferenceRevisions"] | undefined; + const rightRevisions = right.userPreferenceRevisions as AgentDataStoreState["userPreferenceRevisions"] | undefined; + const revisions = mergeRevisionMaps(leftRevisions, rightRevisions); + return { + ...left, + ...right, + legacyPreferencesMigrated: !!left.legacyPreferencesMigrated || !!right.legacyPreferencesMigrated, + userPreferenceRevisions: revisions, + agents: mergeAgentSnapshots(left.agents, right.agents, leftRevisions, rightRevisions), + }; +} + +function mergeSerializedAgentData(existing: string | null, incoming: string): string { + if (!existing) return incoming; + try { + const existingEnvelope = JSON.parse(existing) as { state?: Record; version?: number }; + const incomingEnvelope = JSON.parse(incoming) as { state?: Record; version?: number }; + if (!existingEnvelope.state || !incomingEnvelope.state) return incoming; + const mergedState = mergePersistedAgentSlices(existingEnvelope.state, incomingEnvelope.state); + return JSON.stringify({ + ...incomingEnvelope, + state: persistedAgentDataState(mergedState as AgentDataStoreState), + }); + } catch { + return incoming; + } +} + +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`; + if (value && typeof value === "object") { + const object = value as Record; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`).join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + +function persistedAgentDataState(state: AgentDataStoreState): Record { + return { + legacyPreferencesMigrated: state.legacyPreferencesMigrated, + userPreferenceRevisions: state.userPreferenceRevisions, + agents: Object.fromEntries(AGENT_TYPES.map((agentType) => { + const agent = state.agents[agentType]; + return [agentType, { + catalog: { + models: agent.catalog.models, + skillsByNotebookId: agent.catalog.skillsByNotebookId, + }, + user: agent.user, + }]; + })), + + }; +} + +function mapLegacyRuntimePreference(legacy: LegacyAgentRuntimePreference): AgentUserPreference { + const patch: AgentUserPreference = {}; + if (legacy.model?.key && legacy.model.key !== "inherit") { + patch.model = { id: legacy.model.key, ...(legacy.model.providerId ? { providerId: legacy.model.providerId } : {}) }; + } + if (legacy.access?.sandbox) patch.permission = legacy.access.sandbox; + if (legacy.mode) patch.mode = legacy.mode; + if (legacy.piCodeMode !== undefined) patch.mode = legacy.piCodeMode ? "code" : "writing"; + if (legacy.reasoningEffort) { + patch.reasoningEffort = legacy.reasoningEffort; + if (legacy.model?.key && legacy.model.key !== "inherit") { + patch.reasoningByModel = { + [modelPreferenceKey({ id: legacy.model.key, providerId: legacy.model.providerId })]: legacy.reasoningEffort, + }; + } + } + if (legacy.serviceTier) patch.serviceTier = legacy.serviceTier; + return patch; +} + +export const useAgentDataStore = create()( + persist( + (set, get) => ({ + legacyPreferencesMigrated: false, + userPreferenceRevisions: {}, + persistenceError: lastPersistenceError, + agents: createInitialAgents(), + loadState: { piMode: { status: "idle" } }, + + initializePiMode: () => { + if (pendingPiModeInitialization) return pendingPiModeInitialization; + if (get().loadState.piMode.status === "ready") return Promise.resolve(); + set((state) => ({ loadState: { ...state.loadState, piMode: { status: "loading" } } })); + const request = (async () => { + if (get().agents.pi.user.mode === undefined) { + const [features, legacyMode] = await Promise.all([ + piModels.getFeatures(), + get().legacyPreferencesMigrated ? undefined : readLegacyPiModePreference(), + ]); + // Another window may have migrated and cleared the old source already. + await useAgentDataStore.persist.rehydrate(); + const importedMode = legacyMode ?? (features.legacyCodeMode === undefined + ? undefined : features.legacyCodeMode ? "code" : "writing"); + set((state) => { + // A choice made during this read always wins over migration. + const previous = state.agents.pi.user; + if (previous.mode !== undefined) return {}; + const user = { ...previous, mode: importedMode ?? "writing" }; + const revisions = stampLegacyPreferenceFields(state.userPreferenceRevisions.pi ?? {}, previous, user); + if (importedMode === undefined) revisions.mode = DEFAULT_PREFERENCE_REVISION; + return { + agents: { ...state.agents, pi: { ...state.agents.pi, user } }, + userPreferenceRevisions: { + ...state.userPreferenceRevisions, + pi: revisions, + }, + }; + }); + // The persistence merge can retain a stronger choice from another window. + await useAgentDataStore.persist.rehydrate(); + } + // Remove the legacy source only after the migrated preference is durable. + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA) ?? "null"); + if (saved?.state?.agents?.pi?.user?.mode === get().agents.pi.user.mode) { + await piModels.clearLegacyCodeMode(); + } + } catch (error) { + logger.warn("Pi legacy mode cleanup deferred", { error }); + } + set((state) => ({ loadState: { ...state.loadState, piMode: { status: "ready" } } })); + })().catch((error) => { + set((state) => ({ loadState: { ...state.loadState, piMode: { status: "error", error: errorMessage(error) } } })); + throw error; + }).finally(() => { pendingPiModeInitialization = null; }); + pendingPiModeInitialization = request; + return request; + }, + + initializeLegacyPreferences: () => { + if (get().legacyPreferencesMigrated) return Promise.resolve(); + if (pendingLegacyPreferencesInitialization) return pendingLegacyPreferencesInitialization; + const request = (async () => { + const legacy = await agentWorkspace.getLegacyPreferences(); + const { useAgentSessionStore } = await import('./agent-session-store'); + get().migrateLegacyPreferences( + legacy ?? undefined, + useAgentSessionStore.getState().sessionMeta.settings, + ); + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA) ?? 'null'); + if (!saved?.state?.legacyPreferencesMigrated) { + logger.warn('Agent preference migration has not reached durable storage'); + } + } catch (error) { + logger.warn('Agent preference migration persistence check failed', { error }); + } + })().finally(() => { pendingLegacyPreferencesInitialization = null; }); + pendingLegacyPreferencesInitialization = request; + return request; + }, + + setModelCatalog: (agentType, catalog) => set((state) => ({ + agents: { + ...state.agents, + [agentType]: { + ...state.agents[agentType], + catalog: { ...state.agents[agentType].catalog, models: catalog }, + }, + }, + })), + + invalidateModelCatalog: (agentType) => set((state) => ({ + agents: { + ...state.agents, + [agentType]: { + ...state.agents[agentType], + catalog: { + ...state.agents[agentType].catalog, + models: { ...state.agents[agentType].catalog.models, updatedAt: 0 }, + }, + }, + }, + })), + + invalidateSkillCatalog: (agentType, notebookId) => set((state) => { + const current = state.agents[agentType].catalog.skillsByNotebookId[notebookId]; + if (!current) return state; + return { + agents: { + ...state.agents, + [agentType]: { + ...state.agents[agentType], + catalog: { + ...state.agents[agentType].catalog, + skillsByNotebookId: { + ...state.agents[agentType].catalog.skillsByNotebookId, + [notebookId]: { ...current, updatedAt: 0 }, + }, + }, + }, + }, + }; + }), + + loadModelCatalog: (agentType, loader, options) => { + const cached = get().agents[agentType].catalog.models; + const maxAgeMs = options?.maxAgeMs ?? MODEL_CATALOG_MAX_AGE_MS; + const isFresh = !!cached.updatedAt && Date.now() - cached.updatedAt < maxAgeMs; + if (!options?.force && isFresh) return Promise.resolve(cached); + const pending = pendingModelLoads.get(agentType); + if (pending) { + if (!options?.force) return pending; + return pending.catch(() => undefined).then(() => { + const nextPending = pendingModelLoads.get(agentType); + return nextPending ?? get().loadModelCatalog(agentType, loader, options); + }); + } + + const request = loader() + .then((catalog) => { + const stored = { ...catalog, updatedAt: catalog.updatedAt ?? Date.now() }; + get().setModelCatalog(agentType, stored); + return stored; + }) + .finally(() => pendingModelLoads.delete(agentType)); + pendingModelLoads.set(agentType, request); + return request; + }, + + setSkillCatalog: (agentType, notebookId, items) => set((state) => ({ + agents: { + ...state.agents, + [agentType]: { + ...state.agents[agentType], + catalog: { + ...state.agents[agentType].catalog, + skillsByNotebookId: { + ...state.agents[agentType].catalog.skillsByNotebookId, + [notebookId]: { items, updatedAt: Date.now() }, + }, + }, + }, + }, + })), + + loadSkillCatalog: (agentType, notebookId, loader, options) => { + const cached = get().agents[agentType].catalog.skillsByNotebookId[notebookId]; + const maxAgeMs = options?.maxAgeMs ?? SKILL_CATALOG_MAX_AGE_MS; + const isFresh = !!cached?.updatedAt && Date.now() - cached.updatedAt < maxAgeMs; + if (!options?.force && isFresh) return Promise.resolve(cached.items); + const key = skillLoadKey(agentType, notebookId); + const pending = pendingSkillLoads.get(key); + if (pending) { + if (!options?.force) return pending; + return pending.catch(() => undefined).then(() => { + const nextPending = pendingSkillLoads.get(key); + return nextPending ?? get().loadSkillCatalog(agentType, notebookId, loader, options); + }); + } + + const request = loader() + .then((items) => { + get().setSkillCatalog(agentType, notebookId, items); + return items; + }) + .finally(() => pendingSkillLoads.delete(key)); + pendingSkillLoads.set(key, request); + return request; + }, + + setUserPreference: (agentType, patch) => set((state) => { + const previous = state.agents[agentType].user; + const nextUser = { ...previous, ...patch }; + const revisions = stampChangedPreferenceFields( + state.userPreferenceRevisions[agentType] ?? {}, previous, nextUser, + ); + // Explicitly selecting an existing default/import is still a user choice. + for (const field of Object.keys(patch)) { + if (field !== "reasoningByModel" && (!revisions[field] || isImportedPreferenceRevision(revisions[field]))) { + revisions[field] = nextPreferenceRevision(); + } + } + const userPreferenceRevisions = { + ...state.userPreferenceRevisions, + [agentType]: revisions, + }; + return { + agents: { + ...state.agents, + [agentType]: { ...state.agents[agentType], user: nextUser }, + }, + userPreferenceRevisions, + }; + }), + + rememberModelReasoning: (agentType, model, effort) => { + const current = get().agents[agentType].user; + const reasoningByModel = { ...current.reasoningByModel }; + if (model) reasoningByModel[modelPreferenceKey(model)] = effort; + get().setUserPreference(agentType, { reasoningEffort: effort, reasoningByModel }); + }, + + migrateLegacyPreferences: (runtime, codexSessionSettings) => { + if (get().legacyPreferencesMigrated) return; + if (!runtime && !codexSessionSettings) { + set({ legacyPreferencesMigrated: true }); + return; + } + set((state) => { + const agents = { ...state.agents }; + const userPreferenceRevisions = { ...state.userPreferenceRevisions }; + for (const agentType of AGENT_TYPES) { + const legacyRuntimeKey = agentType === "claudecode" ? "claude" : agentType === "dsh" ? "deepseek-harness" : agentType; + const legacy = runtime?.[legacyRuntimeKey]; + const sessionFallback: AgentUserPreference = agentType === "codex" && codexSessionSettings + ? { + ...(codexSessionSettings.agentCodexModel && codexSessionSettings.agentCodexModel !== "inherit" + ? { model: { id: codexSessionSettings.agentCodexModel } } + : {}), + ...(codexSessionSettings.agentPermissionMode + ? { permission: codexSessionSettings.agentPermissionMode } + : {}), + ...(codexSessionSettings.agentCodexReasoningEffort + ? { + reasoningEffort: codexSessionSettings.agentCodexReasoningEffort, + ...(codexSessionSettings.agentCodexModel && codexSessionSettings.agentCodexModel !== "inherit" + ? { reasoningByModel: { + [modelPreferenceKey({ id: codexSessionSettings.agentCodexModel })]: codexSessionSettings.agentCodexReasoningEffort, + } } + : {}), + } + : {}), + } + : {}; + const migrated = legacy + ? { ...sessionFallback, ...mapLegacyRuntimePreference(legacy) } + : sessionFallback; + if (!Object.keys(migrated).length) continue; + const previousUser = agents[agentType].user; + const nextUser = { + ...migrated, + ...previousUser, + ...(migrated.reasoningByModel || previousUser.reasoningByModel + ? { reasoningByModel: { ...migrated.reasoningByModel, ...previousUser.reasoningByModel } } + : {}), + }; + userPreferenceRevisions[agentType] = stampLegacyPreferenceFields( + userPreferenceRevisions[agentType] ?? {}, + previousUser, + nextUser, + ); + agents[agentType] = { + ...agents[agentType], + user: nextUser, + }; + } + return { agents, userPreferenceRevisions, legacyPreferencesMigrated: true }; + }); + }, + + }), + { + name: STORAGE_KEYS.AGENT_DATA, + version: 2, + storage: createJSONStorage(() => agentStorage), + partialize: persistedAgentDataState, + migrate: (persisted) => mergePersistedAgentSlices({}, persisted as Record), + merge: (persisted, current) => { + const stored = persisted as Partial; + const merged = mergePersistedAgentSlices(current as unknown as Record, stored as Record); + const agents = merged.agents as Record; + for (const agentType of AGENT_TYPES) { + agents[agentType] = { + ...current.agents[agentType], + ...agents[agentType], + catalog: { + ...current.agents[agentType].catalog, + ...agents[agentType].catalog, + capabilities: current.agents[agentType].catalog.capabilities, + }, + }; + } + return { + ...current, + agents, + legacyPreferencesMigrated: !!merged.legacyPreferencesMigrated, + userPreferenceRevisions: merged.userPreferenceRevisions, + }; + }, + }, + ), +); + +/** Resolve legacy defaults before a runtime config is built for a request. */ +export async function ensureAgentPreferencesReady(agentType?: AgentTypeKey): Promise { + const state = useAgentDataStore.getState(); + await state.initializeLegacyPreferences().catch((error) => { + logger.warn("Agent preference migration deferred", { error }); + }); + if (!agentType || agentType === "pi") { + await state.initializePiMode(); + } +} + +if (typeof window !== "undefined") { + window.addEventListener(PERSISTENCE_STATUS_EVENT, (event) => { + const error = (event as CustomEvent<{ error: string | null }>).detail?.error ?? null; + if (useAgentDataStore.getState().persistenceError !== error) { + useAgentDataStore.setState({ persistenceError: error }); + } + }); + window.addEventListener("storage", (event) => { + if (event.key === STORAGE_KEYS.AGENT_DATA || event.key === null) { + void Promise.resolve(useAgentDataStore.persist.rehydrate()).then(() => { + const raw = agentStorage.getItem(STORAGE_KEYS.AGENT_DATA); + if (!raw) return; + try { + const persisted = JSON.parse(raw) as { state?: Record }; + const currentSnapshot = persistedAgentDataState(useAgentDataStore.getState()); + if (persisted.state && stableSerialize(currentSnapshot) !== stableSerialize(persisted.state)) { + // Re-write the field-wise merge so simultaneous windows converge + // even when both tabs wrote a stale whole-store snapshot. + useAgentDataStore.setState((state) => ({ ...state })); + } + } catch (error) { + logger.warn("Agent cache reconciliation failed", { error }); + } + }); + } + }); +} diff --git a/app/flowix-web/features/agent/store/agent-history-adapters.test.ts b/app/flowix-web/features/agent/store/agent-history-adapters.test.ts index 2e725aedd..e3c0cdaab 100644 --- a/app/flowix-web/features/agent/store/agent-history-adapters.test.ts +++ b/app/flowix-web/features/agent/store/agent-history-adapters.test.ts @@ -95,7 +95,7 @@ describe("Pi backend history pages", () => { }); describe.each([ - ["claude", client.getClaudeThreadPage], + ["claudecode", client.getClaudeThreadPage], ["opencode", client.getOpenCodeThreadPage], ] as const)("%s full history", (runtime, getPage) => { beforeEach(() => getPage.mockReset()); diff --git a/app/flowix-web/features/agent/store/agent-history-adapters.ts b/app/flowix-web/features/agent/store/agent-history-adapters.ts index 9ba9b2e84..3d2687d23 100644 --- a/app/flowix-web/features/agent/store/agent-history-adapters.ts +++ b/app/flowix-web/features/agent/store/agent-history-adapters.ts @@ -171,11 +171,11 @@ function createCodexHistoryAdapter(): AgentHistoryAdapter { function createClaudeHistoryAdapter(): AgentHistoryAdapter { return { - typeKey: "claude", + typeKey: "claudecode", externalSessionBacked: true, listThreads: () => agentClient.listClaudeThreads(), async getFullHistory(threadId) { - return readAllHistoryPages("claude", threadId); + return readAllHistoryPages("claudecode", threadId); }, getInitialHistory: (threadId, limit) => agentClient.getClaudeThreadPage(threadId, null, limit), @@ -280,7 +280,7 @@ function createPiHistoryAdapter(): AgentHistoryAdapter { function createDeepSeekHarnessHistoryAdapter(): AgentHistoryAdapter { return { - typeKey: "deepseek-harness", + typeKey: "dsh", externalSessionBacked: true, listThreads: () => agentClient.listDeepSeekHarnessThreads(), async getFullHistory(threadId) { @@ -302,11 +302,11 @@ const historyAdapters: Partial> = { // Codex history is projected by the backend from Codex App Server threads. codex: createCodexHistoryAdapter(), pi: createPiHistoryAdapter(), - claude: createClaudeHistoryAdapter(), + claudecode: createClaudeHistoryAdapter(), hermes: createHermesHistoryAdapter(), // OpenCode reads its provider session through the ACP session/load path. opencode: createOpenCodeHistoryAdapter(), - "deepseek-harness": createDeepSeekHarnessHistoryAdapter(), + dsh: createDeepSeekHarnessHistoryAdapter(), }; export function getAgentHistoryAdapter(typeKey: AgentTypeKey): AgentHistoryAdapter { diff --git a/app/flowix-web/features/agent/store/agent-message-font-store.ts b/app/flowix-web/features/agent/store/agent-message-font-store.ts new file mode 100644 index 000000000..295eacf6b --- /dev/null +++ b/app/flowix-web/features/agent/store/agent-message-font-store.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { STORAGE_KEYS } from '@/lib/constants'; +import { + getTypographyFontFamily, + TYPOGRAPHY_FONT_MODES, + type TypographyFontMode, +} from '@/lib/typography-font-mode'; + +export type AgentMessageFontMode = TypographyFontMode; +export const AGENT_MESSAGE_SIZE_MODES = ['small', 'medium', 'large'] as const; +export type AgentMessageSizeMode = (typeof AGENT_MESSAGE_SIZE_MODES)[number]; + +export const AGENT_MESSAGE_SIZE_SCALE: Record = { + small: 14 / 15, + medium: 1, + large: 16 / 15, +}; + +export function getAgentMessageTypographyCssVars( + fontMode: AgentMessageFontMode, + sizeMode: AgentMessageSizeMode, +) { + return { + '--agent-message-font-family': `'Flowix Small Emoji', ${getTypographyFontFamily(fontMode)}`, + '--agent-message-base-font-size': '15px', + '--agent-message-size-scale': AGENT_MESSAGE_SIZE_SCALE[sizeMode], + } as const; +} + +interface AgentMessageFontStore { + fontMode: AgentMessageFontMode; + setFontMode: (fontMode: AgentMessageFontMode) => void; + sizeMode: AgentMessageSizeMode; + setSizeMode: (sizeMode: AgentMessageSizeMode) => void; +} + +function isAgentMessageFontMode(value: unknown): value is AgentMessageFontMode { + return typeof value === 'string' && (TYPOGRAPHY_FONT_MODES as readonly string[]).includes(value); +} + +function isAgentMessageSizeMode(value: unknown): value is AgentMessageSizeMode { + return typeof value === 'string' && (AGENT_MESSAGE_SIZE_MODES as readonly string[]).includes(value); +} + +export const useAgentMessageFontStore = create()( + persist( + (set) => ({ + fontMode: 'standard', + setFontMode: (fontMode) => set({ fontMode }), + sizeMode: 'medium', + setSizeMode: (sizeMode) => set({ sizeMode }), + }), + { + name: STORAGE_KEYS.AGENT_MESSAGE_FONT, + storage: createJSONStorage(() => localStorage), + partialize: ({ fontMode, sizeMode }) => ({ fontMode, sizeMode }), + merge: (persisted, current) => { + const storedMode = (persisted as Partial | undefined)?.fontMode; + return { + ...current, + fontMode: isAgentMessageFontMode(storedMode) ? storedMode : 'standard', + sizeMode: isAgentMessageSizeMode((persisted as Partial | undefined)?.sizeMode) + ? (persisted as Partial).sizeMode! + : 'medium', + }; + }, + }, + ), +); + +// Zustand's persist middleware writes localStorage but does not subscribe to +// changes made by sibling windows. Keep dedicated conversation windows and +// the editor's embedded Thread Cards on the same typography selection. +if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if (event.key === STORAGE_KEYS.AGENT_MESSAGE_FONT || event.key === null) { + void useAgentMessageFontStore.persist.rehydrate(); + } + }); +} diff --git a/app/flowix-web/features/agent/store/agent-runtime-store.ts b/app/flowix-web/features/agent/store/agent-runtime-store.ts index 0a2b0b495..01f262171 100644 --- a/app/flowix-web/features/agent/store/agent-runtime-store.ts +++ b/app/flowix-web/features/agent/store/agent-runtime-store.ts @@ -1,13 +1,64 @@ import { create } from 'zustand'; -import { agent, type AgentRuntimeAvailability } from '@platform/tauri/client'; +import { agent } from '@platform/tauri/client'; import { subscribe } from '@platform/tauri/event-bus'; -import type { AgentTypeKey } from '@/types/agent'; +import type { AgentRuntimeSnapshot, AgentTypeKey } from '@/types/agent'; const RUNTIME_STATUS_TTL_MS = 120_000; -type RuntimeStatusByType = Partial>; +type RuntimeStatusByType = Partial>; + +type RuntimeStatusRecord = Record; + +function isRecord(value: unknown): value is RuntimeStatusRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Normalize the current nested protocol and the flat response returned by + * older desktop hosts. During development the web bundle can hot reload before + * the Tauri process is restarted, so the IPC boundary must tolerate both. + */ +function normalizeRuntimeStatusReport(report: unknown): RuntimeStatusByType { + if (!isRecord(report)) return {}; + const source = isRecord(report.agents) ? report.agents : report; + const aliases: Record = { + pi: ['pi'], + codex: ['codex'], + claudecode: ['claudecode', 'claude'], + opencode: ['opencode'], + hermes: ['hermes'], + dsh: ['dsh', 'deepseek-harness', 'deepseek_harness'], + }; + const result: RuntimeStatusByType = {}; + + for (const [agentId, keys] of Object.entries(aliases) as [AgentTypeKey, string[]][]) { + const value = keys.map((key) => source[key]).find(isRecord); + if (!value) continue; + + if ( + ['unknown', 'installed', 'missing'].includes(String(value.installation)) && + ['unknown', 'ready', 'blocked'].includes(String(value.readiness)) + ) { + result[agentId] = value as unknown as AgentRuntimeSnapshot; + continue; + } + + // Compatibility with the former { available, installed, reason } shape. + const installed = value.installed === true || value.available === true; + const details = { + reasonCode: null, + reason: typeof value.reason === 'string' ? value.reason : null, + }; + result[agentId] = installed + ? { ...details, installation: 'installed', readiness: value.available === false ? 'blocked' : 'ready' } + : { ...details, installation: 'missing', readiness: 'unknown' }; + } + + return result; +} let inFlightRefresh: Promise | null = null; +let refreshGeneration = 0; let dshRuntimeStatusUnsubscribe: (() => void) | null = null; function ensureDshRuntimeStatusSubscription(): void { @@ -21,7 +72,7 @@ export interface AgentRuntimeState { statusByType: RuntimeStatusByType; isChecking: boolean; lastCheckedAt: number | null; - refresh: (options?: { force?: boolean; type?: AgentTypeKey }) => Promise; + refresh: (options?: { force?: boolean }) => Promise; refreshIfStale: () => Promise; } @@ -36,43 +87,57 @@ export const useAgentRuntimeStore = create((set, get) => ({ refresh: async (options) => { ensureDshRuntimeStatusSubscription(); - const { force = false, type } = options ?? {}; + const { force = false } = options ?? {}; if (!force && isFresh(get().lastCheckedAt)) return; - if (inFlightRefresh) { - await inFlightRefresh; - if (!force && isFresh(get().lastCheckedAt)) return; - } + if (force) refreshGeneration += 1; + if (!inFlightRefresh) { + const refreshTask = (async () => { + let completedGeneration = -1; + do { + const generationAtStart = refreshGeneration; + set({ isChecking: true }); + let status: RuntimeStatusByType; + try { + const report = await agent.runtimeStatus(); + status = normalizeRuntimeStatusReport(report); + } catch (err) { + // A force refresh that arrived during this request still needs its + // own attempt, even when the older request fails. + if (generationAtStart !== refreshGeneration) continue; + throw err; + } + completedGeneration = generationAtStart; + set({ + statusByType: status, + lastCheckedAt: Date.now(), + isChecking: false, + }); + } while (completedGeneration !== refreshGeneration); + })(); - set({ isChecking: true }); - const refreshTask = (async () => { - const status = await agent.runtimeStatus(); - set((current) => { - const nextStatus: RuntimeStatusByType = type - ? { ...current.statusByType, [type]: status[type] } - : status; - return { - statusByType: nextStatus, - lastCheckedAt: Date.now(), - isChecking: false, - }; + const handledRefreshTask = refreshTask.catch((err) => { + console.warn('[agent-runtime-store] Failed to refresh runtime status:', err); + set({ isChecking: false }); }); - })(); - - inFlightRefresh = refreshTask; - try { - await refreshTask; - } catch (err) { - console.warn('[agent-runtime-store] Failed to refresh runtime status:', err); - set({ isChecking: false }); - } finally { - if (inFlightRefresh === refreshTask) { - inFlightRefresh = null; + inFlightRefresh = handledRefreshTask; + try { + await handledRefreshTask; + } finally { + if (inFlightRefresh === handledRefreshTask) inFlightRefresh = null; } + return; } + + await inFlightRefresh; }, refreshIfStale: async () => { await get().refresh(); }, })); + +// Recover a bad snapshot left in memory by a previously hot-reloaded bundle. +if (!isRecord(useAgentRuntimeStore.getState().statusByType)) { + useAgentRuntimeStore.setState({ statusByType: {}, lastCheckedAt: null }); +} diff --git a/app/flowix-web/features/agent/store/agent-session-actions.test.ts b/app/flowix-web/features/agent/store/agent-session-actions.test.ts index eeffe1d9c..aa23b9485 100644 --- a/app/flowix-web/features/agent/store/agent-session-actions.test.ts +++ b/app/flowix-web/features/agent/store/agent-session-actions.test.ts @@ -138,13 +138,19 @@ vi.mock("@features/document/store/document-store", () => ({ }, })); -vi.mock("@features/agent/store/agent-access-store", () => ({ - useAgentAccessStore: { - getState: () => ({ - config: agentAccessMock.config, - }), - }, -})); +vi.mock("@features/agent/store/agent-workspace-store", async (importOriginal) => { + const actual = await importOriginal(); + const getState = () => ({ ...actual.useAgentWorkspaceStore.getState(), workspaceActions: { + ...actual.useAgentWorkspaceStore.getState().workspaceActions, + getNotebookFiles: (notebookId: string | null | undefined) => notebookId + ? agentAccessMock.config.defaults?.files?.[notebookId] : undefined, + } }); + return { ...actual, ensureAgentWorkspaceReady: vi.fn(async () => {}), useAgentWorkspaceStore: Object.assign( + (selector: Parameters[0]) => selector ? selector(getState()) : getState(), + actual.useAgentWorkspaceStore, + { getState }, + ) }; +}); vi.mock("@features/preferences/store/user-settings-store", () => ({ useUserSettingsStore: { @@ -254,7 +260,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { ); const instance = useAgentSessionStore.getState().createInstance({ - agentType: "deepseek-harness", + agentType: "dsh", title: "First title", threadId: null, source: { kind: "thread-card" }, @@ -364,7 +370,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { useChatStore.getState().reconcileRunningRunsFromSnapshot({ [threadId]: { runId: "run-ended-offline", - agentType: "deepseek-harness", + agentType: "dsh", startedAt: Date.now() - 10_000, currentTool: null, }, @@ -378,7 +384,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { expect(useChatStore.getState().threadStates[threadId].isLoading).toBe(false); expect(reconcileCompletedRun).toHaveBeenCalledWith( - "deepseek-harness", + "dsh", threadId, "run-ended-offline", ); @@ -395,8 +401,8 @@ describe("chat-store Agent Thread Card streaming flow", () => { ); const threadId = "active-history-after-ipc-failure"; useChatStore.setState({ - activeThreadIds: { "deepseek-harness": threadId }, - threadTypes: { [threadId]: "deepseek-harness" }, + activeThreadIds: { "dsh": threadId }, + threadTypes: { [threadId]: "dsh" }, }); vi.mocked(agent.runningThreads).mockRejectedValueOnce( new Error("ipc://localhost/agent_running_threads was blocked"), @@ -407,7 +413,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { await reconcileAgentRunsAndRefreshEndedHistory(); - expect(loadMessages).toHaveBeenCalledWith("deepseek-harness", threadId); + expect(loadMessages).toHaveBeenCalledWith("dsh", threadId); }); it("routes streamed assistant text into the same thread state consumed by Thread Card", async () => { @@ -418,7 +424,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "thread-card-flowix"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatchAgentChunk({ kind: "stream_start", thread_id: threadId, run_id: "run-flowix" }); store.dispatchAgentChunk({ kind: "text", @@ -486,7 +492,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "thread-card-optimistic-user"; - await store.sendMessageToThread(threadId, "Hello from user", "deepseek-harness"); + await store.sendMessageToThread(threadId, "Hello from user", "dsh"); const renderMessages = useAgentConversationStore.getState().messageStates[threadId]?.messages ?? @@ -529,7 +535,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const { useAgentSessionStore } = await import("@features/agent/store/agent-session-store"); vi.mocked(agent.chatStream).mockRejectedValueOnce(new Error("request was rejected")); await expect(useAgentSessionStore.getState().sendMessageToThread( - "thread-rejected-dispatch", "keep input", "deepseek-harness", { rejectOnFailure: true }, + "thread-rejected-dispatch", "keep input", "dsh", { rejectOnFailure: true }, )).rejects.toThrow("request was rejected"); expect(useAgentSessionStore.getState().threadProjections["thread-rejected-dispatch"].runs.isLoading).toBe(false); }); @@ -541,21 +547,21 @@ describe("chat-store Agent Thread Card streaming flow", () => { const activeThreadId = "thread-card-active-title"; const cardThreadId = "thread-card-secondary-title"; const cardInstance = useAgentConversationStore.getState().createInstance({ - agentType: "deepseek-harness", + agentType: "dsh", title: "B title", threadId: cardThreadId, source: { kind: "thread-card" }, }); useChatStore.setState({ - activeThreadIds: { "deepseek-harness": activeThreadId }, + activeThreadIds: { "dsh": activeThreadId }, currentThreadTitles: { [activeThreadId]: "A title" }, }); await useChatStore.getState().sendMessageToThread( cardThreadId, "message from B", - "deepseek-harness", + "dsh", { instanceId: cardInstance.instanceId, conversationTitle: "B title", @@ -564,7 +570,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { ); expect(useChatStore.getState().currentThreadTitles[activeThreadId]).toBe("A title"); - expect(useChatStore.getState().threadLists["deepseek-harness"]).toEqual([]); + expect(useChatStore.getState().threadLists["dsh"]).toEqual([]); expect( useAgentConversationStore .getState() @@ -589,7 +595,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { await useChatStore.getState().sendMessageToThread( threadId, "follow up from canonical", - "deepseek-harness", + "dsh", { currentNoteContent: "note context should not be appended", }, @@ -609,7 +615,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const threadId = "thread-low-frequency-conversation-live"; const toolCallId = "tool-live-state"; - useChatStore.getState().bindThreadType(threadId, "deepseek-harness"); + useChatStore.getState().bindThreadType(threadId, "dsh"); await seedProjection(threadId, (projection) => ({ ...projection, messages: [ @@ -633,7 +639,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { id: toolCallId, name: "Read", result: { content: "file contents from conversation state" }, - agent_type: "deepseek-harness", + agent_type: "dsh", }); const message = @@ -661,19 +667,19 @@ describe("chat-store Agent Thread Card streaming flow", () => { const runId = "claude-live-reasoning-run"; const store = useChatStore.getState(); - store.bindThreadType(threadId, "claude"); + store.bindThreadType(threadId, "claudecode"); const chunks: AgentChunk[] = [ { kind: "stream_start", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", }, { kind: "reasoning", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", text: "first thought\n\n", message_id: "reasoning-provider-message-1-block-0", message_phase: "updated", @@ -686,7 +692,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "tool_call", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", id: "tool-cycle-1", name: "Bash", input: { command: "pwd" }, @@ -700,7 +706,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "tool_result", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", id: "tool-cycle-1", name: "Bash", result: { content: "/workspace" }, @@ -714,7 +720,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "reasoning", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", text: "second thought", message_id: "reasoning-provider-message-2-block-0", message_phase: "updated", @@ -727,7 +733,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "text", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", text: "final answer", message_id: "assistant-provider-message-2-block-1", message_phase: "updated", @@ -740,7 +746,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "stream_end", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", reason: null, }, ]; @@ -755,13 +761,13 @@ describe("chat-store Agent Thread Card streaming flow", () => { expect(reasoning).toHaveLength(1); expect(reasoning[0]).toMatchObject({ - id: `msg:claude:${runId}:reasoning:reasoning-${runId}`, + id: `msg:claudecode:${runId}:reasoning:reasoning-${runId}`, content: "first thought\n\nsecond thought", isCompleted: true, }); expect(tools).toHaveLength(1); expect(tools[0]).toMatchObject({ - toolCallId: `msg:claude:${runId}:tool-call:tool-cycle-1`, + toolCallId: `msg:claudecode:${runId}:tool-call:tool-cycle-1`, isLoading: false, }); expect(messages.map((message) => message.role)).toEqual([ @@ -786,18 +792,18 @@ describe("chat-store Agent Thread Card streaming flow", () => { const runId = "claude-reasoning-only-run"; const store = useChatStore.getState(); - store.bindThreadType(threadId, "claude"); + store.bindThreadType(threadId, "claudecode"); store.dispatchAgentChunk({ kind: "stream_start", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", }); store.dispatchAgentChunk({ kind: "reasoning", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", text: "reasoning without final text", message_id: "reasoning-provider-message-only-block-0", message_phase: "updated", @@ -807,7 +813,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "stream_end", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", reason: null, }); @@ -815,7 +821,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { useAgentConversationStore.getState().messageStates[threadId]; expect(messageState.messages).toMatchObject([ { - id: `msg:claude:${runId}:reasoning:reasoning-${runId}`, + id: `msg:claudecode:${runId}:reasoning:reasoning-${runId}`, role: "reasoning", content: "reasoning without final text", isCompleted: true, @@ -834,13 +840,13 @@ describe("chat-store Agent Thread Card streaming flow", () => { const runId = "claude-live-envelope-run"; const store = useChatStore.getState(); - store.bindThreadType(threadId, "claude"); + store.bindThreadType(threadId, "claudecode"); const chunks: AgentChunk[] = [ { kind: "stream_start", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", }, ...[ ["d9193ae4-86b5-47a6-9e85-1bb4ef0acc1c", "first "], @@ -850,7 +856,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "text", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", text, message_id: `assistant-${envelopeId}-block-1`, message_phase: "updated", @@ -864,7 +870,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "tool_call", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", id: "legacy-text-tool", name: "Bash", input: { command: "pwd" }, @@ -878,7 +884,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "tool_result", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", id: "legacy-text-tool", name: "Bash", result: { content: "/workspace" }, @@ -896,7 +902,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "text", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", text, message_id: `assistant-${envelopeId}-block-1`, message_phase: "updated", @@ -910,7 +916,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { kind: "stream_end", thread_id: threadId, run_id: runId, - agent_type: "claude", + agent_type: "claudecode", reason: null, }, ]; @@ -941,7 +947,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { ); const threadId = "thread-buffered-conversation-live"; - useChatStore.getState().bindThreadType(threadId, "deepseek-harness"); + useChatStore.getState().bindThreadType(threadId, "dsh"); // Phase 2 (2026-08-02): session-store 是真源, 直接 seed 它. mirror 会自动 // 把同步状态写到 conv-store 与 chat-store, 断言仍走两个老 store. await seedProjection(threadId, (p) => ({ @@ -962,7 +968,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { thread_id: threadId, run_id: "run-buffered-live", text: "world", - agent_type: "deepseek-harness", + agent_type: "dsh", }); await flushAnimationFrame(); @@ -987,12 +993,12 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "thread-card-missed-start"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatchAgentChunk({ kind: "text", thread_id: threadId, run_id: "run-restored", - agent_type: "deepseek-harness", + agent_type: "dsh", text: "still running", }); @@ -1104,14 +1110,14 @@ describe("chat-store Agent Thread Card streaming flow", () => { useChatStore.getState().reconcileRunningRunsFromSnapshot({ [threadId]: { runId: "run-snapshot-instance-running", - agentType: "deepseek-harness", + agentType: "dsh", startedAt: 1234, currentTool: "shell", }, }); expect(useAgentConversationStore.getState().findByThreadId(threadId)).toMatchObject({ - agentType: "deepseek-harness", + agentType: "dsh", title: "Snapshot restored title", threadId, source: { kind: "thread-card" }, @@ -1217,7 +1223,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "thread-card-stale-running"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatchAgentChunk({ kind: "stream_start", thread_id: threadId, @@ -1264,7 +1270,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { vi.mocked(agent.runningThreads).mockReturnValueOnce(new Promise((resolve) => { finishQuery = resolve; })); const pending = useAgentSessionStore.getState().reconcileRunningRuns(); useAgentSessionStore.getState().dispatchAgentChunk({ - kind: "stream_start", thread_id: threadId, run_id: "new-run", agent_type: "claude", + kind: "stream_start", thread_id: threadId, run_id: "new-run", agent_type: "claudecode", }); now += 10_000; finishQuery({}); @@ -1318,8 +1324,8 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "thread-card-optimistic-run"; - store.bindThreadType(threadId, "deepseek-harness"); - await store.sendMessageToThread(threadId, "hello optimistic", "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); + await store.sendMessageToThread(threadId, "hello optimistic", "dsh"); expect(useChatStore.getState().threadStates[threadId].isLoading).toBe(true); store.reconcileRunningRunsFromSnapshot({}); @@ -1360,7 +1366,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { // 鍒涘缓涓€涓湁 content 鐨?thread, 鐒跺悗 dispatch 涓€浜?chunk 璁?threadStates // 绱Н messages / runs 鈹€鈹€ 杩欐槸 deleteThread 涔嬪墠鐨勭姸鎬併€? - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatchAgentChunk({ kind: "stream_start", thread_id: threadId, @@ -1371,7 +1377,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { thread_id: threadId, run_id: "run-1", text: "answer body that should be wiped on delete", - agent_type: "deepseek-harness", + agent_type: "dsh", }); store.dispatchAgentChunk({ kind: "tool_call", @@ -1380,7 +1386,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { id: "call-x", name: "read", input: {}, - agent_type: "deepseek-harness", + agent_type: "dsh", }); // rAF flush 璁?text chunk 鐪熸钀藉埌 messages / pendingAssistantId 涓? // (涓?`routes streamed assistant text` 娴嬭瘯鍚屽舰 鈹€ 绂昏繖鍧楃殑璇?text @@ -1406,7 +1412,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { it("deleteThread clears threadTypes and reverse-mapped externalSessionResolutions", async () => { // 淇 #7: deleteThread 涔嬪墠娌℃竻 `state.threadTypes[threadId]`, 鐣欎笅瀛ゅ効 - // 鏉$洰 鈹€鈹€ 鍚庣画 `get().threadTypes[threadId] ?? "deepseek-harness"` 浼氭嬁鍒版棫 type, + // 鏉$洰 鈹€鈹€ 鍚庣画 `get().threadTypes[threadId] ?? "dsh"` 浼氭嬁鍒版棫 type, // 璇垽 dispatch 璺緞銆?鍚屾椂鍙嶅悜鏄犲皠 `externalSessionResolutions[local] === threadId` // (鍗?local id 宸茬粡琚?resolve 鍒拌繖涓鍒犵殑 thread) 涔熻娓? 鍚﹀垯 findByThreadId // 浼氳鍛戒腑宸插垹 id銆? @@ -1974,7 +1980,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const secondThreadId = "thread-card-flowix"; store.bindThreadType(firstThreadId, "codex"); - store.bindThreadType(secondThreadId, "deepseek-harness"); + store.bindThreadType(secondThreadId, "dsh"); const chunks: AgentChunk[] = [ { kind: "stream_start", thread_id: firstThreadId, run_id: "run-codex" }, @@ -2004,7 +2010,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { state.threadStates[secondThreadId].runs[ state.threadStates[secondThreadId].activeRunId ?? "" ]?.agentType, - ).toBe("deepseek-harness"); + ).toBe("dsh"); }); it("loads Codex history through paged IPC", async () => { @@ -2135,7 +2141,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const messages = await useAgentSessionStore .getState() - .reloadMessagesFromHistory("deepseek-harness", threadId); + .reloadMessagesFromHistory("dsh", threadId); expect(agent.getDeepSeekHarnessThread).toHaveBeenCalledWith(threadId); expect(messages).toEqual( @@ -2218,14 +2224,14 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "claude-history-tool-merge"; - store.bindThreadType(threadId, "claude"); + store.bindThreadType(threadId, "claudecode"); store.dispatchAgentChunk({ kind: "tool_call", thread_id: threadId, id: "toolu_1", name: "Read", input: { file_path: "README.md" }, - agent_type: "claude", + agent_type: "claudecode", }); store.dispatchAgentChunk({ kind: "tool_result", @@ -2233,7 +2239,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { id: "toolu_1", name: "", result: { content: "file contents" }, - agent_type: "claude", + agent_type: "claudecode", }); vi.mocked(agent.listClaudeThreads).mockResolvedValueOnce([ @@ -2318,7 +2324,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const store = useChatStore.getState(); const threadId = "thread-card-tool"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatchAgentChunk({ kind: "stream_start", thread_id: threadId, run_id: "run-tool" }); store.dispatchAgentChunk({ kind: "tool_call", @@ -2574,7 +2580,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); const store = useChatStore.getState(); - // 鍒濆 activeAgentTypeKey (DEFAULT_AGENT_TYPE_KEY 閫氬父鏄?'deepseek-harness', 浣嗕笉渚濊禆鍏蜂綋鍊? + // 鍒濆 activeAgentTypeKey (DEFAULT_AGENT_TYPE_KEY 閫氬父鏄?'dsh', 浣嗕笉渚濊禆鍏蜂綋鍊? const initialType = useChatStore.getState().activeAgentTypeKey; // 鍒囧埌 codex thread 鈹€鈹€ 浠呮洿鏂?activeThreadIds.codex, 涓嶅姩 activeAgentTypeKey銆? @@ -2584,7 +2590,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { // 鍒囧埌 flowix thread 鈹€鈹€ 鍚屾牱涓嶅姩 activeAgentTypeKey銆? store.setActiveThreadId("flowix-thread-1"); - expect(useChatStore.getState().activeThreadIds["deepseek-harness"]).toBe("flowix-thread-1"); + expect(useChatStore.getState().activeThreadIds["dsh"]).toBe("flowix-thread-1"); expect(useChatStore.getState().activeAgentTypeKey).toBe(initialType); // setActiveAgentThread 浠嶇劧鍚屾涓よ€?鈹€鈹€ 杩欐槸璺?runtime 鍒囨崲鐨勬樉寮忓叆鍙c€? @@ -2609,7 +2615,7 @@ describe("chat-store Agent Thread Card streaming flow", () => { // 鈹€鈹€ 鎯呭舰 2: thread 璺戣繃浣嗗凡鑷劧缁撴潫銆? const finishedThreadId = "thread-stop-already-ended"; - store.bindThreadType(finishedThreadId, "deepseek-harness"); + store.bindThreadType(finishedThreadId, "dsh"); store.dispatchAgentChunk({ kind: "stream_start", thread_id: finishedThreadId, @@ -2674,6 +2680,18 @@ describe("chat-store Agent Thread Card streaming flow", () => { expect(payload).not.toHaveProperty("codexModel"); }); + it("sends using canonical Agent preferences instead of stale session settings", async () => { + const { useAgentDataStore } = await import("./agent-data-store"); + const { useAgentSessionStore } = await import("./agent-session-store"); + const { agent } = await import("@platform/tauri/client"); + const store = useAgentDataStore.getState(); + store.setUserPreference("codex", { model: { id: "new-model" }, permission: "read-only" }); + store.rememberModelReasoning("codex", { id: "new-model" }, "low"); + useAgentSessionStore.getState().setSessionMeta((meta) => ({ ...meta, settings: { ...meta.settings, agentCodexModel: "stale-model", agentPermissionMode: "danger-full-access" } })); + await useAgentSessionStore.getState().sendMessageToThread("canonical-preferences", "hello", "codex"); + expect(vi.mocked(agent.chatStream).mock.lastCall?.[1].runtimeConfig?.codex).toMatchObject({ model: "new-model", permissionMode: "read-only", reasoningEffort: "low" }); + }); + it("derives Codex cwd and workspacePaths from notebook folder defaults + current notebook", async () => { const { agent } = await import("@platform/tauri/client"); const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); @@ -2683,12 +2701,10 @@ describe("chat-store Agent Thread Card streaming flow", () => { path: "D:\\projects\\flowix", }; agentAccessMock.config = { - // defaults.folders 里的每个 folder 必须在 entries 里有对应的 - // enabled && !missing 授权条目, 否则 resolveAuthorizedDefaultFiles - // 会把它收窄掉 (防越权)。 + // The workspace adapter intersects notebook add-dirs with enabled access entries. entries: [ { id: "e-notes-main", kind: "folder", path: "D:\\notes\\main", name: "main", enabled: true, missing: false }, - { id: "e-flowix", kind: "folder", path: "D:\\projects\\flowix", name: "deepseek-harness", enabled: true, missing: false }, + { id: "e-flowix", kind: "folder", path: "D:\\projects\\flowix", name: "dsh", enabled: true, missing: false }, ], defaults: { files: { @@ -2839,30 +2855,30 @@ describe("chat-store Agent Thread Card streaming flow", () => { it("renames local agent threads through the standard action", async () => { const { agent } = await import("@platform/tauri/client"); const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); - const threadId = "thread-card-rename-gemini"; + const threadId = "thread-card-rename-codex"; vi.mocked(agent.listLocalAgentThreads).mockResolvedValueOnce([ { threadId, title: "New title", createdAt: 1, updatedAt: 2 }, ]); useChatStore.setState((state) => ({ - threadTypes: { ...state.threadTypes, [threadId]: "gemini" }, + threadTypes: { ...state.threadTypes, [threadId]: "codex" }, threadLists: { ...state.threadLists, - gemini: [{ threadId, title: "Old title", createdAt: 1, updatedAt: 1 }], + codex: [{ threadId, title: "Old title", createdAt: 1, updatedAt: 1 }], }, })); await useChatStore .getState() - .renameThread(threadId, " New title ", "gemini"); + .renameThread(threadId, " New title ", "codex"); const state = useChatStore.getState(); expect(state.currentThreadTitles[threadId]).toBe("New title"); - expect(state.threadLists.gemini?.[0]?.title).toBe("New title"); + expect(state.threadLists.codex?.[0]?.title).toBe("New title"); expect(agent.updateThreadTitle).toHaveBeenCalledWith( threadId, "New title", - "gemini", + "codex", ); }); @@ -2882,17 +2898,17 @@ describe("chat-store Agent Thread Card streaming flow", () => { }, ]); const instance = useAgentConversationStore.getState().createInstance({ - agentType: "gemini", + agentType: "codex", title: "Old conversation title", threadId, source: { kind: "thread-card" }, }); useChatStore.setState((state) => ({ - threadTypes: { ...state.threadTypes, [threadId]: "gemini" }, + threadTypes: { ...state.threadTypes, [threadId]: "codex" }, threadLists: { ...state.threadLists, - gemini: [ + codex: [ { threadId, title: "Old conversation title", @@ -2912,13 +2928,13 @@ describe("chat-store Agent Thread Card streaming flow", () => { useAgentConversationStore.getState().getInstance(instance.instanceId) ?.title, ).toBe("New conversation title"); - expect(useChatStore.getState().threadLists.gemini?.[0]?.title).toBe( + expect(useChatStore.getState().threadLists.codex?.[0]?.title).toBe( "New conversation title", ); expect(agent.updateThreadTitle).toHaveBeenCalledWith( threadId, "New conversation title", - "gemini", + "codex", ); }); diff --git a/app/flowix-web/features/agent/store/agent-session-runtime-bridges.ts b/app/flowix-web/features/agent/store/agent-session-runtime-bridges.ts index 58c77b15c..f9ce7f728 100644 --- a/app/flowix-web/features/agent/store/agent-session-runtime-bridges.ts +++ b/app/flowix-web/features/agent/store/agent-session-runtime-bridges.ts @@ -1,6 +1,5 @@ import type { AgentSessionStore } from '@features/agent/store/agent-session-store'; import { createAgentChunkBridge } from '@features/agent/store/agent-chunk-bridge'; -import { installGlobalAgentSettingsSync } from '@features/agent/store/global-agent-settings-sync'; import { resolveIncomingChunkThreadId } from '@features/agent/store/agent-chunk-routing'; import { hasThreadInterest } from '@features/agent/store/thread-interest'; @@ -9,8 +8,6 @@ interface AgentSessionStoreAccess { } export function installAgentSessionRuntimeBridges(store: AgentSessionStoreAccess) { - installGlobalAgentSettingsSync((updater) => store.getState().setSessionMeta(updater)); - return createAgentChunkBridge((chunk) => { const canonicalThreadId = resolveIncomingChunkThreadId(chunk, store.getState()); const runId = chunk.run_id?.trim(); diff --git a/app/flowix-web/features/agent/store/agent-session-store.test.ts b/app/flowix-web/features/agent/store/agent-session-store.test.ts index cf344e0ef..4eb397602 100644 --- a/app/flowix-web/features/agent/store/agent-session-store.test.ts +++ b/app/flowix-web/features/agent/store/agent-session-store.test.ts @@ -10,7 +10,7 @@ import { DEFAULT_AGENT_TYPE_KEY } from "@/lib/agent-types"; const streamStart = (runId: string, threadId = "t1") => ({ kind: "stream_start" as const, - agentType: "deepseek-harness" as const, + agentType: "dsh" as const, threadId, runId, timestamp: 0, @@ -19,7 +19,7 @@ const streamStart = (runId: string, threadId = "t1") => ({ const textDelta = (text: string, runId: string, threadId = "t1") => ({ kind: "text_delta" as const, - agentType: "deepseek-harness" as const, + agentType: "dsh" as const, threadId, runId, timestamp: 1000, @@ -34,7 +34,7 @@ const textDelta = (text: string, runId: string, threadId = "t1") => ({ const streamEnd = (runId: string, threadId = "t1") => ({ kind: "stream_end" as const, - agentType: "deepseek-harness" as const, + agentType: "dsh" as const, threadId, runId, timestamp: 2000, @@ -626,7 +626,7 @@ describe("useAgentSessionStore", () => { const store = useAgentSessionStore.getState(); const threadId = "steer-stop-thread"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatch(streamStart("steer-stop-run", threadId)); store.enqueueSteeringMessage({ id: "pending-steer-stop", @@ -646,7 +646,7 @@ describe("useAgentSessionStore", () => { const threadId = "steer-error-thread"; const runId = "steer-error-run"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatch(streamStart(runId, threadId)); store.enqueueSteeringMessage({ id: "pending-steer-error", @@ -657,7 +657,7 @@ describe("useAgentSessionStore", () => { store.dispatchAgentChunk({ kind: "stream_end", - agent_type: "deepseek-harness", + agent_type: "dsh", thread_id: threadId, run_id: runId, reason: "provider exited unexpectedly", @@ -670,7 +670,7 @@ describe("useAgentSessionStore", () => { const store = useAgentSessionStore.getState(); const threadId = "steer-stale-end-thread"; - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); store.dispatch(streamStart("steer-old-run", threadId)); store.dispatch(streamStart("steer-new-run", threadId)); store.enqueueSteeringMessage({ @@ -682,7 +682,7 @@ describe("useAgentSessionStore", () => { store.dispatchAgentEvent({ kind: "stream_end", - agentType: "deepseek-harness", + agentType: "dsh", threadId, runId: "steer-old-run", timestamp: 2, @@ -737,7 +737,7 @@ describe("useAgentSessionStore", () => { const before = useAgentSessionStore.getState(); before.dispatch({ kind: "session_resolved", - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "test-run", timestamp: 0, @@ -811,7 +811,7 @@ describe("useAgentSessionStore persist", () => { }); it("starts with current defaults when only obsolete CHAT data exists", async () => { - localStorage.setItem(STORAGE_KEYS.CHAT, JSON.stringify({ state: { activeAgentTypeKey: "claude" } })); + localStorage.setItem(STORAGE_KEYS.CHAT, JSON.stringify({ state: { activeAgentTypeKey: "claudecode" } })); const { useAgentSessionStore } = await import("@features/agent/store/agent-session-store"); expect(useAgentSessionStore.getState().sessionMeta.activeAgentTypeKey).toBe(DEFAULT_AGENT_TYPE_KEY); }); @@ -852,7 +852,7 @@ describe("useAgentSessionStore persist", () => { it("ignores the obsolete CHAT key when AGENT_SESSION is present", async () => { localStorage.setItem( STORAGE_KEYS.CHAT, - JSON.stringify({ state: { activeAgentTypeKey: "claude" } }), + JSON.stringify({ state: { activeAgentTypeKey: "claudecode" } }), ); localStorage.setItem( STORAGE_KEYS.AGENT_SESSION, diff --git a/app/flowix-web/features/agent/store/agent-session-store.ts b/app/flowix-web/features/agent/store/agent-session-store.ts index 3b566cdc9..9b8f863b4 100644 --- a/app/flowix-web/features/agent/store/agent-session-store.ts +++ b/app/flowix-web/features/agent/store/agent-session-store.ts @@ -224,17 +224,17 @@ export const useAgentSessionStore = create()( } const state = get(); const type = getAgentType(typeKey ?? state.sessionMeta.threadTypes[threadId] ?? state.sessionMeta.activeAgentTypeKey); - const productThreadId = resolveCommandThreadId(state, threadId, type.key); + const productThreadId = resolveCommandThreadId(state, threadId, type.id); if (!productThreadId) { if (options?.rejectOnFailure) throw new Error("Agent thread was not found"); return; } - if (productThreadId !== threadId) return get().sendMessageToThread(productThreadId, content, type.key, options); + if (productThreadId !== threadId) return get().sendMessageToThread(productThreadId, content, type.id, options); if (state.threadTombstones[threadId] || state.threadMutationGuards[threadId]) { if (options?.rejectOnFailure) throw new Error("Agent thread is unavailable"); return; } - state.bindThreadType(threadId, type.key); + state.bindThreadType(threadId, type.id); const isFirstMessage = options?.isFirstMessage ?? (state.threadProjections[threadId]?.messages.length ?? 0) === 0; const conversationTitle = normalizeThreadTitle(options?.conversationTitle); if (isFirstMessage && conversationTitle) { @@ -251,7 +251,7 @@ export const useAgentSessionStore = create()( : {}), threadLists: { ...meta.threadLists, - [type.key]: (meta.threadLists[type.key] ?? []).map((item) => + [type.id]: (meta.threadLists[type.id] ?? []).map((item) => item.threadId === threadId ? { ...item, title: conversationTitle } : item, @@ -262,7 +262,7 @@ export const useAgentSessionStore = create()( const { userPayload, llmContent, userMessage } = prepareUserMessage({ content: trimmed, isFirstMessage, - agentType: type.key, + agentType: type.id, currentNoteContent: options?.currentNoteContent, systemReminderDirectory: options?.runtimeConfig?.workspaceSnapshot?.notebookPath, @@ -270,7 +270,7 @@ export const useAgentSessionStore = create()( options?.attachments ?? createAgentMessageAttachments(options?.imagePaths), }); if ( - (type.key === "codex" || type.key === "deepseek-harness" || type.key === "pi") && + (type.id === "codex" || type.id === "dsh" || type.id === "pi") && isProjectionWorking(get().threadProjections[threadId]) ) { const clientUserMessageId = `flowix-${createRunId(threadId)}`; @@ -284,7 +284,7 @@ export const useAgentSessionStore = create()( queuedAt: Date.now(), clientUserMessageId, llmContent, - deliveryMode: type.key === 'pi' || type.key === 'codex' ? 'follow_up' : 'steer', + deliveryMode: type.id === 'pi' || type.id === 'codex' ? 'follow_up' : 'steer', deliveryStatus: "submitting", }); try { @@ -292,10 +292,10 @@ export const useAgentSessionStore = create()( content: trimmed, llmContent, imagePaths: options?.imagePaths, - agentType: type.key, + agentType: type.id, runtimeConfig: options?.runtimeConfig ?? undefined, }; - if (type.key === "codex") { + if (type.id === "codex") { await get().queueCodexFollowUp( threadId, activeTurnMessage, @@ -307,16 +307,16 @@ export const useAgentSessionStore = create()( threadId, activeTurnMessage, clientUserMessageId, - type.key === 'pi' ? 'follow_up' : 'steer', + type.id === 'pi' ? 'follow_up' : 'steer', ); get().updateSteeringMessage(threadId, pendingId, { deliveryStatus: "queued" }); } } catch (err) { get().removeSteeringMessage(threadId, pendingId); - logger.error("Failed to queue active-turn message", { error: String(err), agentType: type.key }); + logger.error("Failed to queue active-turn message", { error: String(err), agentType: type.id }); get().dispatch({ kind: "error", - agentType: type.key, + agentType: type.id, threadId, runId: get().threadProjections[threadId]?.runs.activeRunId ?? createRunId(threadId), timestamp: Date.now(), @@ -327,20 +327,20 @@ export const useAgentSessionStore = create()( return; } const runId = options?.runId ?? createRunId(threadId); - userMessage.id = completedRunUserMessageId(type.key, runId); + userMessage.id = completedRunUserMessageId(type.id, runId); userMessage.renderKey = userMessage.id; - if (type.key === "pi") userMessage.messageId = null; + if (type.id === "pi") userMessage.messageId = null; const startedAt = Date.now(); state.dispatch({ kind: "stream_start", - agentType: type.key, + agentType: type.id, threadId, runId, timestamp: startedAt, }); state.dispatch({ kind: "user_message", - agentType: type.key, + agentType: type.id, threadId, runId, timestamp: startedAt, @@ -349,9 +349,8 @@ export const useAgentSessionStore = create()( attachments: userMessage.attachments, }); if (options?.instanceId) { - state.updateThread(options.instanceId, { threadId, agentType: type.key }); + state.updateThread(options.instanceId, { threadId, agentType: type.id }); } - const settings = get().sessionMeta.settings; try { await dispatchChatStream({ threadId, @@ -360,10 +359,7 @@ export const useAgentSessionStore = create()( llmContent, runId, userPayload, - agentType: type.key, - permissionMode: settings.agentPermissionMode, - codexModel: settings.agentCodexModel, - codexReasoningEffort: settings.agentCodexReasoningEffort, + agentType: type.id, runtimeConfig: options?.runtimeConfig ?? undefined, imagePaths: options?.imagePaths, conversationTitle: @@ -377,7 +373,7 @@ export const useAgentSessionStore = create()( ); get().dispatchAgentEvent({ kind: "error", - agentType: type.key, + agentType: type.id, threadId, runId, timestamp: Date.now(), @@ -389,7 +385,7 @@ export const useAgentSessionStore = create()( stopStream: async () => { const meta = get().sessionMeta; const type = getAgentType(meta.activeAgentTypeKey); - const activeId = meta.activeThreadIds[type.key]; + const activeId = meta.activeThreadIds[type.id]; if (activeId) await get().stopThreadRun(activeId); }, stopThreadRun: async (threadId, runId) => { @@ -427,17 +423,17 @@ export const useAgentSessionStore = create()( meta.threadTypes[threadId] ?? meta.activeAgentTypeKey, ); let stopRunId = targetRunId ?? - (type.key === "codex" ? pendingCodexCommandRunId : undefined); + (type.id === "codex" ? pendingCodexCommandRunId : undefined); if (!stopRunId) { const running = await agentClient.runningThreads(); const candidate = Object.entries(running).find(([reportedThreadId, info]) => (reportedThreadId === threadId || info.pendingThreadId === threadId) && - (!info.agentType || info.agentType === type.key)); + (!info.agentType || info.agentType === type.id)); stopRunId = candidate?.[1].runId; if (!stopRunId) return; } - accepted = await agentClient.stopChatStream(threadId, type.key, stopRunId); - if (accepted && type.key !== "codex" && (!runId || !activeRunIdBeforeStop || runId === activeRunIdBeforeStop)) { + accepted = await agentClient.stopChatStream(threadId, type.id, stopRunId); + if (accepted && type.id !== "codex" && (!runId || !activeRunIdBeforeStop || runId === activeRunIdBeforeStop)) { get().clearPendingSteeringMessages(threadId); } } catch (err) { @@ -615,7 +611,12 @@ export const useAgentSessionStore = create()( }, { name: STORAGE_KEYS.AGENT_SESSION, + version: 2, storage: createJSONStorage(() => createAgentSessionStateStorage()), + migrate: (persisted) => ({ + ...(persisted as Record), + sessionMeta: rehydrateSessionMeta(persisted), + }), partialize: (state) => ({ sessionMeta: { ...state.sessionMeta, diff --git a/app/flowix-web/features/agent/store/agent-session-test-facade.ts b/app/flowix-web/features/agent/store/agent-session-test-facade.ts index a71d47ee3..98b7d6859 100644 --- a/app/flowix-web/features/agent/store/agent-session-test-facade.ts +++ b/app/flowix-web/features/agent/store/agent-session-test-facade.ts @@ -1,3 +1,4 @@ +import { useAgentDataStore } from "./agent-data-store"; /** * Test-only legacy-shape facade over the canonical AgentSessionStore. * @@ -132,6 +133,16 @@ function applyLegacyChatPatch( patch.agentPermissionMode !== undefined || patch.agentCodexModel !== undefined || patch.agentCodexReasoningEffort !== undefined; + if (hasSettings) { + const preferences = useAgentDataStore.getState(); + preferences.setUserPreference("codex", { + ...(patch.agentPermissionMode !== undefined ? { permission: patch.agentPermissionMode } : {}), + ...(patch.agentCodexModel !== undefined ? { model: { id: patch.agentCodexModel } } : {}), + }); + if (patch.agentCodexReasoningEffort !== undefined) { + preferences.rememberModelReasoning("codex", useAgentDataStore.getState().agents.codex.user.model, patch.agentCodexReasoningEffort); + } + } if (hasMeta || hasSettings) { useAgentSessionStore.getState().setSessionMeta((meta) => ({ ...meta, diff --git a/app/flowix-web/features/agent/store/agent-user-preferences.ts b/app/flowix-web/features/agent/store/agent-user-preferences.ts new file mode 100644 index 000000000..4960fed40 --- /dev/null +++ b/app/flowix-web/features/agent/store/agent-user-preferences.ts @@ -0,0 +1,38 @@ +import type { AgentHarnessPreset, AgentTypeKey, RuntimeConfig } from "@/types/agent"; +import { modelPreferenceKey, useAgentDataStore } from "./agent-data-store"; + +/** Apply this Agent's remembered preferences, preserving conversation overrides. */ +export function resolveAgentPreferenceRuntimeConfig( + agentType: AgentTypeKey, + runtimeConfig: RuntimeConfig = {}, +): RuntimeConfig { + const agent = useAgentDataStore.getState().agents[agentType]; + const preference = agent.user; + const model = runtimeConfig.model ?? (preference.model + ? { key: preference.model.id, ...(preference.model.providerId ? { providerId: preference.model.providerId } : {}) } + : undefined); + const modelReasoning = model + ? preference.reasoningByModel?.[modelPreferenceKey({ id: model.key, providerId: model.providerId })] + : undefined; + const supportedEfforts = model + ? agent.catalog.models.items.find((item) => item.id === model.key && item.providerId === model.providerId)?.reasoningEfforts + : undefined; + const defaultEffort = supportedEfforts?.includes("medium") ? "medium" : supportedEfforts?.[0]; + const reasoningEffort = runtimeConfig.reasoningEffort ?? modelReasoning + ?? (!model ? preference.reasoningEffort : defaultEffort); + const permission = runtimeConfig.access?.sandbox ?? preference.permission; + const serviceTier = runtimeConfig.serviceTier ?? preference.serviceTier; + const harnessMode = runtimeConfig.deepseekHarness?.mode ?? preference.mode; + // Historical Pi mode snapshots are ignored; Pi mode is shared across conversations. + const { pi: _legacyPiMode, ...conversationConfig } = runtimeConfig; + return { + ...conversationConfig, + ...(model ? { model } : {}), + ...(permission ? { access: { ...runtimeConfig.access, sandbox: permission } } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(serviceTier ? { serviceTier } : {}), + ...(harnessMode && agentType === "dsh" + ? { deepseekHarness: { ...runtimeConfig.deepseekHarness, mode: harnessMode as AgentHarnessPreset } } + : {}), + }; +} diff --git a/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts b/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts new file mode 100644 index 000000000..2887151a6 --- /dev/null +++ b/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentWorkspaceSnapshot, LegacyAgentRuntimePreferences, NotebookWorkspaceConfig } from "@/lib/types/agent-workspace"; +import { STORAGE_KEYS } from "@/lib/constants"; + +const backend = vi.hoisted(() => ({ get: vi.fn(), getLegacyPreferences: vi.fn(), attachDirectory: vi.fn(), detachDirectory: vi.fn(), pickDirectory: vi.fn() })); +const piBackend = vi.hoisted(() => ({ getFeatures: vi.fn(), clearLegacyCodeMode: vi.fn() })); +vi.mock("@platform/tauri/client", () => ({ agentWorkspace: backend, piModels: piBackend })); +vi.mock("./agent-session-store", () => ({ useAgentSessionStore: { getState: () => ({ sessionMeta: { settings: {} } }) } })); +import { ensureAgentPreferencesReady, useAgentDataStore } from "./agent-data-store"; +import { ensureAgentWorkspaceReady, useAgentWorkspaceStore } from "./agent-workspace-store"; + +const notebook = (id: string) => ({ id, kind: "notebook" as const, path: `/notes/${id}`, name: id, enabled: true, missing: false, addedAt: 1, updatedAt: 1 }); +const resource = { ...notebook("resource"), kind: "folder" as const, path: "/projects/shared" }; +const localConfig: NotebookWorkspaceConfig = { version: 1, revision: 1, addDirs: [{ id: "dir", path: resource.path, label: "Shared", enabled: true }] }; +const snapshot = (): AgentWorkspaceSnapshot => ({ notebooks: [notebook("one"), notebook("two")], notebookConfigs: { one: structuredClone(localConfig), two: { version: 1, revision: 0, addDirs: [] } } }); + +beforeEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + useAgentDataStore.setState(useAgentDataStore.getInitialState(), true); + useAgentWorkspaceStore.setState(useAgentWorkspaceStore.getInitialState(), true); + vi.resetAllMocks(); + backend.get.mockImplementation(async () => snapshot()); + backend.getLegacyPreferences.mockResolvedValue(null); + piBackend.getFeatures.mockResolvedValue({ toolSearch: false }); + piBackend.clearLegacyCodeMode.mockResolvedValue(undefined); +}); + +describe("Pi mode startup migration", () => { + it("treats reselecting the default as an explicit choice ahead of a late migration", async () => { + await useAgentDataStore.getState().initializePiMode(); + useAgentDataStore.getState().setUserPreference("pi", { mode: "writing" }); + const otherWindow = JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!); + otherWindow.state.agents.pi.user.mode = "code"; + otherWindow.state.userPreferenceRevisions.pi.mode = "0000000000000:legacy"; + localStorage.setItem(STORAGE_KEYS.AGENT_DATA, JSON.stringify(otherWindow)); + await useAgentDataStore.persist.rehydrate(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("writing"); + }); + + it("keeps another window's migrated mode before its storage event arrives", async () => { + useAgentDataStore.setState({ legacyPreferencesMigrated: true }); + let finish!: (features: { toolSearch: boolean }) => void; + piBackend.getFeatures.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + const initializing = useAgentDataStore.getState().initializePiMode(); + const otherWindow = JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!); + otherWindow.state.agents.pi.user.mode = "code"; + otherWindow.state.userPreferenceRevisions.pi = { mode: "0000000000000:legacy" }; + localStorage.setItem(STORAGE_KEYS.AGENT_DATA, JSON.stringify(otherWindow)); + finish({ toolSearch: false }); + await initializing; + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); + expect(JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!).state.agents.pi.user.mode).toBe("code"); + }); + + it("gives an imported mode priority over a stale default from another window", async () => { + await useAgentDataStore.getState().initializePiMode(); + const staleDefault = localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!; + const imported = JSON.parse(staleDefault); + imported.state.agents.pi.user.mode = "code"; + imported.state.userPreferenceRevisions.pi.mode = "0000000000000:legacy"; + localStorage.setItem(STORAGE_KEYS.AGENT_DATA, JSON.stringify(imported)); + await useAgentDataStore.persist.rehydrate(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); + localStorage.setItem(STORAGE_KEYS.AGENT_DATA, staleDefault); + await useAgentDataStore.persist.rehydrate(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); + }); + + it("uses an existing shared choice without reading legacy settings, even if cleanup fails", async () => { + useAgentDataStore.getState().setUserPreference("pi", { mode: "writing" }); + piBackend.getFeatures.mockRejectedValue(new Error("old settings unavailable")); + piBackend.clearLegacyCodeMode.mockRejectedValue(new Error("cleanup unavailable")); + await useAgentDataStore.getState().initializePiMode(); + expect(piBackend.getFeatures).not.toHaveBeenCalled(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("writing"); + expect(useAgentDataStore.getState().loadState.piMode.status).toBe("ready"); + }); + + it("imports the former Pi feature before notebook hydration and only cleans up a durable copy", async () => { + piBackend.getFeatures.mockResolvedValue({ toolSearch: true, legacyCodeMode: true }); + piBackend.clearLegacyCodeMode.mockImplementation(async () => { + const persisted = JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!); + expect(persisted.state.agents.pi.user.mode).toBe("code"); + }); + await useAgentDataStore.getState().initializePiMode(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); + expect(useAgentDataStore.getState().loadState.piMode.status).toBe("ready"); + expect(useAgentWorkspaceStore.getState().loadState.workspace.status).toBe("idle"); + expect(backend.get).not.toHaveBeenCalled(); + expect(piBackend.clearLegacyCodeMode).toHaveBeenCalledOnce(); + }); + + it("preserves the former Agent runtime default ahead of the Pi feature fallback", async () => { + backend.getLegacyPreferences.mockResolvedValue({ pi: { piCodeMode: true } }); + await useAgentDataStore.getState().initializePiMode(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); + }); + + it("deduplicates initialization and preserves a choice made while migration is reading", async () => { + let resolve!: (features: { toolSearch: boolean; legacyCodeMode: boolean }) => void; + piBackend.getFeatures.mockReturnValue(new Promise((done) => { resolve = done; })); + const first = useAgentDataStore.getState().initializePiMode(); + const second = useAgentDataStore.getState().initializePiMode(); + useAgentDataStore.getState().setUserPreference("pi", { mode: "writing" }); + resolve({ toolSearch: false, legacyCodeMode: true }); + await Promise.all([first, second]); + expect(piBackend.getFeatures).toHaveBeenCalledOnce(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("writing"); + }); + + it("keeps the legacy value when browser persistence fails", async () => { + piBackend.getFeatures.mockResolvedValue({ toolSearch: false, legacyCodeMode: true }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota exceeded"); }); + await useAgentDataStore.getState().initializePiMode(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); + expect(piBackend.clearLegacyCodeMode).not.toHaveBeenCalled(); + }); + + it("retries a failed migration without deleting the old preference", async () => { + piBackend.getFeatures.mockRejectedValueOnce(new Error("read failed")); + await expect(useAgentDataStore.getState().initializePiMode()).rejects.toThrow("read failed"); + expect(useAgentDataStore.getState().loadState.piMode.status).toBe("error"); + expect(piBackend.clearLegacyCodeMode).not.toHaveBeenCalled(); + await useAgentDataStore.getState().initializePiMode(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("writing"); + }); +}); + + +describe("local.db workspace projection", () => { + it("derives resources from mounts and excludes missing/disabled paths from runs", async () => { + const data = snapshot(); + data.notebookConfigs.one.addDirs.push( + { id: 'missing', path: '/missing', label: 'Missing', enabled: true, missing: true }, + { id: 'disabled', path: '/disabled', label: 'Disabled', enabled: false }, + ); + backend.get.mockResolvedValue(data); + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')).toBeUndefined(); + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([resource.path]); + expect(useAgentWorkspaceStore.getState().workspace.resources).toHaveLength(3); + }); + it("never persists or rehydrates a stale workspace cache", async () => { + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + const persisted = JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!); + expect(persisted.state.workspace).toBeUndefined(); + expect(persisted.state.agents.codex.catalog.capabilities).toBeUndefined(); + const current = useAgentWorkspaceStore.getState().workspace; + persisted.state.workspace = { resources: [], byNotebookId: {}, validatedAt: Date.now()+1000 }; + localStorage.setItem(STORAGE_KEYS.AGENT_DATA,JSON.stringify(persisted)); + await useAgentDataStore.persist.rehydrate(); + expect(useAgentWorkspaceStore.getState().workspace).toBe(current); + }); + it.each(['attach-first','detach-first'])("serializes opposite path operations: %s", async order => { + let data = snapshot(); + backend.attachDirectory.mockImplementation(async (id,path) => { + data.notebookConfigs[id].addDirs = [{ id:'saved', path, label:'Shared', enabled:true }]; + return structuredClone(data); + }); + backend.detachDirectory.mockImplementation(async id => { + data.notebookConfigs[id].addDirs=[]; + return structuredClone(data); + }); + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + const actions = useAgentWorkspaceStore.getState().workspaceActions; + await Promise.all(order==='attach-first' ? [actions.attachFolderToNotebook('two',resource.path),actions.detachFolderFromNotebook('two',resource.path)] : [actions.detachFolderFromNotebook('one',resource.path),actions.attachFolderToNotebook('one',resource.path)]); + expect(actions.getNotebookFiles(order==='attach-first'?'two':'one')?.folders).toEqual(order==='attach-first'?[]:[resource.path]); + }); + it("picks and mounts in one scoped call without creating a global resource", async () => { + const data = snapshot(); + data.notebookConfigs.two.addDirs=structuredClone(localConfig.addDirs); + backend.pickDirectory.mockResolvedValue(data); + expect((await useAgentWorkspaceStore.getState().workspaceActions.addFolderFromPicker(null)).ok).toBe(false); + expect(backend.pickDirectory).not.toHaveBeenCalled(); + expect((await useAgentWorkspaceStore.getState().workspaceActions.addFolderFromPicker('two')).ok).toBe(true); + expect(backend.pickDirectory).toHaveBeenCalledWith('two'); + expect(backend.attachDirectory).not.toHaveBeenCalled(); + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('two')?.folders).toEqual([resource.path]); + }); + it("ignores a read started before a committed mutation", async () => { + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + let finish!: (data: AgentWorkspaceSnapshot) => void; + backend.get.mockReturnValueOnce(new Promise(resolve => {finish=resolve;})); + const reading=useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + const saved=snapshot(); saved.notebookConfigs.one.addDirs=[]; + backend.detachDirectory.mockResolvedValue(saved); + await useAgentWorkspaceStore.getState().workspaceActions.detachFolderFromNotebook('one',resource.path); + finish(snapshot()); await reading; + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([]); + }); + it("migrates old user preferences without writing the legacy JSON", async () => { + backend.getLegacyPreferences.mockResolvedValue({codex:{model:{key:'legacy-model'}}}); + await useAgentDataStore.getState().initializeLegacyPreferences(); + expect(useAgentDataStore.getState().agents.codex.user.model?.id).toBe('legacy-model'); + expect(JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!).state.legacyPreferencesMigrated).toBe(true); + }); + it("waits for legacy preference migration before preparing an Agent request", async () => { + let finish!: (value: LegacyAgentRuntimePreferences | null) => void; + backend.getLegacyPreferences.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + const ready = ensureAgentPreferencesReady("codex"); + await Promise.resolve(); + expect(useAgentDataStore.getState().agents.codex.user.model).toBeUndefined(); + finish({ codex: { model: { key: "legacy-model" } } }); + await ready; + expect(useAgentDataStore.getState().agents.codex.user.model?.id).toBe("legacy-model"); + }); + it("reports a database failure and retries", async () => { + backend.get.mockRejectedValueOnce(new Error('database unavailable')); + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + expect(useAgentWorkspaceStore.getState().loadState.workspace.status).toBe('error'); + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + expect(useAgentWorkspaceStore.getState().loadState.workspace.status).toBe('ready'); + }); +}); + +it("waits for initial database hydration before allowing a workspace snapshot", async () => { + let finish!: (snapshot: AgentWorkspaceSnapshot) => void; + backend.get.mockReturnValueOnce(new Promise(resolve => { finish=resolve; })); + const ready = ensureAgentWorkspaceReady(); + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')).toBeUndefined(); + finish(snapshot()); await ready; + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([resource.path]); +}); + +it("loads database mounts even when importing old preferences fails", async () => { + backend.getLegacyPreferences.mockRejectedValue(new Error('legacy JSON unavailable')); + expect(await useAgentWorkspaceStore.getState().workspaceActions.loadInitial()).toBe(true); + await expect(useAgentDataStore.getState().initializeLegacyPreferences()).rejects.toThrow('legacy JSON unavailable'); + expect(useAgentDataStore.getState().legacyPreferencesMigrated).toBe(false); + expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([resource.path]); +}); + +it("waits for the newer startup read when its own read was superseded", async () => { + let first!: (data: AgentWorkspaceSnapshot) => void; + let second!: (data: AgentWorkspaceSnapshot) => void; + backend.get.mockReturnValueOnce(new Promise(resolve => { first=resolve; })); + backend.get.mockReturnValueOnce(new Promise(resolve => { second=resolve; })); + const ready=ensureAgentWorkspaceReady(); + const newer=useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + first(snapshot()); + await Promise.resolve(); await Promise.resolve(); + second(snapshot()); + await Promise.all([ready,newer]); + expect(useAgentWorkspaceStore.getState().loadState.workspace.status).toBe('ready'); +}); diff --git a/app/flowix-web/features/agent/store/agent-workspace-slice.ts b/app/flowix-web/features/agent/store/agent-workspace-slice.ts new file mode 100644 index 000000000..bba7e4028 --- /dev/null +++ b/app/flowix-web/features/agent/store/agent-workspace-slice.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@/lib/logger'; +import { agentWorkspace } from '@platform/tauri/client'; +import type { AgentWorkspaceSnapshot, AgentWorkspaceResource } from '@/lib/types/agent-workspace'; +import type { FilesConfig } from '@/types/agent'; + +const logger = createLogger('agent-workspace'); +export type AgentAccessErrorCode = 'not-selected' | 'already-tracked' | 'save-failed'; +export interface AgentWorkspaceNotebook { + notebook: { id: string; name: string; path: string }; + addDirs: Array<{ + id: string; + path: string; + label: string; + enabled: boolean; + missing: boolean; + }>; +} +export interface AgentWorkspaceState { + /** In-memory projection of local.db; runtime authorization remains backend-owned. */ + resources: AgentWorkspaceResource[]; + byNotebookId: Record; + validatedAt?: number; +} +export interface AgentWorkspaceActions { + loadInitial: () => Promise; + getNotebookFiles: (notebookId: string | null | undefined) => FilesConfig | undefined; + /** The picker and mount commit share one notebook-scoped backend operation. */ + addFolderFromPicker: (notebookId: string | null | undefined) => Promise< + { ok: true } | { ok: false; code: AgentAccessErrorCode }>; + attachFolderToNotebook: (notebookId: string | null | undefined, path: string) => Promise<'saved' | 'already-attached' | false>; + detachFolderFromNotebook: (notebookId: string | null | undefined, path: string) => Promise<'saved' | 'not-attached' | false>; +} +export type AgentWorkspaceLoadStatus = 'idle' | 'loading' | 'ready' | 'error'; +export interface AgentWorkspaceLoadState { status: AgentWorkspaceLoadStatus; error?: string } + +export interface AgentWorkspaceSliceStoreState { + workspace: AgentWorkspaceState; + loadState: { workspace: AgentWorkspaceLoadState }; + workspaceActions: AgentWorkspaceActions; +} + +export function createAgentWorkspaceSlice( + getStore: () => AgentWorkspaceSliceStoreState, + publishWorkspace: (snapshot: AgentWorkspaceSnapshot) => void, + setLoadState: (state: AgentWorkspaceLoadState) => void, +): AgentWorkspaceActions { + let generation = 0; + let writeQueue: Promise = Promise.resolve(); + async function mutate(operation: () => Promise): Promise { + const request = writeQueue.catch(() => undefined).then(async () => { + return operation(); + }); + writeQueue = request; + return request; + } + const publish = (snapshot: AgentWorkspaceSnapshot) => { + ++generation; + publishWorkspace(snapshot); + setLoadState({ status: 'ready' }); + }; + return { + getNotebookFiles: (notebookId) => { + if (!notebookId || getStore().loadState.workspace.status !== 'ready') return undefined; + const notebook = getStore().workspace.byNotebookId[notebookId]; + if (!notebook) return undefined; + return { workspace: null, folders: notebook.addDirs.filter(d => d.enabled && !d.missing).map(d => d.path), notebooks: [] }; + }, + loadInitial: async () => { + const requestGeneration = ++generation; + if (!getStore().workspace.validatedAt) setLoadState({ status: 'loading' }); + try { + const snapshot = await agentWorkspace.get(); + if (requestGeneration !== generation) return false; + publish(snapshot); + return true; + } catch (error) { + if (requestGeneration === generation) setLoadState({ status: 'error', error: String(error) }); + logger.error('Workspace hydration failed', { error }); + return false; + } + }, + addFolderFromPicker: async (notebookId) => { + if (!notebookId) return { ok: false, code: 'not-selected' }; + return mutate(async () => { + try { + const picked = await agentWorkspace.pickDirectory(notebookId); + if (!picked) return { ok: false as const, code: 'not-selected' as const }; + publish(picked); + return { ok: true as const }; + } catch (error) { + logger.error('Directory picker failed', { error }); + await getStore().workspaceActions.loadInitial(); + return { ok: false as const, code: 'save-failed' as const }; + } + }); + }, + attachFolderToNotebook: async (notebookId, path) => { + if (!notebookId) return false; + return mutate(async () => { + try { + publish(await agentWorkspace.attachDirectory(notebookId, path)); + return 'saved' as const; + } catch (error) { + logger.error('Directory attachment failed', { error }); + await getStore().workspaceActions.loadInitial(); + return false; + } + }); + }, + detachFolderFromNotebook: async (notebookId, path) => { + if (!notebookId) return false; + return mutate(async () => { + try { + publish(await agentWorkspace.detachDirectory(notebookId, path)); + return 'saved' as const; + } catch (error) { + logger.error('Directory removal failed', { error }); + await getStore().workspaceActions.loadInitial(); + return false; + } + }); + }, + }; +} diff --git a/app/flowix-web/features/agent/store/agent-workspace-store.ts b/app/flowix-web/features/agent/store/agent-workspace-store.ts new file mode 100644 index 000000000..0987a9731 --- /dev/null +++ b/app/flowix-web/features/agent/store/agent-workspace-store.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import type { AgentWorkspaceSnapshot, AgentWorkspaceResource } from '@/lib/types/agent-workspace'; +import { createAgentWorkspaceSlice, type AgentWorkspaceSliceStoreState } from './agent-workspace-slice'; + +export type { + AgentAccessErrorCode, + AgentWorkspaceActions, + AgentWorkspaceLoadState, + AgentWorkspaceLoadStatus, + AgentWorkspaceNotebook, + AgentWorkspaceState, +} from './agent-workspace-slice'; + +export interface AgentWorkspaceStoreState extends AgentWorkspaceSliceStoreState {} + +export function projectAgentWorkspace( + snapshot: AgentWorkspaceSnapshot, +): Pick { + const resourcesByPath = new Map(); + const byNotebookId: AgentWorkspaceStoreState['workspace']['byNotebookId'] = {}; + for (const notebook of snapshot.notebooks) { + const addDirs = (snapshot.notebookConfigs[notebook.id]?.addDirs ?? []).map((directory) => { + const missing = !!directory.missing; + if (!resourcesByPath.has(directory.path)) { + resourcesByPath.set(directory.path, { + id: directory.id, + path: directory.path, + name: directory.label, + enabled: directory.enabled, + missing, + }); + } + const resource = resourcesByPath.get(directory.path)!; + resource.enabled ||= directory.enabled; + return { ...directory, missing }; + }); + byNotebookId[notebook.id] = { notebook, addDirs }; + } + return { resources: [...resourcesByPath.values()], byNotebookId, validatedAt: Date.now() }; +} + +export const useAgentWorkspaceStore = create((set, get) => { + const workspaceActions = createAgentWorkspaceSlice( + get, + (snapshot) => set((state) => ({ + workspace: { ...state.workspace, ...projectAgentWorkspace(snapshot) }, + })), + (workspace) => set((state) => ({ + loadState: { ...state.loadState, workspace }, + })), + ); + + return { + workspace: { resources: [], byNotebookId: {} }, + loadState: { workspace: { status: 'idle' } }, + workspaceActions, + }; +}); + +/** Agent submissions wait for the backend-owned notebook access snapshot. */ +export async function ensureAgentWorkspaceReady(): Promise { + if (useAgentWorkspaceStore.getState().loadState.workspace.status === 'ready') return; + await useAgentWorkspaceStore.getState().workspaceActions.loadInitial(); + if (useAgentWorkspaceStore.getState().loadState.workspace.status === 'loading') { + await new Promise((resolve) => { + const unsubscribe = useAgentWorkspaceStore.subscribe((state) => { + if (state.loadState.workspace.status !== 'loading') { + unsubscribe(); + resolve(); + } + }); + }); + } + + const loadState = useAgentWorkspaceStore.getState().loadState.workspace; + if (loadState.status !== 'ready') { + throw new Error(loadState.error ?? 'Workspace data is not ready'); + } +} diff --git a/app/flowix-web/features/agent/store/chat-stream.pi-mode.test.ts b/app/flowix-web/features/agent/store/chat-stream.pi-mode.test.ts new file mode 100644 index 000000000..020bc4a63 --- /dev/null +++ b/app/flowix-web/features/agent/store/chat-stream.pi-mode.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const backend = vi.hoisted(() => ({ getFeatures: vi.fn(), clearLegacyCodeMode: vi.fn(), chatStream: vi.fn() })); +vi.mock("@platform/tauri/client", () => ({ piModels: backend, agentAccess: {} })); +vi.mock("./agent-client", () => ({ agentClient: backend })); +vi.mock("@features/agent/runtime/workspace-snapshot", () => ({ + normalizeWorkspaceSnapshot: () => null, + markConversationWorkspaceStarted: vi.fn(), +})); +vi.mock("@features/agent/runtime/conversation-workspace", () => ({ normalizeConversationWorkspaceState: () => null })); + +import { dispatchChatStream } from "./chat-stream"; +import { useAgentDataStore } from "./agent-data-store"; + +beforeEach(() => { + vi.resetAllMocks(); + localStorage.clear(); + useAgentDataStore.setState(useAgentDataStore.getInitialState(), true); + useAgentDataStore.setState({ legacyPreferencesMigrated: true }); + backend.clearLegacyCodeMode.mockResolvedValue(undefined); + backend.chatStream.mockResolvedValue(undefined); +}); + +const input = { + threadId: "pi-thread", content: "hello", llmContent: "hello", runId: "run", + userPayload: { llmContent: "hello", systemReminderDirectory: "/notes" }, + agentType: "pi" as const, +}; + +it("waits for startup migration on first send and uses the shared mode on subsequent turns", async () => { + let finish!: (features: { toolSearch: boolean; legacyCodeMode: boolean }) => void; + backend.getFeatures.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + const firstSend = dispatchChatStream({ ...input, runtimeConfig: { pi: { codeMode: false } } }); + expect(backend.chatStream).not.toHaveBeenCalled(); + finish({ toolSearch: false, legacyCodeMode: true }); + await firstSend; + expect(backend.chatStream).toHaveBeenLastCalledWith("pi-thread", expect.objectContaining({ + runtimeConfig: expect.objectContaining({ pi: expect.objectContaining({ codeMode: true }) }), + })); + useAgentDataStore.getState().setUserPreference("pi", { mode: "writing" }); + await dispatchChatStream({ ...input, runtimeConfig: { pi: { codeMode: true } } }); + expect(backend.chatStream).toHaveBeenLastCalledWith("pi-thread", expect.objectContaining({ + runtimeConfig: expect.objectContaining({ pi: expect.objectContaining({ codeMode: false }) }), + })); +}); + +it("rejects a send when migration fails instead of silently using another mode", async () => { + backend.getFeatures.mockRejectedValueOnce(new Error("settings unavailable")); + await expect(dispatchChatStream(input)).rejects.toThrow("settings unavailable"); + expect(backend.chatStream).not.toHaveBeenCalled(); + expect(backend.clearLegacyCodeMode).not.toHaveBeenCalled(); +}); diff --git a/app/flowix-web/features/agent/store/chat-stream.ts b/app/flowix-web/features/agent/store/chat-stream.ts index f31c18eca..15110a652 100644 --- a/app/flowix-web/features/agent/store/chat-stream.ts +++ b/app/flowix-web/features/agent/store/chat-stream.ts @@ -1,14 +1,12 @@ +import { resolveAgentPreferenceRuntimeConfig } from "./agent-user-preferences"; import type { - AgentCodexModel, - AgentCodexReasoningEffort, - AgentPermissionMode, AgentTypeKey, RuntimeConfig, } from "@/types/agent"; import { buildAgentRuntimeConfig } from "@features/agent/runtime/agent-runtime-spec"; import { agentClient } from "@features/agent/store/agent-client"; -import { useAgentAccessStore } from "@features/agent/store/agent-access-store"; -import { resolveNotebookAgentFiles } from "@/lib/agent-access-defaults"; +import { ensureAgentPreferencesReady, useAgentDataStore } from "@features/agent/store/agent-data-store"; +import { ensureAgentWorkspaceReady, useAgentWorkspaceStore } from "@features/agent/store/agent-workspace-store"; import type { OutgoingUserPayload } from "@features/agent/store/user-message"; import { markConversationWorkspaceStarted, @@ -24,9 +22,6 @@ export interface DispatchChatStreamArgs { runId: string; userPayload: OutgoingUserPayload; agentType: AgentTypeKey; - permissionMode: AgentPermissionMode; - codexModel: AgentCodexModel; - codexReasoningEffort: AgentCodexReasoningEffort; /** Runtime config snapshot from the conversation instance. */ runtimeConfig?: RuntimeConfig; imagePaths?: string[]; @@ -50,33 +45,33 @@ export async function dispatchChatStream({ runId, userPayload, agentType, - permissionMode, - codexModel, - codexReasoningEffort, runtimeConfig: instanceRuntimeConfig, imagePaths, conversationTitle, }: DispatchChatStreamArgs): Promise { + await ensureAgentPreferencesReady(agentType); // Thread Card 首次运行前会冻结 workspaceSnapshot,后续 turn 只使用快照。 - // 没有快照的调用读取 notebook-local `.flowix/agent.json` add-dirs。 + // 没有快照的调用读取 local.db notebook add-dirs。 const workspaceSnapshot = normalizeConversationWorkspaceState(instanceRuntimeConfig)?.desired ?? normalizeWorkspaceSnapshot(instanceRuntimeConfig?.workspaceSnapshot); const notebookId = instanceRuntimeConfig?.notebookId; - const accessState = useAgentAccessStore.getState(); + if (!workspaceSnapshot && notebookId) await ensureAgentWorkspaceReady(); + const userDefaults = useAgentDataStore.getState().agents; const defaultFiles = !workspaceSnapshot && notebookId - ? resolveNotebookAgentFiles(accessState.config, accessState.notebookConfigs, notebookId) + ? useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles(notebookId) : undefined; + const preferences = resolveAgentPreferenceRuntimeConfig(agentType, instanceRuntimeConfig); const runtimeConfig = buildAgentRuntimeConfig({ typeKey: agentType, notebookPath: userPayload.systemReminderDirectory, - permissionMode, - codexModel, - codexReasoningEffort, - codexServiceTier: accessState.config.defaults?.runtime?.codex?.serviceTier, - instanceRuntimeConfig, + permissionMode: preferences.access?.sandbox ?? "danger-full-access", + codexModel: preferences.model?.key ?? "inherit", + codexReasoningEffort: preferences.reasoningEffort ?? "medium", + codexServiceTier: preferences.serviceTier, + instanceRuntimeConfig: preferences, defaultFiles, - piCodeMode: accessState.config.defaults?.runtime?.pi?.piCodeMode, + piCodeMode: userDefaults.pi.user.mode === "code", workspaceSnapshot, }); if (instanceId) markConversationWorkspaceStarted(instanceId); diff --git a/app/flowix-web/features/agent/store/codex-history-reconciliation.test.ts b/app/flowix-web/features/agent/store/codex-history-reconciliation.test.ts index 494a5ac64..a58e8d68c 100644 --- a/app/flowix-web/features/agent/store/codex-history-reconciliation.test.ts +++ b/app/flowix-web/features/agent/store/codex-history-reconciliation.test.ts @@ -121,6 +121,19 @@ describe("Codex turn reconciliation", () => { expect(result.map((item) => item.id)).toEqual(["u", "command", "a", "next"]); }); + it("reconciles a completed native command by its shared turn identity", () => { + const command = { + ...row("flowix:codex:command:t1", "user", "t1", "/compact"), + messageType: "codex-command" as const, + isCompleted: true, + }; + + const result = reconcileCodexHistory( + [command], [{ ...command }], complete, [command], + ); + expect(result.map((item) => item.id)).toEqual(["flowix:codex:command:t1"]); + }); + it("reuses a large loaded prefix and the array for an equivalent latest-page snapshot", () => { const current: ChatMessage[] = []; for (let turn = 0; turn < 2000; turn += 1) { diff --git a/app/flowix-web/features/agent/store/context-block.test.ts b/app/flowix-web/features/agent/store/context-block.test.ts index 414d1d796..af93a600f 100644 --- a/app/flowix-web/features/agent/store/context-block.test.ts +++ b/app/flowix-web/features/agent/store/context-block.test.ts @@ -71,7 +71,7 @@ describe("appendFirstMessageContext", () => { "@features/agent/store/context-block" ); - const result = appendFirstMessageContext("question", true, "note", "claude"); + const result = appendFirstMessageContext("question", true, "note", "claudecode"); expect(result).toContain("# flowix CLI"); }); diff --git a/app/flowix-web/features/agent/store/context-block.ts b/app/flowix-web/features/agent/store/context-block.ts index e237871d2..e7cb90486 100644 --- a/app/flowix-web/features/agent/store/context-block.ts +++ b/app/flowix-web/features/agent/store/context-block.ts @@ -49,17 +49,17 @@ const FLOWIX_CLI_PROMPT_BLOCK = [ "# flowix CLI", "非交互笔记与插件产物 CLI,--json 取 JSON 输出。", "", - '只在显式触发时调:用户说"搜/列/改/删/新建笔记",或给了 8 位 ID / 笔记本名要求"看/改/删"。', + '只在显式触发时调:用户说"搜/列/改/删/新建笔记",或给了笔记地址 / 笔记本名要求"看/改/删"。', "如果上下文包含当前任务标签, 创建新笔记时必须在正文写入该 #标签。", "", "- `flowix notebooks` — list all notebooks.", "- `flowix list ` — list notes in a notebook.", - "- `flowix show ` — print a note to stdout.", + "- `flowix show
` — print a note to stdout.", "- `flowix search [-b ] [-l N]` — full-text search.", - "- `flowix edit --old --new ` — exact-string replace (always `read` first).", - "- `flowix write ` (body from stdin) — overwrite a note.", + "- `flowix edit
--old --new ` — exact-string replace (always `read` first).", + "- `flowix write
` (body from stdin) — overwrite a note.", "- `flowix create ` (body from stdin) — create a note.", - "- `flowix delete ` — delete a note.", + "- `flowix delete
` — delete a note.", "- `flowix plugin create mindmap --notebook [--source-note ] --json` — read final Markmap Markdown from stdin and create a Flowix mindmap document.", "", "仅当用户明确要求生成/创建思维导图时调用 mindmap 工具。先自行整理最终 Markdown(恰好一个一级根标题,分支使用二/三级标题和无序列表),再传给 stdin;不要手工创建 `.plugin-output` 文件。成功后向用户返回 title 和 noteId。", @@ -67,7 +67,7 @@ const FLOWIX_CLI_PROMPT_BLOCK = [ const NATIVE_AGENTS_INSTRUCTION_AGENT_TYPES = new Set([ "codex", - "deepseek-harness", + "dsh", ]); export function appendFirstMessageContext( diff --git a/app/flowix-web/features/agent/store/conversation-run-index.test.ts b/app/flowix-web/features/agent/store/conversation-run-index.test.ts index 7e39785c5..d97e3fd00 100644 --- a/app/flowix-web/features/agent/store/conversation-run-index.test.ts +++ b/app/flowix-web/features/agent/store/conversation-run-index.test.ts @@ -19,7 +19,7 @@ describe('conversation run index', () => { runId: 'run-1', status: 'running' as const, startedAt: 42, - agentType: 'deepseek-harness' as const, + agentType: 'dsh' as const, threadId: 'thread', }, }, @@ -52,7 +52,7 @@ describe('conversation run index', () => { runId: 'run-1', status: 'running' as const, startedAt: 42, - agentType: 'deepseek-harness' as const, + agentType: 'dsh' as const, threadId: 'thread', }, }, @@ -69,7 +69,7 @@ describe('conversation run index', () => { lastRun: { runId: 'run-1', status: 'completed' as const, - agentType: 'deepseek-harness' as const, + agentType: 'dsh' as const, startedAt: 42, endedAt: 50, }, diff --git a/app/flowix-web/features/agent/store/dsh-model-config-store.ts b/app/flowix-web/features/agent/store/dsh-model-config-store.ts index 226d95830..caf994f0a 100644 --- a/app/flowix-web/features/agent/store/dsh-model-config-store.ts +++ b/app/flowix-web/features/agent/store/dsh-model-config-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import type { AgentConfig } from "@platform/tauri/client/agent"; import { deepseekHarness } from "@platform/tauri/client"; +import { useAgentDataStore } from "@features/agent/store/agent-data-store"; export interface DshModelConfigSnapshot { model: AgentConfig; @@ -37,4 +38,5 @@ export function loadDshModelConfigs(): Promise { export function invalidateDshModelConfigs(): void { useDshModelConfigStore.getState().setConfigs(null); + useAgentDataStore.getState().invalidateModelCatalog("dsh"); } diff --git a/app/flowix-web/features/agent/store/follow-up-queue-slice.ts b/app/flowix-web/features/agent/store/follow-up-queue-slice.ts index 11b109726..7d0d5ca4e 100644 --- a/app/flowix-web/features/agent/store/follow-up-queue-slice.ts +++ b/app/flowix-web/features/agent/store/follow-up-queue-slice.ts @@ -283,13 +283,13 @@ export function createFollowUpQueueSlice(set: SessionSet, get: SessionGet): Foll const pending = get().pendingSteeringMessages[threadId]?.find((message) => message.id === messageId); if (!pending?.clientUserMessageId || pending.deliveryMode !== "follow_up") return; const type = getAgentType(get().sessionMeta.threadTypes[threadId] ?? get().sessionMeta.activeAgentTypeKey); - if (type.key === "pi") { + if (type.id === "pi") { if (!canActOnQueuedMessage(pending, "pi")) return; await agentClient.promotePiFollowUpToSteer(threadId, pending.clientUserMessageId); updateSteeringMessage(threadId, messageId, { deliveryMode: "steer", deliveryStatus: "queued" }); return; } - if (type.key !== "codex" || !canActOnQueuedMessage(pending, "codex")) return; + if (type.id !== "codex" || !canActOnQueuedMessage(pending, "codex")) return; // Lock immediately, including the queue refresh, so the UI can show // pending delivery and repeated clicks cannot submit the same item. @@ -348,13 +348,13 @@ export function createFollowUpQueueSlice(set: SessionSet, get: SessionGet): Foll const pending = get().pendingSteeringMessages[threadId]?.find((message) => message.id === messageId); if (!pending?.clientUserMessageId) return; const type = getAgentType(get().sessionMeta.threadTypes[threadId] ?? get().sessionMeta.activeAgentTypeKey); - if (type.key === "pi") { + if (type.id === "pi") { if (!canActOnQueuedMessage(pending, "pi")) return; await agentClient.deletePiQueuedMessage(threadId, pending.clientUserMessageId); removeMessage(set, threadId, messageId); return; } - if (type.key !== "codex" || !canActOnQueuedMessage(pending, "codex")) return; + if (type.id !== "codex" || !canActOnQueuedMessage(pending, "codex")) return; if (pending.deliveryMode === "steer" && pending.deliveryStatus !== "cleanup_failed") return; const current = get().pendingSteeringMessages[threadId]?.find((message) => message.id === messageId); if (!current || !current.providerQueueId) return; diff --git a/app/flowix-web/features/agent/store/global-agent-settings-sync.test.ts b/app/flowix-web/features/agent/store/global-agent-settings-sync.test.ts deleted file mode 100644 index b413077ff..000000000 --- a/app/flowix-web/features/agent/store/global-agent-settings-sync.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { STORAGE_KEYS } from "@/lib/constants"; -import { DEFAULT_AGENT_SESSION_META } from "@features/agent/store/session-state"; -import { installGlobalAgentSettingsSync } from "@features/agent/store/global-agent-settings-sync"; - -describe("installGlobalAgentSettingsSync", () => { - it("applies settings written by another Webview", () => { - const update = vi.fn(); - installGlobalAgentSettingsSync(update); - window.dispatchEvent( - new StorageEvent("storage", { - key: STORAGE_KEYS.AGENT_SESSION, - newValue: JSON.stringify({ - state: { - sessionMeta: { - settings: { - ...DEFAULT_AGENT_SESSION_META.settings, - agentPermissionMode: "read-only", - }, - }, - }, - }), - }), - ); - expect(update).toHaveBeenCalledTimes(1); - expect( - update.mock.calls[0][0](DEFAULT_AGENT_SESSION_META).settings - .agentPermissionMode, - ).toBe("read-only"); - }); -}); diff --git a/app/flowix-web/features/agent/store/global-agent-settings-sync.ts b/app/flowix-web/features/agent/store/global-agent-settings-sync.ts deleted file mode 100644 index dbe3ec5fa..000000000 --- a/app/flowix-web/features/agent/store/global-agent-settings-sync.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { STORAGE_KEYS } from "@/lib/constants"; -import type { AgentSessionMeta } from "@features/agent/store/session-state"; - -export function installGlobalAgentSettingsSync( - updateMeta: ( - updater: (meta: AgentSessionMeta) => AgentSessionMeta, - ) => void, -): void { - if (typeof window === "undefined") return; - window.addEventListener("storage", (event) => { - if (event.key !== STORAGE_KEYS.AGENT_SESSION || !event.newValue) return; - try { - const settings = ( - JSON.parse(event.newValue) as { - state?: { sessionMeta?: Pick }; - } - ).state?.sessionMeta?.settings; - if (!settings) return; - updateMeta((meta) => ({ - ...meta, - settings: { ...meta.settings, ...settings }, - })); - } catch { - // Ignore malformed external writes. Rehydration falls back safely. - } - }); -} diff --git a/app/flowix-web/features/agent/store/index.ts b/app/flowix-web/features/agent/store/index.ts index bdbd49eac..c0029c8a9 100644 --- a/app/flowix-web/features/agent/store/index.ts +++ b/app/flowix-web/features/agent/store/index.ts @@ -1,4 +1,19 @@ -export { useAgentAccessStore } from '@features/agent/store/agent-access-store'; +export { ensureAgentPreferencesReady, modelPreferenceKey, useAgentDataStore } from '@features/agent/store/agent-data-store'; +export type { + AgentCatalogModel, + AgentCatalogSkill, + AgentCapabilities, + AgentModelCatalog, + AgentUserPreference, +} from '@features/agent/store/agent-data-store'; +export { ensureAgentWorkspaceReady, useAgentWorkspaceStore } from '@features/agent/store/agent-workspace-store'; +export type { + AgentWorkspaceActions, + AgentWorkspaceLoadState, + AgentWorkspaceLoadStatus, + AgentWorkspaceNotebook, + AgentWorkspaceState, +} from '@features/agent/store/agent-workspace-store'; export { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; export type { AgentConversationInstance, diff --git a/app/flowix-web/features/agent/store/initial-runtime-config.test.ts b/app/flowix-web/features/agent/store/initial-runtime-config.test.ts index 29098ffcb..64f12ca5e 100644 --- a/app/flowix-web/features/agent/store/initial-runtime-config.test.ts +++ b/app/flowix-web/features/agent/store/initial-runtime-config.test.ts @@ -1,144 +1,56 @@ -/** - * 新模型下 buildInitialInstanceRuntimeConfig 只种子 model / access / - * reasoningEffort 的全局默认 + 创建时所属 notebookId ── 文件区域 (cwd / - * folders / notebooks) 在提交时由 agent-runtime-spec 实时推导, 不再烧录 - * 进 instance.files, 也无 _frozen / lockInstanceFileSeed / backfill 机制。 - * - * 旧版本测的 cwd / files / frozen seed / backfill / defaults.files 权威 - * 快照等场景已不再适用, 这份测试只盯 model / access / reasoningEffort / - * notebookId 四条。 - */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentDataStore } from "./agent-data-store"; +import { resolveAgentPreferenceRuntimeConfig } from "./agent-user-preferences"; +import { buildInitialInstanceRuntimeConfig } from "./initial-runtime-config"; -const memoStateMock = vi.hoisted(() => ({ - selectedNotebook: null as null | { id: string; path: string } | unknown, -})); - -const accessStateMock = vi.hoisted(() => ({ - config: { - version: 1, - entries: [] as Array, - } as { - version: number; - entries: Array; - defaults?: { - runtime?: Record< - string, - { - model?: { key: string }; - access?: { sandbox: string }; - reasoningEffort?: string; - } - >; - }; - }, -})); - +const memoState = vi.hoisted(() => ({ selectedNotebook: null as null | { id: string; path: string } })); vi.mock("@features/memo/store/note-store", () => ({ - useNoteStore: { - getState: () => ({ - selectedNotebook: memoStateMock.selectedNotebook, - }), - }, + useNoteStore: { getState: () => memoState }, })); -vi.mock("@features/agent/store/agent-access-store", () => ({ - useAgentAccessStore: { - getState: () => ({ - config: accessStateMock.config, - }), - }, -})); +beforeEach(() => { + memoState.selectedNotebook = null; + useAgentDataStore.setState(useAgentDataStore.getInitialState(), true); +}); -describe("buildInitialInstanceRuntimeConfig — 仅种子 model/access/reasoning/notebookId", () => { - beforeEach(() => { - memoStateMock.selectedNotebook = null; - accessStateMock.config = { version: 1, entries: [] }; +describe("initial runtime from unified Agent preferences", () => { + it("creates a minimal snapshot until preferences or a notebook are selected", () => { + expect(buildInitialInstanceRuntimeConfig()).toEqual({ notebookId: undefined }); }); - it("默认返回无 model/access/reasoning/notebookId 的最小快照", async () => { - const { buildInitialInstanceRuntimeConfig } = - await import("@features/agent/store/initial-runtime-config"); - - const config = buildInitialInstanceRuntimeConfig(); - - expect(config.model).toBeUndefined(); - expect(config.access).toBeUndefined(); - expect(config.reasoningEffort).toBeUndefined(); - expect(config.notebookId).toBeUndefined(); - // cwd / files 不再由 helper 生成 ── 提交时实时推导。 - expect(config.cwd).toBeUndefined(); - expect(config.files).toBeUndefined(); + it("uses the selected notebook, model-specific reasoning and permission", () => { + memoState.selectedNotebook = { id: "nb-1", path: "/notes/one" }; + const store = useAgentDataStore.getState(); + store.setUserPreference("codex", { model: { id: "gpt-6.1-sol" }, permission: "workspace-write" }); + store.rememberModelReasoning("codex", { id: "gpt-6.1-sol" }, "low"); + store.rememberModelReasoning("codex", { id: "other-model" }, "high"); + expect(buildInitialInstanceRuntimeConfig()).toEqual({ + notebookId: "nb-1", model: { key: "gpt-6.1-sol" }, access: { sandbox: "workspace-write" }, reasoningEffort: "low", + }); }); - it("selectedNotebook 已 hydrate 时, 快照其 id 为 notebookId", async () => { - memoStateMock.selectedNotebook = { id: "nb-1", path: "/Users/notes/开发" }; - const { buildInitialInstanceRuntimeConfig } = - await import("@features/agent/store/initial-runtime-config"); - - const config = buildInitialInstanceRuntimeConfig(); - - expect(config.notebookId).toBe("nb-1"); + it("isolates Agent preferences without copying the global Pi mode into conversations", () => { + const store = useAgentDataStore.getState(); + store.setUserPreference("claudecode", { model: { id: "claude-model" } }); + store.setUserPreference("pi", { mode: "code" }); + expect(buildInitialInstanceRuntimeConfig("dsh").model).toBeUndefined(); + expect(buildInitialInstanceRuntimeConfig("pi").pi).toBeUndefined(); + expect(resolveAgentPreferenceRuntimeConfig("pi", { pi: { codeMode: false } }).pi).toBeUndefined(); + expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); }); - it("defaults.runtime[agentType] 里的 model / access / reasoningEffort 种子进 instance", async () => { - accessStateMock.config = { - version: 1, - entries: [], - defaults: { - runtime: { - codex: { - model: { key: "gpt-5.5" }, - access: { sandbox: "workspace-write" }, - reasoningEffort: "high", - }, - }, - }, - }; - const { buildInitialInstanceRuntimeConfig } = - await import("@features/agent/store/initial-runtime-config"); - - const config = buildInitialInstanceRuntimeConfig("codex"); - - expect(config.model).toEqual({ key: "gpt-5.5" }); - expect(config.access).toEqual({ sandbox: "workspace-write" }); - expect(config.reasoningEffort).toBe("high"); - }); - - it("未选 notebook 时 notebookId=undefined (提交侧 defaultFiles 回落当前笔记本)", async () => { - accessStateMock.config = { - version: 1, - entries: [], - defaults: { - runtime: { - codex: { model: { key: "gpt-5.5" } }, - }, - }, - }; - const { buildInitialInstanceRuntimeConfig } = - await import("@features/agent/store/initial-runtime-config"); - - const config = buildInitialInstanceRuntimeConfig("codex"); - - expect(config.notebookId).toBeUndefined(); - expect(config.model).toEqual({ key: "gpt-5.5" }); + it("preserves conversation overrides while applying the selected model's remembered effort", () => { + const store = useAgentDataStore.getState(); + store.setUserPreference("codex", { model: { id: "global" }, permission: "read-only" }); + store.rememberModelReasoning("codex", { id: "conversation", providerId: "provider" }, "low"); + const runtime = resolveAgentPreferenceRuntimeConfig("codex", { + model: { key: "conversation", providerId: "provider" }, access: { sandbox: "workspace-write" }, cwd: "/frozen", + }); + expect(runtime).toMatchObject({ model: { key: "conversation", providerId: "provider" }, access: { sandbox: "workspace-write" }, reasoningEffort: "low", cwd: "/frozen" }); + expect(useAgentDataStore.getState().agents.codex.user.model?.id).toBe("global"); }); - it("其它 agent type 的默认不会污染当前 agent", async () => { - accessStateMock.config = { - version: 1, - entries: [], - defaults: { - runtime: { - claude: { model: { key: "claude-opus-4-8" } }, - }, - }, - }; - const { buildInitialInstanceRuntimeConfig } = - await import("@features/agent/store/initial-runtime-config"); - - const config = buildInitialInstanceRuntimeConfig("deepseek-harness"); - - expect(config.model).toBeUndefined(); + it("does not read legacy runtime defaults outside migration", () => { + expect(buildInitialInstanceRuntimeConfig().model).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/app/flowix-web/features/agent/store/initial-runtime-config.ts b/app/flowix-web/features/agent/store/initial-runtime-config.ts index 4f3efd83f..8bb9cfc3c 100644 --- a/app/flowix-web/features/agent/store/initial-runtime-config.ts +++ b/app/flowix-web/features/agent/store/initial-runtime-config.ts @@ -6,36 +6,19 @@ * 避免插入阶段 store 尚未 hydrate 的 race,也保证后续 turn 不随全局配置变化。 * * 这里只种子 model / access / reasoningEffort 的全局默认, 以及创建时所属 - * notebookId ── 提交时据此读取该笔记本 `.flowix/agent.json` 中的 add-dir + * notebookId ── 提交时据此读取该笔记本 `local.db` 中的 add-dir * 配置。未选笔记本时 notebookId 为 undefined,提交侧不会附加资料目录。 */ import type { AgentTypeKey, RuntimeConfig } from "@/types/agent"; import { DEFAULT_AGENT_TYPE_KEY } from "@/lib/agent-types"; -import { useAgentAccessStore } from "@features/agent/store/agent-access-store"; +import { resolveAgentPreferenceRuntimeConfig } from "./agent-user-preferences"; import { useNoteStore } from "@features/memo/store/note-store"; export function buildInitialInstanceRuntimeConfig( agentType: AgentTypeKey = DEFAULT_AGENT_TYPE_KEY, ): RuntimeConfig { - const accessState = useAgentAccessStore.getState(); - const notebookId = useNoteStore.getState().selectedNotebook?.id ?? undefined; - const defaultRuntime = accessState.config.defaults?.runtime?.[agentType]; - return { - ...(defaultRuntime?.model ? { model: defaultRuntime.model } : {}), - ...(defaultRuntime?.access ? { access: defaultRuntime.access } : {}), - ...(defaultRuntime?.reasoningEffort - ? { reasoningEffort: defaultRuntime.reasoningEffort } - : {}), - ...(defaultRuntime?.serviceTier - ? { serviceTier: defaultRuntime.serviceTier } - : {}), - ...(defaultRuntime?.mode - ? { deepseekHarness: { mode: defaultRuntime.mode } } - : {}), - ...(defaultRuntime?.piCodeMode !== undefined - ? { pi: { codeMode: defaultRuntime.piCodeMode } } - : {}), - notebookId, + ...resolveAgentPreferenceRuntimeConfig(agentType), + notebookId: useNoteStore.getState().selectedNotebook?.id ?? undefined, }; } diff --git a/app/flowix-web/features/agent/store/session-meta-slice.ts b/app/flowix-web/features/agent/store/session-meta-slice.ts index f634dbbb6..f9ff2a43c 100644 --- a/app/flowix-web/features/agent/store/session-meta-slice.ts +++ b/app/flowix-web/features/agent/store/session-meta-slice.ts @@ -1,3 +1,4 @@ +import { useAgentDataStore } from "./agent-data-store"; import type { AgentCodexModel, AgentCodexReasoningEffort, @@ -76,16 +77,16 @@ export function createSessionMetaSlice( ...state.sessionMeta, threadLists: { ...state.sessionMeta.threadLists, - "deepseek-harness": list, + dsh: list, }, }, })), setActiveThreadId: (threadId) => - setActiveThread("deepseek-harness", threadId, false), + setActiveThread("dsh", threadId, false), setActiveCodexThreadId: (threadId) => setActiveThread("codex", threadId, false), setActiveClaudeThreadId: (threadId) => - setActiveThread("claude", threadId, false), + setActiveThread("claudecode", threadId, false), setActiveAgentTypeKey: (typeKey) => set((state) => ({ sessionMeta: { ...state.sessionMeta, activeAgentTypeKey: typeKey }, @@ -105,31 +106,12 @@ export function createSessionMetaSlice( })); }, setAgentPermissionMode: (mode) => - set((state) => ({ - sessionMeta: { - ...state.sessionMeta, - settings: { - ...state.sessionMeta.settings, - agentPermissionMode: mode, - }, - }, - })), + useAgentDataStore.getState().setUserPreference("codex", { permission: mode }), setAgentCodexModel: (model) => - set((state) => ({ - sessionMeta: { - ...state.sessionMeta, - settings: { ...state.sessionMeta.settings, agentCodexModel: model }, - }, - })), - setAgentCodexReasoningEffort: (effort) => - set((state) => ({ - sessionMeta: { - ...state.sessionMeta, - settings: { - ...state.sessionMeta.settings, - agentCodexReasoningEffort: effort, - }, - }, - })), + useAgentDataStore.getState().setUserPreference("codex", { model: { id: model } }), + setAgentCodexReasoningEffort: (effort) => { + const store = useAgentDataStore.getState(); + store.rememberModelReasoning("codex", store.agents.codex.user.model, effort); + }, }; } diff --git a/app/flowix-web/features/agent/store/session-persistence.ts b/app/flowix-web/features/agent/store/session-persistence.ts index 955ad1eae..f72037c07 100644 --- a/app/flowix-web/features/agent/store/session-persistence.ts +++ b/app/flowix-web/features/agent/store/session-persistence.ts @@ -1,8 +1,10 @@ import { DEFAULT_AGENT_TYPE_KEY, isAgentTypeSelectable, + normalizeAgentId, normalizeAgentTypeKey, } from '@/lib/agent-types'; +import type { AgentId } from '@/types/agent'; import { normalizeCodexPermissionMode } from '@features/agent/runtime/agent-runtime-spec'; import { DEFAULT_AGENT_SESSION_META, @@ -28,8 +30,31 @@ export function rehydrateSessionMeta(persisted: unknown): AgentSessionMeta { ? typeKey : DEFAULT_AGENT_TYPE_KEY; base.currentThreadTitles ??= {}; + base.threadTypes = Object.fromEntries( + Object.entries(base.threadTypes ?? {}).flatMap(([threadId, legacyId]) => { + const id = normalizeAgentId(legacyId); + return id ? [[threadId, id]] : []; + }), + ); + base.activeThreadIds = normalizeAgentKeyedRecord(base.activeThreadIds); + base.threadLists = normalizeAgentKeyedRecord(base.threadLists); base.settings.agentPermissionMode = normalizeCodexPermissionMode( base.settings.agentPermissionMode, ); return base; } + +function normalizeAgentKeyedRecord(value: unknown): Partial> { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const source = value as Record; + const result: Partial> = {}; + for (const [key, item] of Object.entries(source)) { + const id = normalizeAgentId(key); + if (id && key === id) result[id] = item; + } + for (const [key, item] of Object.entries(source)) { + const id = normalizeAgentId(key); + if (id && result[id] === undefined) result[id] = item; + } + return result; +} diff --git a/app/flowix-web/features/agent/store/session-reducer/reduce-projection.test.ts b/app/flowix-web/features/agent/store/session-reducer/reduce-projection.test.ts index 723d95617..2dcfd3798 100644 --- a/app/flowix-web/features/agent/store/session-reducer/reduce-projection.test.ts +++ b/app/flowix-web/features/agent/store/session-reducer/reduce-projection.test.ts @@ -5,6 +5,7 @@ import { reduceProjection, } from "@features/agent/store/session-reducer"; import { mergeLiveMessagesIntoRenderableMessages } from "@features/agent/store/thread-history"; +import { codexFallbackItemMessageId } from "@features/agent/events/codex-message-identity"; function event( kind: K, @@ -15,7 +16,7 @@ function event( const userMessage = (text: string, id: string): AgentEvent => event("user_message", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 1000, @@ -28,7 +29,7 @@ const userMessage = (text: string, id: string): AgentEvent => const textDelta = (text: string, messageId?: string): AgentEvent => event("text_delta", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 2000, @@ -43,7 +44,7 @@ const textDelta = (text: string, messageId?: string): AgentEvent => const reasoningDelta = (text: string): AgentEvent => event("reasoning_delta", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 3000, @@ -58,7 +59,7 @@ const reasoningDelta = (text: string): AgentEvent => const streamStart = (runId: string): AgentEvent => event("stream_start", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId, timestamp: 0, @@ -71,7 +72,7 @@ const streamEnd = ( durationMs?: number, ): AgentEvent => event("stream_end", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId, timestamp: 9000, @@ -81,7 +82,7 @@ const streamEnd = ( const errorEvent = (message: string): AgentEvent => event("error", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 9500, @@ -94,7 +95,7 @@ const toolCall = ( reasoningBoundary = false, ): AgentEvent => event("tool_call", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 4000, @@ -111,7 +112,7 @@ const toolCall = ( const toolResult = (id: string, name: string): AgentEvent => event("tool_result", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 5000, @@ -253,6 +254,105 @@ describe("reduceProjection / text streaming lifecycle", () => { expect(p.messages.find((message) => message.id === "item-1")?.content).toBe("Hello"); }); + it("adopts a stable turn-scoped fallback ID when a completed Codex item has no provider ID", () => { + let p = emptyProjection(); + p = reduceProjection(p, event("text_delta", { + agentType: "codex", threadId: "t1", runId: "run-1", timestamp: 1, + text: "same reply", codexTurnId: "turn-fallback", + contentMode: "delta", messagePhase: "updated", + })); + p = reduceProjection(p, event("text_delta", { + agentType: "codex", threadId: "t1", runId: "run-1", timestamp: 2, + text: "same reply", codexTurnId: "turn-fallback", + contentMode: "snapshot", messagePhase: "completed", + })); + + const expected = codexFallbackItemMessageId( + "turn-fallback", "assistant", "same reply", 0, + ); + expect(p.messages).toHaveLength(1); + expect(p.messages[0]).toMatchObject({ + id: expected, + messageId: expected, + content: "same reply", + isCompleted: true, + }); + expect(expected).toBe("flowix:codex:item:turn-fallback:assistant:c9c3d78b64eaec57:0"); + expect(codexFallbackItemMessageId("t", "assistant", "你好🙂", 0)).toBe( + "flowix:codex:item:t:assistant:71b13a3934f0304d:0", + ); + }); + + it("keeps distinct identical Codex items when the backend supplies their occurrence IDs", () => { + let p = emptyProjection(); + for (const occurrence of [0, 1]) { + const messageId = codexFallbackItemMessageId( + "turn-repeat", "assistant", "same reply", occurrence, + ); + const completed = event("text_delta", { + agentType: "codex", threadId: "t1", runId: "run-1", timestamp: occurrence + 1, + text: "same reply", messageId, codexTurnId: "turn-repeat", + contentMode: "snapshot", messagePhase: "completed", + }); + p = reduceProjection(p, completed); + // The terminal turn snapshot can repeat the same identity; it remains an + // idempotent update while an equal-text item with another occurrence id + // stays a separate history row. + p = reduceProjection(p, completed); + } + + expect(p.messages.filter((message) => message.role === "assistant")).toEqual([ + expect.objectContaining({ + id: codexFallbackItemMessageId("turn-repeat", "assistant", "same reply", 0), + content: "same reply", + }), + expect.objectContaining({ + id: codexFallbackItemMessageId("turn-repeat", "assistant", "same reply", 1), + content: "same reply", + }), + ]); + }); + + it("keeps repeated identical idless Codex reasoning snapshots aligned with history", () => { + let p = emptyProjection(); + for (const occurrence of [0, 1]) { + p = reduceProjection(p, event("reasoning_delta", { + agentType: "codex", threadId: "t1", runId: "run-1", timestamp: occurrence + 1, + text: "same thought", codexTurnId: "turn-reasoning", + contentMode: "snapshot", messagePhase: "completed", + sourceSequence: occurrence + 1, + })); + } + + expect(p.messages.filter((message) => message.role === "reasoning")).toEqual([ + expect.objectContaining({ + id: codexFallbackItemMessageId("turn-reasoning", "reasoning", "same thought", 0), + content: "same thought", + }), + expect.objectContaining({ + id: codexFallbackItemMessageId("turn-reasoning", "reasoning", "same thought", 1), + content: "same thought", + }), + ]); + }); + + it("assigns distinct matching fallbacks to repeated Codex user items without provider IDs", () => { + let p = emptyProjection(); + const userEvent = (timestamp: number): AgentEvent => event("user_message", { + agentType: "codex", threadId: "t1", runId: "run-1", timestamp, + id: "flowix:codex:missing-user-item", + messageId: "flowix:codex:missing-user-item", text: "repeat", + codexTurnId: "turn-users", messagePhase: "completed", contentMode: "snapshot", + }); + p = reduceProjection(p, userEvent(1)); + p = reduceProjection(p, userEvent(2)); + + expect(p.messages.map((message) => message.id)).toEqual([ + codexFallbackItemMessageId("turn-users", "user", "repeat", 0), + codexFallbackItemMessageId("turn-users", "user", "repeat", 1), + ]); + }); + it("stream_start → text_delta → text_delta appends content", () => { let p = emptyProjection(); p = reduceProjection(p, streamStart("r1")); @@ -277,23 +377,23 @@ describe("reduceProjection / text streaming lifecycle", () => { p = reduceProjection( p, event("user_message", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r2", timestamp: 2500, text: "second question", - id: "msg:deepseek-harness:r2:user:user-r2", + id: "msg:dsh:r2:user:user-r2", }), ); p = reduceProjection( p, event("text_delta", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r2", timestamp: 2600, text: "second", - messageId: "msg:deepseek-harness:r2:assistant:stream", + messageId: "msg:dsh:r2:assistant:stream", messagePhase: "updated", contentMode: "delta", }), @@ -338,7 +438,7 @@ describe("reduceProjection / text streaming lifecycle", () => { p = reduceProjection( p, event("reasoning_delta", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 6000, @@ -360,13 +460,14 @@ describe("reduceProjection / text streaming lifecycle", () => { }); }); - it("stream_end clears an unfinished reasoning draft and pending state", () => { + it("stream_end retains and seals an unfinished reasoning draft", () => { let p = emptyProjection(); p = reduceProjection(p, streamStart("r1")); p = reduceProjection(p, reasoningDelta("only thinking")); p = reduceProjection(p, streamEnd("r1")); - expect(p.messages).toHaveLength(0); + expect(p.messages).toHaveLength(1); + expect(p.messages[0].isCompleted).toBe(true); expect(p.runs.isLoading).toBe(false); expect(p.runs.activeRunId).toBeNull(); expect(p.runs.lastRun?.status).toBe("completed"); @@ -377,16 +478,34 @@ describe("reduceProjection / text streaming lifecycle", () => { it.each([ ["user stop", "user_stopped"], ["provider completion", null], - ] as const)("clears assistant drafts on %s", (_label, reason) => { + ] as const)("retains and seals assistant drafts on %s", (_label, reason) => { let p = emptyProjection(); p = reduceProjection(p, streamStart("r1")); p = reduceProjection(p, textDelta("unfinished answer")); p = reduceProjection(p, streamEnd("r1", reason)); - expect(p.messages).toHaveLength(0); + expect(p.messages).toHaveLength(1); + expect(p.messages[0].isCompleted).toBe(true); expect(p.pending.assistantId).toBeNull(); }); + it("does not delete a forced-stop Pi answer when the next run fails", () => { + let p = emptyProjection(); + p = reduceProjection(p, { ...streamStart("r1"), agentType: "pi" }); + // Multiple native blocks can exist without a committed message_end. + p = reduceProjection(p, { ...textDelta("first block"), agentType: "pi", messageId: undefined, sourceSubsequence: 0 }); + p = reduceProjection(p, { ...textDelta("last block"), agentType: "pi", messageId: undefined, sourceSubsequence: 1 }); + p = reduceProjection(p, { ...streamEnd("r1", "user_stopped"), agentType: "pi" }); + p = reduceProjection(p, { ...streamStart("r2"), agentType: "pi" }); + p = reduceProjection(p, { ...textDelta("failed draft", "block-2"), agentType: "pi", runId: "r2" }); + p = reduceProjection(p, { ...errorEvent("startup failed"), agentType: "pi", runId: "r2" }); + expect(p.messages.filter(row => row.role === "assistant").map(row => row.content)) + .toEqual(["first block", "last block", "startup failed"]); + expect(p.messages.some(row => row.content === "failed draft")).toBe(false); + expect(p.messages.filter(row => row.role === "assistant").every(row => row.isCompleted)).toBe(true); + expect(p.messages.some(row => row.content === "startup failed")).toBe(true); + }); + it("clears unfinished output on provider error and retains the terminal error row", () => { let p = emptyProjection(); p = reduceProjection(p, streamStart("r1")); @@ -439,7 +558,7 @@ describe("reduceProjection / text streaming lifecycle", () => { p = reduceProjection(p, streamStart("r1")); p = reduceProjection(p, textDelta("answer")); p = reduceProjection(p, event("final_message", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 8000, @@ -475,11 +594,11 @@ describe("reduceProjection / text streaming lifecycle", () => { it("collapses duplicate stdout/process-exit errors for one run", () => { let p = emptyProjection(); const base = { - agentType: "claude" as const, + agentType: "claudecode" as const, threadId: "t1", runId: "r1", timestamp: 9500, - messageId: "msg:claude:r1:error:error", + messageId: "msg:claudecode:r1:error:error", }; p = reduceProjection(p, { kind: "error", @@ -510,17 +629,17 @@ describe("reduceProjection / text streaming lifecycle", () => { p = reduceProjection( p, event("error", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 9500, - messageId: "msg:deepseek-harness:r1:error:error", + messageId: "msg:dsh:r1:error:error", message: "Request timed out.", }), ); expect(p.messages[p.messages.length - 1]).toMatchObject({ - id: "msg:deepseek-harness:r1:error:error", + id: "msg:dsh:r1:error:error", content: "Request timed out.", notice: "deepseek-harness-reconnect-failed", }); @@ -531,11 +650,11 @@ describe("reduceProjection / text streaming lifecycle", () => { p = reduceProjection( p, event("error", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 9600, - messageId: "msg:deepseek-harness:r1:error:quota", + messageId: "msg:dsh:r1:error:quota", message: "Token Plan exhausted", errorDetails: { category: "quota_exhausted", @@ -559,7 +678,7 @@ describe("reduceProjection / tool call cycle", () => { it("keeps harness assistant segments separate while the run is still streaming", () => { const harnessText = (text: string, messageId: string) => event("text_delta", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 2000, @@ -569,24 +688,24 @@ describe("reduceProjection / tool call cycle", () => { contentMode: "delta", }); const harnessToolCall = event("tool_call", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 3000, - toolCallId: "msg:deepseek-harness:r1:tool-call:c1", + toolCallId: "msg:dsh:r1:tool-call:c1", name: "read", input: { file_path: "README.md" }, - messageId: "msg:deepseek-harness:r1:tool:c1", + messageId: "msg:dsh:r1:tool:c1", }); const harnessToolResult = event("tool_result", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 4000, - toolCallId: "msg:deepseek-harness:r1:tool-call:c1", + toolCallId: "msg:dsh:r1:tool-call:c1", name: "read", result: "ok", - messageId: "msg:deepseek-harness:r1:tool:c1", + messageId: "msg:dsh:r1:tool:c1", }); let p = emptyProjection(); p = reduceProjection(p, streamStart("r1")); @@ -594,7 +713,7 @@ describe("reduceProjection / tool call cycle", () => { p, harnessText( "before tool", - "msg:deepseek-harness:r1:assistant:assistant-stream-0", + "msg:dsh:r1:assistant:assistant-stream-0", ), ); p = reduceProjection(p, harnessToolCall); @@ -603,7 +722,7 @@ describe("reduceProjection / tool call cycle", () => { p, harnessText( "after tool", - "msg:deepseek-harness:r1:assistant:assistant-stream-1", + "msg:dsh:r1:assistant:assistant-stream-1", ), ); @@ -615,7 +734,7 @@ describe("reduceProjection / tool call cycle", () => { ["assistant", "after tool"], ]); expect(p.pending.assistantId).toBe( - "msg:deepseek-harness:r1:assistant:assistant-stream-1", + "msg:dsh:r1:assistant:assistant-stream-1", ); }); @@ -695,7 +814,7 @@ describe("reduceProjection / DSH command operations", () => { p = reduceProjection( p, event("dsh_command", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "command-run-1", timestamp: 1000, @@ -721,7 +840,7 @@ describe("reduceProjection / DSH command operations", () => { p = reduceProjection( p, event("dsh_command", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "command-run-1", timestamp: 2000, @@ -750,7 +869,7 @@ describe("reduceProjection / DSH command operations", () => { p = reduceProjection( p, event("dsh_command", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "command-run-1", timestamp: 1000, @@ -784,7 +903,7 @@ describe("reduceProjection / session_resolved is a no-op", () => { const after = reduceProjection( p, event("session_resolved", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "test-run", timestamp: 9999, @@ -818,7 +937,7 @@ describe("reduceProjection / Codex command operations", () => { expect(p.messages).toMatchObject([ { - id: "codex-command:live:codex-command-1", + id: "flowix:codex:command:pending:codex-command-1", role: "user", messageType: "codex-command", content: "/goal set ship it", @@ -853,10 +972,11 @@ describe("reduceProjection / Codex command operations", () => { ); expect(p.messages[0]).toMatchObject({ - id: "codex-command:live:codex-command-1", + id: "flowix:codex:command:turn-goal-1", codexTurnId: "turn-goal-1", isCompleted: true, }); + expect(p.messages).toHaveLength(1); }); it.each(["error", "cancelled"] as const)( @@ -882,7 +1002,7 @@ describe("reduceProjection / usage accumulates into runs", () => { p = reduceProjection( p, event("usage", { - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "r1", timestamp: 8000, diff --git a/app/flowix-web/features/agent/store/session-reducer/reduce-projection.ts b/app/flowix-web/features/agent/store/session-reducer/reduce-projection.ts index 3348e26d3..7a685bbcb 100644 --- a/app/flowix-web/features/agent/store/session-reducer/reduce-projection.ts +++ b/app/flowix-web/features/agent/store/session-reducer/reduce-projection.ts @@ -1,6 +1,12 @@ import type { ChatMessage } from "@/types"; import type { AgentEvent } from "@/types/agent"; import { completedRunUserMessageId } from "@features/agent/events/message-identity"; +import { + CODEX_MISSING_USER_ITEM_ID, + codexCommandMessageId, + codexFallbackItemMessageId, + codexPendingCommandMessageId, +} from "@features/agent/events/codex-message-identity"; import { applyErrorChunk, applyReasoningChunk, @@ -91,9 +97,16 @@ function applyUserMessageToProjection( const isOptimisticPiUser = event.agentType === "pi" && event.id === completedRunUserMessageId(event.agentType, event.runId); + const codexUserHasSyntheticId = event.agentType === "codex" && + event.id === CODEX_MISSING_USER_ITEM_ID && !!event.codexTurnId; + const codexUserId = codexUserHasSyntheticId + ? codexFallbackIdForCompletedItem(p, event, "user", event.text, null) + : undefined; + const messageId = codexUserId ?? event.id; const next = applyUserMessageChunk(p, event.text, { - id: event.id, - nativeMessageId: !isOptimisticPiUser && (event.agentType === "pi" || event.agentType === "codex") + id: messageId, + nativeMessageId: !isOptimisticPiUser && !codexUserHasSyntheticId && + (event.agentType === "pi" || event.agentType === "codex") ? event.id : undefined, isOptimistic: isOptimisticPiUser, messageType: event.messageType, @@ -117,6 +130,37 @@ function applyUserMessageToProjection( }; } +function codexFallbackIdForCompletedItem( + p: ThreadProjection, + event: AgentEvent, + role: "user" | "assistant" | "reasoning", + content: string, + pendingId: string | null, +): string | undefined { + if ( + event.agentType !== "codex" || + (event.messageId && event.messageId !== CODEX_MISSING_USER_ITEM_ID) || + event.contentMode !== "snapshot" || event.messagePhase !== "completed" || + !event.codexTurnId + ) return undefined; + + const prefix = `flowix:codex:item:${event.codexTurnId}:${role}:`; + const matching = p.messages.filter((message) => + message.id !== pendingId && + message.codexTurnId === event.codexTurnId && + message.role === role && + message.content === content && + message.id.startsWith(prefix), + ); + const pendingExists = pendingId != null && p.messages.some((message) => message.id === pendingId); + // Codex can replay the final assistant item in its terminal turn snapshot. + // Reasoning items have no such replay path and must retain their occurrence. + if (role === "assistant" && !pendingExists && matching.length > 0) { + return matching[matching.length - 1].id; + } + return codexFallbackItemMessageId(event.codexTurnId, role, content, matching.length); +} + function isLiveDshPlanPrompt(p: ThreadProjection, text: string): boolean { const prompt = text.split("\n<## CONTEXT PROMPT ##>", 1)[0].trim(); if (!prompt) return false; @@ -153,7 +197,10 @@ function applyTextDeltaToProjection( } const next = applyTextChunk(p, event.text, { id: event.agentType === "pi" && event.messageId && event.sourceSubsequence !== undefined - ? `${event.messageId}:block:${event.sourceSubsequence}` : event.messageId, + ? `${event.messageId}:block:${event.sourceSubsequence}` + : event.messageId ?? codexFallbackIdForCompletedItem( + p, event, "assistant", event.text, p.pending.assistantId, + ), nativeMessageId: event.agentType === "pi" || event.agentType === "codex" ? event.messageId : undefined, blockIndex: event.agentType === "pi" ? event.sourceSubsequence : undefined, phase: event.messagePhase, @@ -183,7 +230,10 @@ function applyReasoningDeltaToProjection( ): ThreadProjection { const next = applyReasoningChunk(p, event.text, { id: event.agentType === "pi" && event.messageId && event.sourceSubsequence !== undefined - ? `${event.messageId}:block:${event.sourceSubsequence}` : event.messageId, + ? `${event.messageId}:block:${event.sourceSubsequence}` + : event.messageId ?? codexFallbackIdForCompletedItem( + p, event, "reasoning", event.text, p.pending.reasoningId, + ), nativeMessageId: event.agentType === "pi" || event.agentType === "codex" ? event.messageId : undefined, blockIndex: event.agentType === "pi" ? event.sourceSubsequence : undefined, phase: event.messagePhase, @@ -233,7 +283,9 @@ function applyFinalMessageToProjection( ): ThreadProjection { // final_message 形态与 text_delta 一致, 仅 contentMode="snapshot" 且 phase="completed". const next = applyTextChunk(p, event.text, { - id: event.messageId, + id: event.messageId ?? codexFallbackIdForCompletedItem( + p, event, "assistant", event.text, p.pending.assistantId, + ), nativeMessageId: event.agentType === "codex" ? event.messageId : undefined, phase: event.messagePhase, contentMode: event.contentMode, @@ -380,8 +432,13 @@ function applyCodexCommandToProjection( p: ThreadProjection, event: AgentEvent & { kind: "codex_command" }, ): ThreadProjection { - const messageId = `codex-command:live:${event.id}`; - const existingIndex = p.messages.findIndex((message) => message.id === messageId); + const provisionalId = codexPendingCommandMessageId(event.id); + const messageId = event.codexTurnId + ? codexCommandMessageId(event.codexTurnId) + : provisionalId; + const existingIndex = p.messages.findIndex((message) => + message.id === messageId || message.id === provisionalId, + ); const message = { id: messageId, role: "user" as const, @@ -447,19 +504,16 @@ function applyStreamEndToProjection( ): ThreadProjection { const runsNext = applyRunEndedCore(p.runs, event); if (runsNext === p.runs) return p; - // run 结束时把仍 loading 的 tool 行收尾为 isLoading=false (避免中断 tool 永久转圈), - // 并完成仍 pending 的 reasoning 行。助手文本即使没有单独的 final 事件也要保留。 - // 仅在最后一个 run 结束时执行,避免误改并发 run 的消息。 + // Once no run remains active, retained output is terminal even if the + // provider never delivered its final snapshot (for example a forced Pi + // stop). Seal every draft block so a later run's error cannot delete it. const terminalMessages = !runsNext.isLoading - ? closeLoadingToolRows( - p.pending.reasoningId - ? p.messages.map((message) => - message.id === p.pending.reasoningId && message.role === "reasoning" - ? { ...message, isCompleted: true } - : message, - ) - : p.messages, - ) + ? closeLoadingToolRows(p.messages.map((message) => + (message.role === "assistant" || message.role === "reasoning") && + message.isCompleted !== true + ? { ...message, isCompleted: true } + : message, + )) : p.messages; const messagesWithDuration = !runsNext.isLoading && @@ -543,7 +597,7 @@ function clearUnfinishedAssistantDrafts(messages: ChatMessage[]): ChatMessage[] function shouldUseDeepSeekHarnessReconnectNotice( event: AgentEvent & { kind: "error" }, ): boolean { - if (event.agentType !== "deepseek-harness") return false; + if (event.agentType !== "dsh") return false; // A classified provider error has its own user-facing guidance. It must not // be presented as a transport/reconnect failure. diff --git a/app/flowix-web/features/agent/store/session-state.ts b/app/flowix-web/features/agent/store/session-state.ts index 70a440372..f13759f1f 100644 --- a/app/flowix-web/features/agent/store/session-state.ts +++ b/app/flowix-web/features/agent/store/session-state.ts @@ -22,6 +22,7 @@ export interface AgentSessionMeta { currentThreadTitles: Partial>; externalSessionResolutions: Record; lastRunningRunsReconciledAt: number | null; + /** Legacy migration input; active preferences live in Agent Data Store. */ settings: { agentPermissionMode: AgentPermissionMode; agentCodexModel: AgentCodexModel; diff --git a/app/flowix-web/features/agent/store/thread-history-slice.test.ts b/app/flowix-web/features/agent/store/thread-history-slice.test.ts index 7e1bcdac4..93c9b918d 100644 --- a/app/flowix-web/features/agent/store/thread-history-slice.test.ts +++ b/app/flowix-web/features/agent/store/thread-history-slice.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatMessage } from "@/types"; import { attachHistoryCursor, HistoryChangedError, type ThreadHistoryPage } from "./agent-history-adapters"; import type { ProjectionSlice } from "./projection-slice"; -import { emptyProjection } from "./session-reducer"; +import { emptyProjection, reduceProjection } from "./session-reducer"; import { createThreadHistorySlice, type ThreadHistorySlice } from "./thread-history-slice"; import { DEFAULT_AGENT_SESSION_META } from "./session-state"; @@ -31,7 +31,7 @@ function store(messages: ChatMessage[]) { let state: Context; state = { ...createThreadHistorySlice(() => state), - sessionMeta: { ...DEFAULT_AGENT_SESSION_META, threadTypes: { thread: "claude" }, externalSessionResolutions: { thread: "native-a" } }, + sessionMeta: { ...DEFAULT_AGENT_SESSION_META, threadTypes: { thread: "claudecode" }, externalSessionResolutions: { thread: "native-a" } }, threadProjections: { thread: { ...emptyProjection(), messages } }, threadEpochs: {}, threadTombstones: {}, threadMutationGuards: {}, codexLiveTurns: {}, threadRunSignatures: {}, latestCompletedRunIds: {}, readThroughRunIds: {}, runStateVersion: 0, @@ -54,6 +54,32 @@ function store(messages: ChatMessage[]) { describe("Pi history request fences", () => { beforeEach(() => { adapter.getInitialHistory.mockReset(); adapter.getFullHistory.mockReset(); adapter.getPage.mockReset(); }); + it("restores the first turn and second-turn prompt while the second turn keeps streaming after refresh", async () => { + const get = store([]); + const pending = deferred(); + adapter.getInitialHistory.mockReturnValueOnce(pending.promise); + const loading = get().loadMessages("pi", "thread"); + const stream = (text: string) => { + get().threadProjections.thread = reduceProjection(get().threadProjections.thread, { + kind: "text_delta", agentType: "pi", threadId: "thread", runId: "second-run", + timestamp: 2000, sourceSubsequence: 0, contentMode: "delta", text, + }); + }; + stream("second answer"); + const history = [ + { ...row("u1"), role: "user" as const }, row("a1"), + { ...row("u2"), role: "user" as const }, + ]; + pending.resolve(page(history, "u2")); + await loading; + expect(get().threadProjections.thread.messages.slice(0, 3)).toEqual(history); + stream(" continues"); + const messages = get().threadProjections.thread.messages; + expect(messages.slice(0, 3)).toEqual(history); + expect(messages).toHaveLength(4); + expect(messages[3].content).toBe("second answer continues"); + }); + it("keeps known-empty Pi history ready while refreshing before the first message", async () => { const get = store([]); get().updateThreadHistory("thread", (projection) => ({ @@ -160,7 +186,7 @@ describe("history binding request fences", () => { const get = store([row("visible")]); const pending = deferred(); adapter.getInitialHistory.mockReturnValueOnce(pending.promise); - const loading = get().loadMessages("claude", "thread"); + const loading = get().loadMessages("claudecode", "thread"); get().sessionMeta.externalSessionResolutions.thread = "native-b"; pending.resolve(page([row("stale")], "stale")); await loading; @@ -171,9 +197,9 @@ describe("history binding request fences", () => { const get = store([]); const stale = deferred(); adapter.getInitialHistory.mockReturnValueOnce(stale.promise).mockResolvedValueOnce(page([row("fresh")], "fresh")); - const oldLoad = get().loadMessages("claude", "thread"); + const oldLoad = get().loadMessages("claudecode", "thread"); get().sessionMeta.externalSessionResolutions.thread = "native-b"; - await get().loadMessages("claude", "thread"); + await get().loadMessages("claudecode", "thread"); stale.resolve(page([row("stale")], "stale")); await oldLoad; expect(get().threadProjections.thread.messages.map((item) => item.id)).toEqual(["fresh"]); @@ -184,8 +210,8 @@ describe("history binding request fences", () => { const get = store([]); const pending = deferred(); adapter.getInitialHistory.mockReturnValueOnce(pending.promise); - const first = get().loadMessages("claude", "thread"); - await get().loadMessages("claude", "thread"); + const first = get().loadMessages("claudecode", "thread"); + await get().loadMessages("claudecode", "thread"); expect(adapter.getInitialHistory).toHaveBeenCalledTimes(1); pending.resolve(page([row("first")], "first")); await first; @@ -195,7 +221,7 @@ describe("history binding request fences", () => { const get = store([]); const pending = deferred(); adapter.getInitialHistory.mockReturnValueOnce(pending.promise); - const loading = get().loadMessages("claude", "thread"); + const loading = get().loadMessages("claudecode", "thread"); get().sessionMeta.externalSessionResolutions.thread = "native-b"; pending.resolve(page([row("stale")], "stale")); await loading; @@ -211,7 +237,7 @@ describe("history binding request fences", () => { })); const pending = deferred(); adapter.getPage.mockReturnValueOnce(pending.promise); - const loading = get().loadMoreMessages("claude", "thread"); + const loading = get().loadMoreMessages("claudecode", "thread"); get().sessionMeta.externalSessionResolutions.thread = "native-b"; pending.resolve(page([row("stale")], "stale")); await loading; @@ -221,7 +247,7 @@ describe("history binding request fences", () => { it("merges a partial history page without dropping a live message", () => { const get = store([row("live")]); - get().applyHistoryPage("thread", "claude", { messages: [row("older")], coverage: "partial" }); + get().applyHistoryPage("thread", "claudecode", { messages: [row("older")], coverage: "partial" }); expect(get().threadProjections.thread.messages.map((item) => item.id)).toEqual(["older", "live"]); }); @@ -260,7 +286,7 @@ describe("full history refresh", () => { const get = store([old]); const pending = deferred(); adapter.getFullHistory.mockReturnValueOnce(pending.promise); - const loading = get().reloadMessagesFromHistory("deepseek-harness", "thread"); + const loading = get().reloadMessagesFromHistory("dsh", "thread"); const live = { ...old, content: "new live content" }; get().updateThreadHistory("thread", (projection) => ({ ...projection, messages: [live] })); pending.resolve([old]); diff --git a/app/flowix-web/features/agent/store/thread-history.test.ts b/app/flowix-web/features/agent/store/thread-history.test.ts index 32daf5baf..c25a5c07e 100644 --- a/app/flowix-web/features/agent/store/thread-history.test.ts +++ b/app/flowix-web/features/agent/store/thread-history.test.ts @@ -38,7 +38,7 @@ describe("mergeMessagesForThreadRender", () => { const time = "2026-01-01T00:00:00Z"; const history = [message("user-old", "user", "first", time), message("answer-old", "assistant", "Done.", time)]; const live = [message("user-new", "user", "second", time), message("answer-new", "assistant", "Done.", time)]; - expect(mergeMessagesForThreadRender({ history, live, agentType: "claude" }).map((row) => row.id)) + expect(mergeMessagesForThreadRender({ history, live, agentType: "claudecode" }).map((row) => row.id)) .toEqual(["user-old", "answer-old", "user-new", "answer-new"]); }); it("uses the new Pi projection order when existing render rows are reordered", () => { @@ -354,7 +354,7 @@ describe("mergeHistoricalMessages", () => { const time = "2026-01-01T00:00:00Z"; const existing = [message("user-new", "user", "second question", time), message("reply-new", "assistant", "Done.", time)]; const historical = [message("user-old", "user", "first question", time), message("reply-old", "assistant", "Done.", time)]; - expect(mergeHistoricalMessages(existing, historical, "claude").map((item) => item.id)) + expect(mergeHistoricalMessages(existing, historical, "claudecode").map((item) => item.id)) .toEqual(["user-old", "reply-old", "user-new", "reply-new"]); }); it("keeps identical Codex replies from different native turns", () => { @@ -393,7 +393,7 @@ describe("mergeHistoricalMessages", () => { const existing = [ { ...message( - "codex-command:live:command-1", + "flowix:codex:command:turn-1", "user", "/compact", "2026-08-29T10:00:01.000Z", @@ -406,7 +406,7 @@ describe("mergeHistoricalMessages", () => { const historical = [ { ...message( - "codex-command-history-compaction-1", + "flowix:codex:command:turn-1", "user", "/compact", "2026-08-29T10:00:00.000Z", @@ -428,7 +428,7 @@ describe("mergeHistoricalMessages", () => { expect( mergeHistoricalMessages(existing, historical, "codex").map((item) => item.id), - ).toEqual(["codex-command-history-compaction-1", "compaction-1"]); + ).toEqual(["flowix:codex:command:turn-1", "compaction-1"]); }); }); @@ -616,7 +616,7 @@ describe("replaceCompletedRunWithHistory", () => { const existing = [ message("old-user", "user", "same prompt", "2026-01-01T00:00:00.000Z"), message("old-assistant", "assistant", "old answer", "2026-01-01T00:00:01.000Z"), - message("msg:deepseek-harness:run-2:user:user-run-2", "user", "same prompt", "2026-01-01T00:00:02.000Z"), + message("msg:dsh:run-2:user:user-run-2", "user", "same prompt", "2026-01-01T00:00:02.000Z"), message("live-assistant", "assistant", "current answer", "2026-01-01T00:00:03.000Z"), ]; const history = [ @@ -631,7 +631,7 @@ describe("replaceCompletedRunWithHistory", () => { existing, history, "run-2", - "deepseek-harness", + "dsh", ).map((item) => item.content), ).toEqual(["same prompt", "old answer", "same prompt", "current answer"]); }); diff --git a/app/flowix-web/features/agent/store/thread-lifecycle-slice.ts b/app/flowix-web/features/agent/store/thread-lifecycle-slice.ts index a7a47d224..9d2881aae 100644 --- a/app/flowix-web/features/agent/store/thread-lifecycle-slice.ts +++ b/app/flowix-web/features/agent/store/thread-lifecycle-slice.ts @@ -64,10 +64,10 @@ async function loadThreadList( ): Promise { const type = getAgentType(typeKey); try { - const threads = await listHistoryThreads(type.key); + const threads = await listHistoryThreads(type.id); get().setSessionMeta((meta) => ({ ...meta, - threadLists: { ...meta.threadLists, [type.key]: threads }, + threadLists: { ...meta.threadLists, [type.id]: threads }, })); } catch (error) { logger.error(`Failed to load ${errorLabel} thread list`, { error: String(error) }); @@ -85,25 +85,25 @@ async function loadThread( get().activateThread(threadId); const meta = get().sessionMeta; const threadInfo = await findHistoryThreadInfo( - type.key, + type.id, threadId, - meta.threadLists[type.key] ?? [], + meta.threadLists[type.id] ?? [], ); get().setSessionMeta((current) => ({ ...current, - activeAgentTypeKey: type.key, - activeThreadIds: { ...current.activeThreadIds, [type.key]: threadId }, + activeAgentTypeKey: type.id, + activeThreadIds: { ...current.activeThreadIds, [type.id]: threadId }, threadTypes: { ...current.threadTypes, - [threadId]: current.threadTypes[threadId] ?? type.key, + [threadId]: current.threadTypes[threadId] ?? type.id, }, currentThreadTitles: { ...current.currentThreadTitles, - [threadId]: threadInfo?.title ?? defaultThreadTitle(type.key), + [threadId]: threadInfo?.title ?? defaultThreadTitle(type.id), }, })); get().clearThreadPending(threadId); - await get().loadMessages(type.key, threadId); + await get().loadMessages(type.id, threadId); } catch (error) { logger.error(`Failed to load ${type.name} thread`, { error: String(error) }); } @@ -116,9 +116,9 @@ async function reloadThreadListForType( typeKey: AgentTypeKey, ): Promise { const store = get(); - if (typeKey === "deepseek-harness") await store.loadThreadList(); + if (typeKey === "dsh") await store.loadThreadList(); else if (typeKey === "codex") await store.loadCodexThreadList(); - else if (typeKey === "claude") await store.loadClaudeThreadList(); + else if (typeKey === "claudecode") await store.loadClaudeThreadList(); else if (typeKey === "hermes") await store.loadHermesThreadList(); else await store.loadLocalAgentThreadList(typeKey); } @@ -214,9 +214,9 @@ export function createThreadLifecycleSlice( // cleanup out of `finally` prevents the work-column conversation from // disappearing while archive/delete is still pending or has failed. if (action === "archive") { - await agentClient.archiveAgentThread(typeKey.key, threadId); + await agentClient.archiveAgentThread(typeKey.id, threadId); } else { - await agentClient.deleteAgentThread(typeKey.key, threadId); + await agentClient.deleteAgentThread(typeKey.id, threadId); } // Notify the UI as soon as the provider confirms success. Local instance @@ -230,7 +230,7 @@ export function createThreadLifecycleSlice( closeConversationIfActive(threadId, get); // provider 侧列表 (codex thread/list 等) 不再返回该 thread, 刷新让 // 侧栏与后端状态对齐; 失败不阻塞流程。 - await reloadThreadListForType(get, typeKey.key).catch(() => undefined); + await reloadThreadListForType(get, typeKey.id).catch(() => undefined); set((state) => ({ lifecycleVersion: state.lifecycleVersion + 1 })); } finally { get().setThreadMutationGuard(threadId, false); @@ -240,29 +240,29 @@ export function createThreadLifecycleSlice( return { lifecycleVersion: 0, loadThreadList: () => - loadThreadList(get, "deepseek-harness", "DeepSeek Harness"), - loadThread: (threadId) => loadThread(get, "deepseek-harness", threadId), + loadThreadList(get, "dsh", "DeepSeek Harness"), + loadThread: (threadId) => loadThread(get, "dsh", threadId), loadCodexThreadList: () => loadThreadList(get, "codex", "Codex"), loadCodexThread: (threadId) => loadThread(get, "codex", threadId), - loadClaudeThreadList: () => loadThreadList(get, "claude", "Claude Code"), - loadClaudeThread: (threadId) => loadThread(get, "claude", threadId), + loadClaudeThreadList: () => loadThreadList(get, "claudecode", "Claude Code"), + loadClaudeThread: (threadId) => loadThread(get, "claudecode", threadId), loadHermesThreadList: () => loadThreadList(get, "hermes", "Hermes"), loadHermesThread: (threadId) => loadThread(get, "hermes", threadId), loadAgentThread: (typeKey, threadId) => loadThread(get, typeKey, threadId), loadLocalAgentThreadList: async (typeKey) => { const type = getAgentType(typeKey); - if (["deepseek-harness", "codex", "claude", "hermes"].includes(type.key)) return; - await loadThreadList(get, type.key, type.name); + if (["dsh", "codex", "claudecode", "hermes"].includes(type.id)) return; + await loadThreadList(get, type.id, type.name); }, loadThreadCache: async (threadId) => { try { - await get().loadMessages("deepseek-harness", threadId); + await get().loadMessages("dsh", threadId); } catch (error) { logger.error("Failed to load thread cache", { error: String(error) }); } }, loadMoreHistory: async (typeKey, threadId) => { - await get().loadMoreMessages(getAgentType(typeKey).key, threadId); + await get().loadMoreMessages(getAgentType(typeKey).id, threadId); }, deleteThread: (threadId, onProviderSuccess) => runThreadLifecycle(threadId, "delete", onProviderSuccess), @@ -281,12 +281,12 @@ export function createThreadLifecycleSlice( const initialProductThreadId = resolveProductThreadId( threadId, before.externalSessionResolutions, - type.key, + type.id, before.threadTypes, (id) => isKnownProductThreadId(id, get()), ); const initialIds = new Set([threadId, initialProductThreadId]); - const previousList = before.threadLists[type.key] ?? []; + const previousList = before.threadLists[type.id] ?? []; const previousThreadTitles = before.currentThreadTitles; const applyTitle = (meta: typeof before, productThreadId: string) => { const currentThreadTitles = { ...meta.currentThreadTitles }; @@ -295,14 +295,14 @@ export function createThreadLifecycleSlice( } currentThreadTitles[productThreadId] = nextTitle; - const threadLists = (meta.threadLists[type.key] ?? []).map((item) => + const threadLists = (meta.threadLists[type.id] ?? []).map((item) => initialIds.has(item.threadId) ? { ...item, threadId: productThreadId, title: nextTitle } : item, ); const activeThreadIds = { ...meta.activeThreadIds }; - if (initialIds.has(activeThreadIds[type.key] ?? "")) { - activeThreadIds[type.key] = productThreadId; + if (initialIds.has(activeThreadIds[type.id] ?? "")) { + activeThreadIds[type.id] = productThreadId; } const externalSessionResolutions = productThreadId !== threadId @@ -313,14 +313,14 @@ export function createThreadLifecycleSlice( ...meta, threadTypes: { ...meta.threadTypes, - [threadId]: type.key, - [productThreadId]: type.key, + [threadId]: type.id, + [productThreadId]: type.id, }, activeThreadIds, currentThreadTitles, threadLists: { ...meta.threadLists, - [type.key]: threadLists, + [type.id]: threadLists, }, externalSessionResolutions, }; @@ -332,7 +332,7 @@ export function createThreadLifecycleSlice( const persisted = await agentClient.updateThreadTitle( threadId, nextTitle, - type.key, + type.id, ); const persistedProductThreadId = persisted?.threadId ?? initialProductThreadId; @@ -344,7 +344,7 @@ export function createThreadLifecycleSlice( // The title and conversation instances are already updated // optimistically above. Provider history reloads can wake an external // runtime and take several seconds, so they must not block saving. - void reloadThreadListForType(get, type.key).catch((error) => { + void reloadThreadListForType(get, type.id).catch((error) => { logger.error("Failed to refresh thread list after rename", { error: String(error) }); }); } catch (error) { @@ -355,7 +355,7 @@ export function createThreadLifecycleSlice( }, threadLists: { ...meta.threadLists, - [type.key]: previousList, + [type.id]: previousList, }, })); logger.error("Failed to update thread title", { error: String(error) }); diff --git a/app/flowix-web/features/agent/store/thread-runtime-state.test.ts b/app/flowix-web/features/agent/store/thread-runtime-state.test.ts index 4d08c2f54..c5277d63a 100644 --- a/app/flowix-web/features/agent/store/thread-runtime-state.test.ts +++ b/app/flowix-web/features/agent/store/thread-runtime-state.test.ts @@ -57,7 +57,7 @@ describe("thread-runtime-state helpers", () => { ...t, isLoading: true, activeRunId: "run-1", - runs: { "run-1": { runId: "run-1", agentType: "deepseek-harness" as const, threadId: "t1", startedAt: 1, status: "running" as const } }, + runs: { "run-1": { runId: "run-1", agentType: "dsh" as const, threadId: "t1", startedAt: 1, status: "running" as const } }, }; expect(isThreadRunActive(running)).toBe(true); @@ -72,7 +72,7 @@ describe("thread-runtime-state helpers", () => { const t = emptyThreadState(); const event: AgentEvent = { kind: "text_delta", - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "run-1", text: "hello", @@ -89,7 +89,7 @@ describe("thread-runtime-state helpers", () => { const t = emptyThreadState(); const streamStart: AgentEvent = { kind: "stream_start", - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "run-1", timestamp: 1234, @@ -98,7 +98,7 @@ describe("thread-runtime-state helpers", () => { const usage: AgentEvent = { kind: "usage", - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "run-1", timestamp: 1234, @@ -118,7 +118,7 @@ describe("thread-runtime-state helpers", () => { runs: { "run-existing": { runId: "run-existing", - agentType: "deepseek-harness" as const, + agentType: "dsh" as const, threadId: "t1", startedAt: 1000, status: "running" as const, @@ -127,7 +127,7 @@ describe("thread-runtime-state helpers", () => { }; const event: AgentEvent = { kind: "tool_call", - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "run-newer", toolCallId: "tc-1", @@ -146,14 +146,14 @@ describe("thread-runtime-state helpers", () => { ...emptyThreadState(), lastRun: { runId: "run-1", - agentType: "deepseek-harness" as const, + agentType: "dsh" as const, startedAt: 1000, status: "cancelled" as const, }, }; const lateText: AgentEvent = { kind: "text_delta", - agentType: "deepseek-harness", + agentType: "dsh", threadId: "t1", runId: "run-1", text: "leftover", diff --git a/app/flowix-web/features/agent/store/thread-runtime-state.ts b/app/flowix-web/features/agent/store/thread-runtime-state.ts index a36118868..3e30c7a37 100644 --- a/app/flowix-web/features/agent/store/thread-runtime-state.ts +++ b/app/flowix-web/features/agent/store/thread-runtime-state.ts @@ -36,8 +36,7 @@ export interface ThreadState { * 通用 metadata 协议 ── 最新一次 run 的展示快照。 * runs[runId] 在正常完成时被清理,但 lastRun 仍保留 model / usage / * startedAt / endedAt 等关键 metadata,供 BadgeHoverCard 等"展示"层 - * 在 run 结束后仍可读。Provider-agnostic:对 Codex / Claude / Gemini / - * Flowix / Hermes / OpenClaw 全部适用。 + * 在 run 结束后仍可读。Provider-agnostic: 对 Codex / Claude / Flowix / Hermes 全部适用。 */ lastRun?: LastRunSnapshot; /** Layer 4: 当前 in-memory messages 中最早一条的 sequence (作下一页 cursor). diff --git a/app/flowix-web/features/agent/store/thread-titles.test.ts b/app/flowix-web/features/agent/store/thread-titles.test.ts index a4cfeabaf..0c1b4e7ab 100644 --- a/app/flowix-web/features/agent/store/thread-titles.test.ts +++ b/app/flowix-web/features/agent/store/thread-titles.test.ts @@ -26,64 +26,54 @@ describe("thread-titles helpers", () => { }); it("isExternalAgentType returns true for all supported runtimes", () => { - expect(isExternalAgentType("deepseek-harness")).toBe(true); + expect(isExternalAgentType("dsh")).toBe(true); expect(isExternalAgentType("codex")).toBe(true); - expect(isExternalAgentType("claude")).toBe(true); - expect(isExternalAgentType("deepseek-harness")).toBe(true); - expect(isExternalAgentType("gemini")).toBe(true); + expect(isExternalAgentType("claudecode")).toBe(true); + expect(isExternalAgentType("dsh")).toBe(true); expect(isExternalAgentType("hermes")).toBe(true); - expect(isExternalAgentType("openclaw")).toBe(true); }); it("persists every agent title in the product database", () => { - expect(canPersistThreadTitle("deepseek-harness")).toBe(true); + expect(canPersistThreadTitle("dsh")).toBe(true); expect(canPersistThreadTitle("codex")).toBe(true); - expect(canPersistThreadTitle("claude")).toBe(true); - expect(canPersistThreadTitle("deepseek-harness")).toBe(true); + expect(canPersistThreadTitle("claudecode")).toBe(true); + expect(canPersistThreadTitle("dsh")).toBe(true); expect(canPersistThreadTitle("hermes")).toBe(true); - expect(canPersistThreadTitle("gemini")).toBe(true); - expect(canPersistThreadTitle("openclaw")).toBe(true); }); it("defaultExternalThreadTitle returns type-specific translated text", () => { expect(defaultExternalThreadTitle("codex")).toBe("Codex session"); // 注意: Claude agent name 是 "Claude Code" 不是 "Claude", 因此 i18n 词条返回 // "Claude Code session". - expect(defaultExternalThreadTitle("claude")).toBe("Claude Code session"); - expect(defaultExternalThreadTitle("deepseek-harness")).toBe( + expect(defaultExternalThreadTitle("claudecode")).toBe("Claude Code session"); + expect(defaultExternalThreadTitle("dsh")).toBe( "DeepSeek Harness session", ); // Session fallback titles are localized per agent type. expect(defaultExternalThreadTitle("hermes")).toBe("Hermes session"); - expect(defaultExternalThreadTitle("gemini")).toBe("Gemini CLI session"); expect(defaultExternalThreadTitle("opencode")).toBe("OpenCode session"); - expect(defaultExternalThreadTitle("openclaw")).toBe("OpenClaw session"); }); it("defaultThreadTitle falls back per agent family", () => { - expect(defaultThreadTitle("deepseek-harness")).toBe("DeepSeek Harness session"); + expect(defaultThreadTitle("dsh")).toBe("DeepSeek Harness session"); expect(defaultThreadTitle("hermes")).toBe("Hermes session"); expect(defaultThreadTitle("codex")).toBe("Codex session"); - expect(defaultThreadTitle("claude")).toBe("Claude Code session"); - expect(defaultThreadTitle("deepseek-harness")).toBe("DeepSeek Harness session"); + expect(defaultThreadTitle("claudecode")).toBe("Claude Code session"); + expect(defaultThreadTitle("dsh")).toBe("DeepSeek Harness session"); expect(defaultThreadTitle("opencode")).toBe("OpenCode session"); expect(defaultThreadTitle("hermes")).toBe("Hermes session"); - expect(defaultThreadTitle("gemini")).toBe("Gemini CLI session"); - expect(defaultThreadTitle("openclaw")).toBe("OpenClaw session"); }); it("localizes DeepSeek Harness card and session titles", () => { languageState.language = "zh-CN"; - expect(defaultExternalThreadTitle("deepseek-harness")).toBe( + expect(defaultExternalThreadTitle("dsh")).toBe( "DeepSeek Harness 会话", ); - expect(defaultThreadTitle("deepseek-harness")).toBe( + expect(defaultThreadTitle("dsh")).toBe( "DeepSeek Harness 会话", ); expect(defaultThreadTitle("opencode")).toBe("OpenCode 会话"); expect(defaultThreadTitle("hermes")).toBe("Hermes 会话"); - expect(defaultThreadTitle("gemini")).toBe("Gemini CLI 会话"); - expect(defaultThreadTitle("openclaw")).toBe("OpenClaw 会话"); }); it("deriveThreadTitleFromPrompt strips system block, collapses whitespace, truncates", () => { @@ -154,7 +144,7 @@ describe("thread-titles helpers", () => { expect(getConversationTitleForThread(state3, "codex", "t-missing")).toBe( "Codex session", ); - expect(getConversationTitleForThread(state3, "deepseek-harness", "t-missing")).toBe( + expect(getConversationTitleForThread(state3, "dsh", "t-missing")).toBe( "DeepSeek Harness session", ); }); diff --git a/app/flowix-web/features/agent/store/thread-titles.ts b/app/flowix-web/features/agent/store/thread-titles.ts index c6ae8b41a..9b2bf2985 100644 --- a/app/flowix-web/features/agent/store/thread-titles.ts +++ b/app/flowix-web/features/agent/store/thread-titles.ts @@ -18,18 +18,16 @@ function isExternalAgentType(_type: AgentTypeKey): boolean { // title has been reviewed and localized. const AGENT_SESSION_TITLE_KEYS: Record = { codex: "agent.codexSession.title", - claude: "agent.claudeSession.title", - gemini: "agent.geminiSession.title", + claudecode: "agent.claudeSession.title", hermes: "agent.hermesSession.title", - openclaw: "agent.openclawSession.title", opencode: "agent.opencodeSession.title", pi: "agent.piSession.title", - "deepseek-harness": "agent.deepseekHarnessSession.title", + dsh: "agent.deepseekHarnessSession.title", }; const AGENT_THREAD_CARD_TITLE_KEYS: Record = { ...AGENT_SESSION_TITLE_KEYS, - "deepseek-harness": "agent.deepseekHarnessChat.title", + dsh: "agent.deepseekHarnessChat.title", }; function translateAgentTitle( diff --git a/app/flowix-web/features/agent/store/tool-chunks.test.ts b/app/flowix-web/features/agent/store/tool-chunks.test.ts index 7d0663255..0ffba2d2e 100644 --- a/app/flowix-web/features/agent/store/tool-chunks.test.ts +++ b/app/flowix-web/features/agent/store/tool-chunks.test.ts @@ -259,6 +259,9 @@ describe("tool chunk idempotency", () => { { content: "tool output" }, "codex", ); + expect(toolResult.messages[1].id).toBe( + "flowix:codex:tool-call:unbound:future-result-only", + ); const afterTool = applyTextChunk(toolResult, "after", { contentMode: "delta", }); @@ -275,6 +278,44 @@ describe("tool chunk idempotency", () => { ]); }); + it("uses stable turn and call identities for Codex custom tools without item IDs", () => { + const started = applyToolCallChunk( + emptyState(), "call-a", "future_connector", { query: "x" }, "codex", + { messageType: "codex-custom-tool", codexTurnId: "turn-tools" }, + ); + expect(started.messages[0]).toMatchObject({ + id: "flowix:codex:tool-call:turn-tools:call-a", + messageId: "flowix:codex:tool-call:turn-tools:call-a", + toolCallId: "call-a", + }); + + const orphan = applyToolResultChunk( + emptyState(), "call-b", "future_connector", { content: "done" }, "codex", + { messageType: "codex-custom-tool", codexTurnId: "turn-tools" }, + ); + expect(orphan.messages[0]).toMatchObject({ + id: "flowix:codex:tool-output:turn-tools:call-b", + messageId: "flowix:codex:tool-output:turn-tools:call-b", + toolCallId: "call-b", + content: "{\n \"content\": \"done\"\n}", + }); + + const nativeTool = applyToolCallChunk( + emptyState(), "call-c", "exec_command", { cmd: "pwd" }, "codex", + { codexTurnId: "turn-tools" }, + ); + expect(nativeTool.messages[0].id).toBe( + "flowix:codex:tool-call:turn-tools:call-c", + ); + + const unboundTool = applyToolCallChunk( + emptyState(), "call-d", "exec_command", { cmd: "pwd" }, "codex", + ); + expect(unboundTool.messages[0].id).toBe( + "flowix:codex:tool-call:unbound:call-d", + ); + }); + it("inserts a reconciled tool before a later assistant by source time", () => { const assistant = applyTextChunk(emptyState(), "final answer", { id: "assistant-item-2", @@ -314,14 +355,14 @@ describe("tool chunk idempotency", () => { { content: "修复展示", status: "pending" }, ], }, - "deepseek-harness", + "dsh", ); const completed = applyToolResultChunk( started, "plan-1", "tool", { content: "ok" }, - "deepseek-harness", + "dsh", ); expect(completed.messages[0]).toMatchObject({ diff --git a/app/flowix-web/features/agent/store/tool-chunks.ts b/app/flowix-web/features/agent/store/tool-chunks.ts index 1a5a726b8..104656834 100644 --- a/app/flowix-web/features/agent/store/tool-chunks.ts +++ b/app/flowix-web/features/agent/store/tool-chunks.ts @@ -12,6 +12,10 @@ import { } from "@features/agent/message/display-limits"; import { insertAgentMessageBySourceOrder, orderPiMessageBlocks } from "@features/agent/store/message-order"; import type { MessageChunkMetadata } from "@features/agent/store/message-chunks"; +import { + codexCustomToolOutputMessageId, + codexToolCallMessageId, +} from "@features/agent/events/codex-message-identity"; function toolMessageTimestamp(sourceTimestamp?: number): string { return Number.isFinite(sourceTimestamp) @@ -42,10 +46,15 @@ export function applyToolCallChunk( metadata: MessageChunkMetadata = {}, ): MessageProjection { const toolInput = normalizeToolInput(input); + const messageId = metadata.id ?? ( + agentType === "codex" + ? codexToolCallMessageId(metadata.codexTurnId, id) + : `tool-${id || Date.now()}` + ); const toolMessage: ChatMessage = { - id: metadata.id ?? `tool-${id || Date.now()}`, - renderKey: metadata.id ?? id, - messageId: agentType === "pi" || agentType === "codex" ? metadata.id ?? id : undefined, + id: messageId, + renderKey: messageId, + messageId: agentType === "pi" || agentType === "codex" ? messageId : undefined, role: "tool", messageType: metadata.messageType, content: "", @@ -130,6 +139,13 @@ export function applyToolResultChunk( const hasMatchingCall = st.messages.some( (message) => message.role === "tool" && toolCallIdsMatch(message.toolCallId, id), ); + const orphanMessageId = metadata.id ?? ( + agentType === "codex" + ? metadata.messageType === "codex-custom-tool" + ? codexCustomToolOutputMessageId(metadata.codexTurnId, id) + : codexToolCallMessageId(metadata.codexTurnId, id) + : `tool-${id || Date.now()}` + ); const messages = hasMatchingCall ? st.messages.map((m) => m.role === "tool" && toolCallIdsMatch(m.toolCallId, id) @@ -157,9 +173,9 @@ export function applyToolResultChunk( : m, ) : insertAgentMessageBySourceOrder(st.messages, { - id: metadata.id ?? `tool-${id || Date.now()}`, - renderKey: metadata.id ?? id, - messageId: agentType === "pi" || agentType === "codex" ? metadata.id ?? id : undefined, + id: orphanMessageId, + renderKey: orphanMessageId, + messageId: agentType === "pi" || agentType === "codex" ? orphanMessageId : undefined, role: "tool" as const, messageType: metadata.messageType, content: resultContent, diff --git a/app/flowix-web/features/agent/store/user-message.ts b/app/flowix-web/features/agent/store/user-message.ts index 72f7ea8ea..60eea4cd0 100644 --- a/app/flowix-web/features/agent/store/user-message.ts +++ b/app/flowix-web/features/agent/store/user-message.ts @@ -32,6 +32,8 @@ export interface PrepareUserMessageOptions { attachments?: AgentMessageAttachment[]; } +type AgentMessageAttachmentSource = Pick; + function attachmentMimeType(path: string): string { const extension = path.split(/[\\/]/u).pop()?.split(".").pop()?.toLowerCase(); switch (extension) { @@ -48,17 +50,20 @@ function attachmentMimeType(path: string): string { } } -/** Build display metadata from the authorized cache paths used by the IPC. */ +/** Build image display metadata from cached paths or their existing metadata. */ export function createAgentMessageAttachments( - paths: readonly string[] | undefined, + attachments: readonly (string | AgentMessageAttachmentSource)[] | undefined, ): AgentMessageAttachment[] { - return (paths ?? []).map((path, index) => { - const name = path.split(/[\\/]/u).pop() || `image-${index + 1}`; + return (attachments ?? []).map((attachment, index) => { + const path = typeof attachment === "string" ? attachment : attachment.path; + const name = typeof attachment === "string" + ? path.split(/[\\/]/u).pop() || `image-${index + 1}` + : attachment.name; return { type: "input_image", path, name, - mimeType: attachmentMimeType(path), + mimeType: typeof attachment === "string" ? attachmentMimeType(path) : attachment.mimeType, detail: "high", }; }); diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-cache.test.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-cache.test.ts index 5b7033382..3442a602d 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-cache.test.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-cache.test.ts @@ -17,7 +17,7 @@ const sessionStoreMock = vi.hoisted(() => ({ >, sessionMeta: { externalSessionResolutions: {}, - activeAgentTypeKey: "deepseek-harness", + activeAgentTypeKey: "dsh", threadTypes: {}, threadLists: {}, currentThreadTitles: {}, @@ -99,11 +99,11 @@ describe("agent thread card cache helper", () => { const result = await loadAgentThreadCardCache({ threadId: "flowix-thread", - typeKey: "deepseek-harness", + typeKey: "dsh", }); expect(sessionStoreMock.loadMessages).toHaveBeenCalledWith( - "deepseek-harness", + "dsh", "flowix-thread", ); expect(result).toEqual({ @@ -160,11 +160,11 @@ describe("agent thread card cache helper", () => { const result = await loadAgentThreadCardCache({ threadId: "claude-real-session", - typeKey: "claude", + typeKey: "claudecode", }); expect(sessionStoreMock.loadMessages).toHaveBeenCalledWith( - "claude", + "claudecode", "claude-real-session", ); expect(result.loadedThreadId).toBe("claude-real-session"); diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-dom.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-dom.ts index fe53d6fe2..28336b01e 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-dom.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-dom.ts @@ -18,7 +18,7 @@ export function focusAgentThreadCardInput( if (!(dom instanceof HTMLElement)) return; const input = dom.querySelector( - ".agent-thread-card__composer-input", + ".agent-composer__input", ); if (!input) return; @@ -89,9 +89,9 @@ export function isAgentThreadCardInteractiveTarget(target: Element): boolean { "input", "select", '[role="button"]', - ".agent-thread-card__composer", - ".agent-thread-card__access-popover", - ".agent-thread-card__composer-note-popover", + ".agent-composer", + ".agent-composer__access-popover", + ".agent-composer__note-popover", ".agent-thread-card__message-reasoning-header", ].join(","), ); diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-extension.tsx b/app/flowix-web/features/agent/thread-card/agent-thread-card-extension.tsx index b15b2cddc..94f4cb01c 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-extension.tsx +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-extension.tsx @@ -2,6 +2,7 @@ import { Node, mergeAttributes, type MarkdownToken } from "@tiptap/core"; import { Plugin, TextSelection } from "@tiptap/pm/state"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import type { AgentTypeKey } from "@/types/agent"; +import type { WorkspaceHostId } from "@features/workspace/public/host-api"; import { DEFAULT_AGENT_TYPE_KEY, getAgentType, @@ -15,8 +16,8 @@ import { parseFlowixAgentThreadCardComment, parseAgentThreadCardMarkdown, renderAgentThreadCardMarkdown, - encodeAgentThreadCardInputImages, - decodeAgentThreadCardInputImages, + encodeAgentThreadCardInputAttachments, + decodeAgentThreadCardInputAttachments, } from "@features/agent/thread-card/agent-thread-card-markdown"; import { focusAgentThreadCardInput } from "@features/agent/thread-card/agent-thread-card-dom"; import { AgentThreadCardView } from "@features/agent/thread-card/agent-thread-card-view"; @@ -25,6 +26,11 @@ import { terminateAgentThreadCardRuntime, } from "@features/agent/thread-card/agent-thread-card-cleanup"; import { getCurrentThreadCardSource } from "@features/agent/thread-card/runtime/thread-card-source"; +import { + LEGACY_THREAD_CARD_IMAGES_HTML_ATTRIBUTE, + THREAD_CARD_ATTACHMENTS_ATTRIBUTE, + THREAD_CARD_ATTACHMENTS_HTML_ATTRIBUTE, +} from "@features/agent/thread-card/composer/composer-attachment-compat"; export const SKIP_AGENT_THREAD_CARD_CLEANUP_META = "flowixSkipAgentThreadCardCleanup"; @@ -69,7 +75,11 @@ declare module "@tiptap/core" { } } -export const AgentThreadCard = Node.create({ +export const AgentThreadCard = Node.create<{ workspaceHost: WorkspaceHostId }>({ + addOptions() { + return { workspaceHost: "main-third" }; + }, + name: "agentThreadCard", group: "block", content: "", @@ -91,7 +101,7 @@ export const AgentThreadCard = Node.create({ initialPrompt: { default: null }, autoSubmit: { default: false }, inputDraft: { default: null }, - inputImages: { default: [] }, + [THREAD_CARD_ATTACHMENTS_ATTRIBUTE]: { default: [] }, }; }, @@ -111,8 +121,9 @@ export const AgentThreadCard = Node.create({ collapsed: element.getAttribute("data-collapsed") === "true", fullscreen: element.getAttribute("data-fullscreen") === "true", inputDraft: element.getAttribute("data-input-draft") || null, - inputImages: decodeAgentThreadCardInputImages( - element.getAttribute("data-input-images"), + [THREAD_CARD_ATTACHMENTS_ATTRIBUTE]: decodeAgentThreadCardInputAttachments( + element.getAttribute(THREAD_CARD_ATTACHMENTS_HTML_ATTRIBUTE) + ?? element.getAttribute(LEGACY_THREAD_CARD_IMAGES_HTML_ATTRIBUTE), ), }; }, @@ -129,7 +140,9 @@ export const AgentThreadCard = Node.create({ const collapsed = !!node.attrs.collapsed; const fullscreen = !!node.attrs.fullscreen; const inputDraft = node.attrs.inputDraft || ""; - const inputImages = encodeAgentThreadCardInputImages(node.attrs.inputImages); + const inputAttachments = encodeAgentThreadCardInputAttachments( + node.attrs[THREAD_CARD_ATTACHMENTS_ATTRIBUTE], + ); return [ "section", @@ -141,7 +154,9 @@ export const AgentThreadCard = Node.create({ "data-collapsed": collapsed ? "true" : "false", "data-fullscreen": fullscreen ? "true" : "false", "data-input-draft": inputDraft, - "data-input-images": inputImages, + [THREAD_CARD_ATTACHMENTS_HTML_ATTRIBUTE]: inputAttachments, + // Preserve copy/paste compatibility with releases that only read this key. + [LEGACY_THREAD_CARD_IMAGES_HTML_ATTRIBUTE]: inputAttachments, class: collapsed ? "agent-thread-card agent-thread-card--collapsed" : "agent-thread-card", @@ -162,11 +177,11 @@ export const AgentThreadCard = Node.create({ ], [ "div", - { class: "agent-thread-card__composer" }, + { class: "agent-composer" }, [ "div", { - class: "agent-thread-card__composer-input", + class: "agent-composer__input", contenteditable: "true", }, inputDraft, @@ -174,7 +189,7 @@ export const AgentThreadCard = Node.create({ [ "button", { - class: "agent-thread-card__send", + class: "agent-composer__send", type: "button", "aria-label": "Send", }, @@ -215,7 +230,7 @@ export const AgentThreadCard = Node.create({ initialPrompt: options?.initialPrompt ?? null, autoSubmit: !!options?.autoSubmit, inputDraft: null, - inputImages: [], + [THREAD_CARD_ATTACHMENTS_ATTRIBUTE]: [], }); const from = options?.replaceRange?.from ?? state.selection.from; const to = options?.replaceRange?.to ?? from; @@ -272,6 +287,7 @@ export const AgentThreadCard = Node.create({ props.node, props.view, typeof props.getPos === "function" ? props.getPos : undefined, + this.options.workspaceHost, ); }, diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-icons.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-icons.ts index 1ffb63d98..6bd39d35b 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-icons.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-icons.ts @@ -5,7 +5,7 @@ import { TrashSimpleIcon } from "@phosphor-icons/react"; import { getToolIconPath } from "@features/agent/message/tools"; export const ICON_STOP_PATH = - "M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z"; + "M216,68V188a28,28,0,0,1-28,28H68a28,28,0,0,1-28-28V68a28,28,0,0,1,28-28H188a28,28,0,0,1,28,28Z"; const ICON_CHEVRON_UP_PATH = "M6 15l6-6 6 6"; const ICON_CHEVRON_DOWN_PATH = "M6 9l6 6 6-6"; @@ -30,12 +30,12 @@ const ICON_ALERT_PATH = "M12 9v4M12 17h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"; const ICON_LOADER_PATH = "M21 12a9 9 0 1 1-6.219-8.56"; -function createSvg(viewBox: string, className: string): SVGSVGElement { +function createSvg(viewBox: string, ...classNames: string[]): SVGSVGElement { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", viewBox); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); - svg.classList.add(className); + svg.classList.add(...classNames); return svg; } @@ -92,7 +92,7 @@ export function createArrowBendDownRightIcon(): SVGSVGElement { export function createTrashIcon(): SVGSVGElement { return appendFillPath( - createSvg("0 0 256 256", "agent-thread-card__trash-icon"), + createSvg("0 0 256 256", "agent-icon__trash"), ICON_TRASH_PATH, ); } @@ -109,7 +109,7 @@ export function createTrashSimpleIcon(): SVGSVGElement { if (!(icon instanceof SVGSVGElement)) { throw new Error("Failed to render TrashSimpleIcon"); } - icon.classList.add("agent-thread-card__trash-icon"); + icon.classList.add("agent-icon__trash"); return icon; } @@ -132,42 +132,46 @@ export function createToolIcon( export function createFolderIcon(): SVGSVGElement { return appendFillPath( - createSvg("0 0 256 256", "agent-thread-card__access-row-icon"), + createSvg("0 0 256 256", "agent-composer__access-row-icon"), ICON_FOLDER_PATH, ); } export function createLaptopIcon(): SVGSVGElement { return appendFillPath( - createSvg("0 0 256 256", "agent-thread-card__access-workspace-icon"), + createSvg("0 0 256 256", "agent-composer__access-workspace-icon"), ICON_LAPTOP_PATH, ); } export function createPlusIcon(): SVGSVGElement { return appendFillPath( - createSvg("0 0 24 24", "agent-thread-card__access-add-icon"), + createSvg("0 0 24 24", "agent-composer__access-add-icon"), ICON_PLUS_PATH, ); } export function createComposerAddIcon(): SVGSVGElement { return appendFillPath( - createSvg("0 0 24 24", "agent-thread-card__composer-add-button-empty"), + createSvg("0 0 24 24", "agent-composer__add-button-empty"), ICON_PLUS_PATH, ); } export function createAlertIcon(): SVGSVGElement { return appendStrokePath( - createSvg("0 0 24 24", "agent-thread-card__access-alert-icon"), + createSvg("0 0 24 24", "agent-composer__access-alert-icon"), ICON_ALERT_PATH, ); } export function createToolRunningLoadingIcon(): SVGSVGElement { return appendStrokePath( - createSvg("0 0 24 24", "agent-thread-card__tool-group-running-loading-icon"), + createSvg( + "0 0 24 24", + "agent-thread-card__tool-group-running-loading-icon", + "flowix-spin", + ), ICON_LOADER_PATH, ); } diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-markdown.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-markdown.ts index 9abe00675..56d059e08 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-markdown.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-markdown.ts @@ -2,7 +2,11 @@ import { Marked } from "marked"; import { createLogger } from "@/lib/logger"; import { normalizeAgentTypeKey } from "@/lib/agent-types"; import { sanitizeLinkHref } from "@/lib/safe-link"; -import type { AgentThreadCardInputImage } from "@features/agent/thread-card/composer/composer-image-controller"; +import type { AgentThreadCardInputAttachment } from "@features/agent/thread-card/composer/composer-attachment-types"; +import { + LEGACY_THREAD_CARD_IMAGES_ATTRIBUTE, + THREAD_CARD_ATTACHMENTS_ATTRIBUTE, +} from "@features/agent/thread-card/composer/composer-attachment-compat"; import { getShiki, loadHighlighter, @@ -25,7 +29,6 @@ interface FlowixAgentThreadCardMetadata { collapsed: boolean; fullscreen: boolean; inputDraft: string; - inputImages: AgentThreadCardInputImage[]; } function isRecord(value: unknown): value is Record { @@ -252,39 +255,39 @@ export function decodeAgentThreadCardInputDraft( } } -export function encodeAgentThreadCardInputImages( - images: AgentThreadCardInputImage[] | null | undefined, +export function encodeAgentThreadCardInputAttachments( + attachments: AgentThreadCardInputAttachment[] | null | undefined, ): string { - return encodeURIComponent(JSON.stringify(images ?? [])); + return encodeURIComponent(JSON.stringify(attachments ?? [])); } -export function decodeAgentThreadCardInputImages( +export function decodeAgentThreadCardInputAttachments( value: string | null | undefined, -): AgentThreadCardInputImage[] { +): AgentThreadCardInputAttachment[] { if (!value) return []; try { const parsed = JSON.parse(decodeURIComponent(value)); if (!Array.isArray(parsed)) return []; return parsed.filter( - (image): image is AgentThreadCardInputImage => - !!image && - typeof image.path === "string" && - typeof image.mimeType === "string" && - typeof image.name === "string", + (attachment): attachment is AgentThreadCardInputAttachment => + !!attachment && + typeof attachment.path === "string" && + typeof attachment.mimeType === "string" && + typeof attachment.name === "string", ); } catch { return []; } } -function parseAgentThreadCardInputImages(value: unknown): AgentThreadCardInputImage[] { +function parseAgentThreadCardInputAttachments(value: unknown): AgentThreadCardInputAttachment[] { if (!Array.isArray(value)) return []; return value.filter( - (image): image is AgentThreadCardInputImage => - !!image && - typeof image.path === "string" && - typeof image.mimeType === "string" && - typeof image.name === "string", + (attachment): attachment is AgentThreadCardInputAttachment => + !!attachment && + typeof attachment.path === "string" && + typeof attachment.mimeType === "string" && + typeof attachment.name === "string", ); } @@ -295,6 +298,8 @@ function parseAgentThreadCardNodeAttrs( const stringValue = (value: unknown): string => typeof value === "string" ? value : ""; const inputDraftValue = stringValue(values.inputDraft); + const attachmentValue = values[THREAD_CARD_ATTACHMENTS_ATTRIBUTE] + ?? values[LEGACY_THREAD_CARD_IMAGES_ATTRIBUTE]; return { threadId: stringValue(values.threadId) || null, @@ -310,11 +315,11 @@ function parseAgentThreadCardNodeAttrs( ? decodeAgentThreadCardInputDraft(inputDraftValue) : inputDraftValue : null, - inputImages: inputDraftEncoded - ? decodeAgentThreadCardInputImages( - typeof values.inputImages === "string" ? values.inputImages : undefined, + [THREAD_CARD_ATTACHMENTS_ATTRIBUTE]: inputDraftEncoded + ? decodeAgentThreadCardInputAttachments( + typeof attachmentValue === "string" ? attachmentValue : undefined, ) - : parseAgentThreadCardInputImages(values.inputImages), + : parseAgentThreadCardInputAttachments(attachmentValue), }; } @@ -511,9 +516,124 @@ cardMarked.use({ ], }); +function splitPipeTableRow(line: string): string[] | null { + const trimmed = line.trim(); + if (!trimmed.includes("|")) return null; + + const cells: string[] = []; + let cell = ""; + let escaped = false; + for (const character of trimmed) { + if (character === "|" && !escaped) { + cells.push(cell.trim()); + cell = ""; + } else { + cell += character; + } + if (character === "\\" && !escaped) escaped = true; + else escaped = false; + } + cells.push(cell.trim()); + + if (trimmed.startsWith("|") && cells[0] === "") cells.shift(); + if (trimmed.endsWith("|") && cells[cells.length - 1] === "") cells.pop(); + return cells.length > 1 ? cells : null; +} + +function isPipeTableSeparator(line: string): boolean { + const cells = splitPipeTableRow(line); + return !!cells?.length && cells.every((cell) => /^:?-{1,}:?$/.test(cell)); +} + +/** + * Normalize Markdown for every Agent message role. Join blank-separated GFM + * table rows, and demote Setext headings so `xxx\n---` becomes text plus a + * horizontal rule. Fenced code is left untouched. + */ +function normalizeAgentThreadCardMarkdown(markdown: string): string { + const lines = markdown.split("\n"); + const output: string[] = []; + let inFence = false; + let fenceCharacter = ""; + + for (let index = 0; index < lines.length;) { + const line = lines[index]; + const fence = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (inFence) { + output.push(line); + if (fence && fence[1][0] === fenceCharacter) inFence = false; + index += 1; + continue; + } + if (fence) { + inFence = true; + fenceCharacter = fence[1][0]; + output.push(line); + index += 1; + continue; + } + + if ( + /^ {0,3}(?:-{1,}|={1,})[ \t]*$/.test(line) && + output.length > 0 && + output[output.length - 1].trim() + ) { + if (/^ {0,3}-{3,}[ \t]*$/.test(line)) { + output.push("", line); + } else { + const indent = line.match(/^ */)?.[0] ?? ""; + output.push(`${indent}\\${line.trim()}`); + } + index += 1; + continue; + } + + const headerCells = splitPipeTableRow(line); + if (headerCells) { + let separatorIndex = index + 1; + while (separatorIndex < lines.length && !lines[separatorIndex].trim()) { + separatorIndex += 1; + } + const separatorLine = lines[separatorIndex] ?? ""; + const separatorCells = splitPipeTableRow(separatorLine); + if ( + separatorCells && + isPipeTableSeparator(separatorLine) && + separatorCells.length === headerCells.length + ) { + output.push(line, separatorLine); + let rowIndex = separatorIndex + 1; + while (rowIndex < lines.length) { + let nextRowIndex = rowIndex; + while (nextRowIndex < lines.length && !lines[nextRowIndex].trim()) { + nextRowIndex += 1; + } + const rowCells = splitPipeTableRow(lines[nextRowIndex] ?? ""); + if (!rowCells) { + output.push(...lines.slice(rowIndex, nextRowIndex)); + index = nextRowIndex; + break; + } + output.push(lines[nextRowIndex]); + rowIndex = nextRowIndex + 1; + index = rowIndex; + } + if (rowIndex >= lines.length) index = rowIndex; + continue; + } + } + + output.push(line); + index += 1; + } + + return output.join("\n"); +} + export function renderAgentThreadCardMarkdownToHtml(content: string): string { if (!content || !content.trim()) return ""; - const html = cardMarked.parse(content) as string; + const normalizedContent = normalizeAgentThreadCardMarkdown(content); + const html = cardMarked.parse(normalizedContent) as string; // A Thread Card is a ProseMirror NodeView and therefore lives below the // editor's `.tiptap` element. Mark fenced blocks so editor-wide `pre` // selectors can explicitly leave agent message content alone. @@ -670,7 +790,7 @@ export function renderAgentThreadCardMarkdown(node: { }): string { const stringValue = (value: unknown): string => typeof value === "string" ? value : ""; - const metadata: FlowixAgentThreadCardMetadata = { + const metadata: FlowixAgentThreadCardMetadata & Record = { version: FLOWIX_AGENT_THREAD_CARD_METADATA_VERSION, instanceId: stringValue(node.attrs?.instanceId), threadId: stringValue(node.attrs?.threadId), @@ -680,7 +800,11 @@ export function renderAgentThreadCardMarkdown(node: { collapsed: !!node.attrs?.collapsed, fullscreen: !!node.attrs?.fullscreen, inputDraft: stringValue(node.attrs?.inputDraft), - inputImages: parseAgentThreadCardInputImages(node.attrs?.inputImages), }; + // Keep writing the established metadata key for older Flowix versions; + // parsed editor attributes and application code use `inputAttachments`. + metadata[LEGACY_THREAD_CARD_IMAGES_ATTRIBUTE] = parseAgentThreadCardInputAttachments( + node.attrs?.[THREAD_CARD_ATTACHMENTS_ATTRIBUTE], + ); return `\n`; } diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-selectors.test.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-selectors.test.ts index 648854f46..f6f7c71ca 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-selectors.test.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-selectors.test.ts @@ -199,7 +199,7 @@ describe('agent thread card selectors', () => { }), isCreating: false, isLoading: false, - typeKey: 'deepseek-harness', + typeKey: 'dsh', }); expect(runtime.status).toBe('running'); diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-submit-controller.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-submit-controller.ts index e5cbcf21a..ea564dd8c 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-submit-controller.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-submit-controller.ts @@ -1,3 +1,4 @@ +import { ensureAgentWorkspaceReady } from "@features/agent/store/agent-workspace-store"; import type { AgentTypeKey } from "@/types/agent"; import type { AgentConversationSource } from "@features/agent/store/agent-conversation-types"; import { createConversationInstanceId } from "@features/agent/store/conversation-slice"; @@ -76,6 +77,7 @@ export async function ensureAgentThreadCardConversation( runtimeConfig: buildInitialInstanceRuntimeConfig(nextTypeKey), }); nextInstanceId = conversation.instanceId; + await ensureAgentWorkspaceReady(); const runtimeConfig = ensureConversationWorkspaceSnapshot(nextInstanceId); markConversationWorkspaceStarted(nextInstanceId); input.onThreadBound({ diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.test.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.test.ts index a0e715e44..aa60432ee 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.test.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.test.ts @@ -79,7 +79,7 @@ describe('agent thread card submit helper', () => { const result = await ensureAgentThreadCardThread({ prompt: 'hello flowix', fallbackTitle: 'AI', - typeKey: 'deepseek-harness', + typeKey: 'dsh', currentThreadId: null, runtimeHandleId: 'handle-1', instanceId: 'inst-1', @@ -89,7 +89,7 @@ describe('agent thread card submit helper', () => { expect(agent.createThread).not.toHaveBeenCalled(); expect(beginExternalAgentThreadCardRun).toHaveBeenCalledWith( 'handle-1', - 'deepseek-harness', + 'dsh', null, 'inst-1', ); @@ -98,7 +98,7 @@ describe('agent thread card submit helper', () => { expect(result).toEqual({ threadId: 'codex-local-inst-1', title: 'Title: hello flowix', - typeKey: 'deepseek-harness', + typeKey: 'dsh', }); }); }); diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.ts index 67d93061c..fdb6869a6 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-submit.ts @@ -33,11 +33,11 @@ export async function ensureAgentThreadCardThread( return { threadId: beginExternalAgentThreadCardRun( input.runtimeHandleId, - type.key, + type.id, input.currentThreadId, input.instanceId, ), title: nextTitle, - typeKey: type.key, + typeKey: type.id, }; } diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card-view.tsx b/app/flowix-web/features/agent/thread-card/agent-thread-card-view.tsx index 6b08adfa9..42f46719b 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card-view.tsx +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card-view.tsx @@ -17,7 +17,7 @@ import { translate, type AppLanguage, type I18nKey, type I18nParams } from "@/li import { createLogger } from "@/lib/logger"; import { errorMessage } from "@/lib/error-message"; import type { AgentTypeKey } from "@/types/agent"; -import type { WorkspaceHostId } from "@features/workspace/store/workspace-focus-store"; +import type { WorkspaceHostId } from "@features/workspace/public/host-api"; import { deriveThreadTitleFromPrompt, defaultThreadTitle } from "@features/agent/store/thread-titles"; import { toast } from "@/lib/toast"; import { useNoteStore } from "@features/memo/store/note-store"; @@ -42,7 +42,6 @@ import { listPiSkills } from "@features/agent/services/pi-slash-command-service" import { createArrowBendDownRightIcon, createFullscreenIcon, - createTrashSimpleIcon, } from "@features/agent/thread-card/agent-thread-card-icons"; import { createAgentThreadCardDom } from "@features/agent/thread-card/view/agent-thread-card-dom-factory"; import { AgentThreadCardChromeController } from "@features/agent/thread-card/chrome"; @@ -55,10 +54,11 @@ import { ComposerSubmissionController, getComposerRuntimeState, ComposerDraftController, - ComposerImageController, + ComposerAttachmentController, + ComposerAttachmentDocument, ComposerAddMenuController, getCurrentNotebookComposerFolders, - type AgentThreadCardInputImage, + type AgentThreadCardInputAttachment, getAgentThreadCardUserHistoryMessagesFromMessages, } from "@features/agent/thread-card/composer"; @@ -68,7 +68,11 @@ import { } from "@features/agent/thread-card/messages"; import { AgentConversationSurfaceController } from "@features/agent/thread-card/surface/agent-conversation-surface-controller"; import { getAgentConversationRuntimeCwd } from "@features/agent/conversation-presentation"; -import { selectAndOpenAgentConversation } from "@features/workspace/use-cases/agent-conversation-navigation"; +import { openWorkspaceSurface } from "@features/workspace/public/open-surface-api"; +import { + getAgentMessageTypographyCssVars, + useAgentMessageFontStore, +} from "@features/agent/store/agent-message-font-store"; import { acceptComposerSubmission } from "@features/agent/services/composer-submission-service"; @@ -123,11 +127,35 @@ const AGENT_THREAD_CARD_REQUEST_FULLSCREEN_EVENT = "flowix:agent-thread-card-request-fullscreen"; const AGENT_THREAD_CARD_FULLSCREEN_RESTORE_CLASS = "agent-thread-card--restoring-fullscreen"; +const AGENT_THREAD_CARD_MESSAGE_SCROLL_ACTIVE_CLASS = + "agent-thread-card--message-scroll-active"; const AGENT_THREAD_CARD_INPUT_DRAFT_MAX_CHARS = 500; // inputDraft 落盘 debounce: typing 停 2s 后写入 ProseMirror attrs。 // submit / destroy / blur 会主动 flush, 避免卡片重挂载时用旧 attr 回填。 const AGENT_THREAD_CARD_DRAFT_PERSIST_DEBOUNCE_MS = 2000; +function wheelDeltaPixels(event: WheelEvent, scroller: HTMLElement): { + deltaX: number; + deltaY: number; +} { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + const styles = getComputedStyle(scroller); + const lineHeight = Number.parseFloat(styles.lineHeight); + const fontSize = Number.parseFloat(styles.fontSize) || 16; + const lineSize = Number.isFinite(lineHeight) ? lineHeight : fontSize * 1.5; + return { deltaX: event.deltaX * lineSize, deltaY: event.deltaY * lineSize }; + } + + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return { + deltaX: event.deltaX * scroller.clientWidth, + deltaY: event.deltaY * scroller.clientHeight, + }; + } + + return { deltaX: event.deltaX, deltaY: event.deltaY }; +} + function buildTitle(prompt: string, fallback: string = ""): string { return deriveThreadTitleFromPrompt(prompt, fallback); } @@ -163,7 +191,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { private fullscreenLayout: FullscreenLayoutController; private composerDraft: ComposerDraftController; private composerController: ComposerController; - private composerImages: ComposerImageController; + private composerAttachments: ComposerAttachmentController; private messages: AgentThreadCardMessagesController; private surface: AgentConversationSurfaceController; private runtime: AgentThreadCardRuntimeController; @@ -179,11 +207,13 @@ export class AgentThreadCardView implements ProseMirrorNodeView { private isCreating = false; private isDestroyed = false; private disposeBodyScrollbar: () => void = () => {}; + private disposeAgentMessageTypography: () => void = () => {}; private readonly submission = new ComposerSubmissionController(); private interestedThreadId: string | null = null; private hydratingInstanceId: string | null = null; private releaseThreadInterest: (() => void) | null = null; private isFullscreen = false; + private isMessageScrollActive = false; private isViewportVisible = false; private visibilityObserver: IntersectionObserver | null = null; private fullscreenRestoreGeneration = 0; @@ -261,6 +291,18 @@ export class AgentThreadCardView implements ProseMirrorNodeView { private boundHandleOutsidePointerDown = (event: PointerEvent): void => { this.blurOwnedFocusForOutsidePointer(event); }; + private boundHandleMessageScrollModePointerDown = (event: PointerEvent): void => { + if (this.isDestroyed || this.isFullscreen || event.button !== 0) return; + const target = event.target; + if (!(target instanceof Node)) return; + + const bodyFrame = this.body.parentElement; + const clickedMessageSurface = + this.body.contains(target) || + (bodyFrame?.classList.contains("agent-thread-card__body-frame") === true && + bodyFrame.contains(target)); + this.setMessageScrollActive(clickedMessageSurface); + }; /** * A fullscreen card remains a NodeView inside the outer ProseMirror DOM. * The fixed positioning prevents most pointer interaction with the note, @@ -310,6 +352,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { node: ProseMirrorNode, view: EditorView, getPos?: () => number | undefined, + private readonly hostId: WorkspaceHostId = "main-third", ) { this.node = node; this.view = view; @@ -341,7 +384,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { }, onBodyClick: (event) => this.handleBodyClick(event), onBodyScroll: this.boundHandleBodyScroll, - onBodyWheel: (event) => this.messages.handleUserScrollIntent(event.deltaY), + onBodyWheel: (event) => this.handleBodyWheel(event), // composer 空区域 → 输入框 focus 已由 createAgentComposerDom // 工厂内部挂的 pointerdown 委托统一处理 (详见 // composer-dom-factory.ts COMPOSER_FOCUS_INTERACTIVE_SELECTOR), @@ -350,6 +393,19 @@ export class AgentThreadCardView implements ProseMirrorNodeView { }); this.dom = domParts.dom; + const applyAgentMessageTypography = () => { + const typography = useAgentMessageFontStore.getState(); + const cssVars = getAgentMessageTypographyCssVars(typography.fontMode, typography.sizeMode); + this.dom.style.setProperty('--agent-message-font-family', cssVars['--agent-message-font-family']); + this.dom.style.setProperty('--agent-message-base-font-size', cssVars['--agent-message-base-font-size']); + this.dom.style.setProperty('--agent-message-size-scale', String(cssVars['--agent-message-size-scale'])); + }; + applyAgentMessageTypography(); + this.disposeAgentMessageTypography = useAgentMessageFontStore.subscribe((state, previous) => { + if (state.fontMode !== previous.fontMode || state.sizeMode !== previous.sizeMode) { + applyAgentMessageTypography(); + } + }); if (typeof IntersectionObserver !== "undefined") { this.visibilityObserver = new IntersectionObserver((entries) => { const visible = entries.some((entry) => entry.isIntersecting); @@ -360,7 +416,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { }); this.visibilityObserver.observe(this.dom); } - if (this.persistedFullscreen) { + if (this.persistedFullscreen && this.shouldRestorePersistedFullscreen()) { this.dom.classList.add(AGENT_THREAD_CARD_FULLSCREEN_RESTORE_CLASS); } this.chrome = new AgentThreadCardChromeController({ @@ -376,6 +432,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { getNodeSize: () => this.node.nodeSize, isFullscreen: () => this.isFullscreen, closeTransientUi: () => { + this.composerAddMenu?.close(); this.setCodexSettingsPopoverOpen(false); this.notePicker.setOpen(false); }, @@ -420,6 +477,8 @@ export class AgentThreadCardView implements ProseMirrorNodeView { isDestroyed: () => this.isDestroyed, isRunning: () => this.currentRuntimeView().isRunning, consumeOutsidePointer: consumeEditorPopoverDismissPointer, + onPopoverStateChange: (open, anchor) => + this.composerAddMenu?.syncSettingsSubmenuState(open, anchor), // 常用笔记卡片 → 直接注入 composer 输入框, 与文档引用走同一条路径。 onSelectFeaturedNote: (ref) => this.injectMemoReference(ref), toast: (kind, message) => toast[kind](message), @@ -465,11 +524,10 @@ export class AgentThreadCardView implements ProseMirrorNodeView { persistDelayMs: AGENT_THREAD_CARD_DRAFT_PERSIST_DEBOUNCE_MS, persist: (draft) => this.updateAttrs({ inputDraft: draft }), }); - this.composerImages = new ComposerImageController({ + this.composerAttachments = new ComposerAttachmentController({ input: this.input, - container: domParts.composerImages, - initialImages: this.inputImages, - onChange: (images) => this.updateAttrs({ inputImages: images }), + initialAttachments: this.inputAttachments, + onChange: (attachments) => this.updateAttrs({ inputAttachments: attachments }), onStateChange: () => this.composerController?.setSendButtonState(), onError: (message) => toast.error(message), onLimitExceeded: (kind) => { @@ -486,10 +544,46 @@ export class AgentThreadCardView implements ProseMirrorNodeView { trigger: this.composerRoleIcon, popover: composerAddPopover, notePopover: composerRolePopover, + settingsPopover: codexSettingsPopover, notePicker: this.notePicker, - images: this.composerImages, + attachments: this.composerAttachments, t: (key) => this.t(key), isDestroyed: () => this.isDestroyed, + settings: [ + ...(composerModelButton + ? [{ + label: "agent.model.title" as const, + popupRole: this.typeKey === "pi" ? "dialog" as const : "menu" as const, + onHover: (anchor: HTMLButtonElement) => + this.externalAgentSettings.openComposerSettingsPopover("model", anchor), + }] + : []), + ...(composerModeButton + ? [{ + label: "agent.mode.title" as const, + onHover: (anchor: HTMLButtonElement) => + this.externalAgentSettings.openComposerSettingsPopover("mode", anchor), + }] + : []), + ...(composerPermissionButton + ? [{ + label: "agent.permission.title" as const, + onHover: (anchor: HTMLButtonElement) => + this.externalAgentSettings.openComposerSettingsPopover("permission", anchor), + }] + : []), + ...(composerWorkspaceButton + ? [{ + label: "agent.workspace.access" as const, + onHover: (anchor: HTMLButtonElement) => + this.externalAgentSettings.openComposerWorkspacePopover(anchor), + }] + : []), + ], + showSettings: () => !this.isFullscreen, + closeSettingsSubmenu: (anchor) => + this.externalAgentSettings.closeComposerSettingsPopover(anchor), + consumeOutsidePointer: consumeEditorPopoverDismissPointer, openNotebookAgentSettings: () => { const notebookPath = this.notebookPath; if (notebookPath) this.notebookAgentSettingsDialog.open(notebookPath); @@ -558,11 +652,21 @@ export class AgentThreadCardView implements ProseMirrorNodeView { getUserHistoryMessages: () => this.getUserHistoryMessages(), getSendLabel: (label) => this.t(`editor.threadCard.${label}`), getRuntimeState: () => this.composerRuntimeState(), - getHasAttachments: () => this.composerImages.hasImages, - getHasPendingAttachments: () => this.composerImages.hasPending, + getHasAttachments: () => this.composerAttachments.hasAttachments, + getHasPendingAttachments: () => this.composerAttachments.hasPending, agentType: this.typeKey, + getSkillsScopeId: () => { + const instance = this.instance; + const runtime = instance?.runtimeConfig; + return runtime?.notebookId + ?? runtime?.workspaceSnapshot?.notebookId + ?? instance?.source.notebookId + ?? runtime?.workspaceSnapshot?.notebookPath + ?? runtime?.cwd + ?? undefined; + }, listDshSkills: async () => { - if (this.typeKey !== "deepseek-harness") return []; + if (this.typeKey !== "dsh") return []; const ensured = await this.ensureDshCommandConversation("/skill"); return listDshSkills({ threadId: ensured.threadId, @@ -601,6 +705,9 @@ export class AgentThreadCardView implements ProseMirrorNodeView { }); this.messages = this.surface.messages; this.composerController = this.surface.composer; + this.composerAttachments.connectDocument( + new ComposerAttachmentDocument(this.composerController.editorInstance), + ); this.composerController.setSendButtonState(""); this.refreshAttrs(); @@ -615,6 +722,11 @@ export class AgentThreadCardView implements ProseMirrorNodeView { this.boundHandleOutsidePointerDown, true, ); + document.addEventListener( + "pointerdown", + this.boundHandleMessageScrollModePointerDown, + true, + ); document.addEventListener( "pointerdown", this.boundHandleFullscreenPointerDown, @@ -642,7 +754,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { private async forkFromMessage(message: ThreadState["messages"][number]): Promise { if (!this.threadId) return; const isCodex = this.typeKey === "codex"; - const isDsh = this.typeKey === "deepseek-harness"; + const isDsh = this.typeKey === "dsh"; const isPi = this.typeKey === "pi"; if (!isCodex && !isDsh && !isPi) return; if (isCodex && !message.codexTurnId) return; @@ -669,7 +781,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { }, source: { kind: "dedicated", notebookId: source?.source.notebookId ?? null }, }); - await selectAndOpenAgentConversation(fork.instanceId); + await openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: fork.instanceId } }); } catch (error) { logger.error(`Failed to fork ${this.typeKey} conversation`, { error }); toast.error(error instanceof Error ? error.message : String(error)); @@ -830,7 +942,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { threadId: existingThreadId, source: getCurrentThreadCardSource(), // 这里只记录 notebookId;cwd / paths 在首次 send 前解析并冻结,避免 - // 此处 selectedNotebook / agent-access 尚未 hydrate 时写入空快照。 + // 此处 selectedNotebook / workspace 尚未 hydrate 时写入空快照。 runtimeConfig: buildInitialInstanceRuntimeConfig(this.typeKey), }); this.updateAttrs({ @@ -849,9 +961,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { } private get workspaceHost(): WorkspaceHostId | null { - const host = this.dom.closest("[data-workspace-host]") - ?.dataset.workspaceHost; - return host === "main-third" || host === "browser-column" ? host : null; + return this.hostId; } private get inputDraft(): string { @@ -859,8 +969,8 @@ export class AgentThreadCardView implements ProseMirrorNodeView { return typeof value === "string" ? value : ""; } - private get inputImages(): AgentThreadCardInputImage[] { - const value = this.node.attrs.inputImages; + private get inputAttachments(): AgentThreadCardInputAttachment[] { + const value = this.node.attrs.inputAttachments; return Array.isArray(value) ? value : []; } @@ -897,7 +1007,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { private createExternalAgentEmptySettings(): HTMLElement { return this.externalAgentSettings.createEmptySettings({ - showControls: !this.isFullscreen, + presentation: this.isFullscreen ? "expanded" : "inline-card", }); } @@ -1056,6 +1166,17 @@ export class AgentThreadCardView implements ProseMirrorNodeView { this.setFullscreen(!this.isFullscreen); } + /** + * Whether this surface may restore a persisted fullscreen marker. + * + * Persisted fullscreen is restored in the primary work column only. Users + * can still enter fullscreen manually in either host; a browser-column copy + * stays inline when a note opens so its surrounding text remains visible. + */ + private shouldRestorePersistedFullscreen(): boolean { + return this.workspaceHost === 'main-third'; + } + /** * Markdown may contain more than one stale fullscreen marker (for example * after a merge). Only the first marked card in document order is restored. @@ -1067,7 +1188,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { window.cancelAnimationFrame(this.fullscreenRestoreFrame); this.fullscreenRestoreFrame = null; } - if (this.persistedFullscreen) { + if (this.persistedFullscreen && this.shouldRestorePersistedFullscreen()) { this.dom.classList.add(AGENT_THREAD_CARD_FULLSCREEN_RESTORE_CLASS); } queueMicrotask(() => this.restorePersistedFullscreenIfFirst(generation)); @@ -1097,6 +1218,11 @@ export class AgentThreadCardView implements ProseMirrorNodeView { return; } + if (!this.shouldRestorePersistedFullscreen()) { + this.finishPersistedFullscreenRestore(generation); + return; + } + if (!this.isFirstPersistedFullscreenCard()) { this.finishPersistedFullscreenRestore(generation); return; @@ -1179,6 +1305,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { this.cancelPersistedFullscreenRestore(); } if (this.isFullscreen === fullscreen) return; + this.setMessageScrollActive(false); if (fullscreen) { this.captureFullscreenReturnAnchor(); @@ -1448,6 +1575,37 @@ export class AgentThreadCardView implements ProseMirrorNodeView { }); } + private setMessageScrollActive(active: boolean): void { + if (this.isMessageScrollActive === active) return; + this.isMessageScrollActive = active; + this.dom.classList.toggle( + AGENT_THREAD_CARD_MESSAGE_SCROLL_ACTIVE_CLASS, + active, + ); + } + + private handleBodyWheel(event: WheelEvent): void { + if (this.isFullscreen || this.isMessageScrollActive) { + this.messages.handleUserScrollIntent(event.deltaY); + return; + } + + const editorScroller = this.body.closest(".editor-content"); + if (!editorScroller) { + this.messages.handleUserScrollIntent(event.deltaY); + return; + } + + const { deltaX, deltaY } = wheelDeltaPixels(event, editorScroller); + if (deltaX === 0 && deltaY === 0) return; + + // Before the message area is clicked, treat it as transparent to editor + // scrolling. Clicking outside the viewport returns to this default mode. + event.preventDefault(); + editorScroller.scrollTop += deltaY; + editorScroller.scrollLeft += deltaX; + } + private renderMetaState( state: ThreadState | undefined, isLoading: boolean, @@ -1519,7 +1677,24 @@ export class AgentThreadCardView implements ProseMirrorNodeView { soon.setAttribute("role", "status"); soon.textContent = this.t("agent.queue.sendingSoon"); actions.append(soon); - } else if (message.deliveryMode === "follow_up") { + } + if (message.canDelete !== false) { + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "agent-background-terminals__queue-delete"; + remove.disabled = actionsDisabled; + remove.setAttribute("aria-label", this.t("agent.queue.deleteQueued")); + remove.title = this.t("agent.queue.deleteQueued"); + remove.textContent = this.t("agent.queue.deleteQueued"); + remove.addEventListener("click", () => { + const threadId = this.renderThreadId; + if (!threadId) return; + void useAgentSessionStore.getState().deleteQueuedFollowUp(threadId, message.id) + .catch((error) => toast.error(error instanceof Error ? error.message : String(error))); + }); + actions.append(remove); + } + if (message.deliveryStatus !== "starting" && message.deliveryMode === "follow_up") { const steer = document.createElement("button"); steer.type = "button"; steer.className = "agent-background-terminals__queue-steer"; @@ -1541,22 +1716,6 @@ export class AgentThreadCardView implements ProseMirrorNodeView { : this.t("agent.queue.sendingSoon"); actions.append(soon); } - if (message.canDelete !== false) { - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "agent-background-terminals__queue-delete"; - remove.disabled = actionsDisabled; - remove.setAttribute("aria-label", this.t("agent.queue.deleteQueued")); - remove.title = this.t("agent.queue.deleteQueued"); - remove.append(createTrashSimpleIcon()); - remove.addEventListener("click", () => { - const threadId = this.renderThreadId; - if (!threadId) return; - void useAgentSessionStore.getState().deleteQueuedFollowUp(threadId, message.id) - .catch((error) => toast.error(error instanceof Error ? error.message : String(error))); - }); - actions.append(remove); - } row.append(actions); } return row; @@ -1656,7 +1815,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { } private async ensureDshCommandConversation(command: string) { - if (this.typeKey !== "deepseek-harness") { + if (this.typeKey !== "dsh") { throw new Error("This slash command is only available for DSH"); } return ensureAgentThreadCardConversation({ @@ -1718,7 +1877,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { const isFirstMessage = this.currentMessages().length === 0; await this.submission.submit({ composer: this.composerController, - images: this.composerImages, + images: this.composerAttachments, getRuntime: () => this.composerRuntimeState(), isDisposed: () => this.isDestroyed, bind: (snapshot) => ensureAgentThreadCardConversation({ @@ -1818,7 +1977,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { const activeElement = document.activeElement; if ( activeElement instanceof HTMLElement && - activeElement.closest(".agent-thread-card__composer") + activeElement.closest(".agent-composer") ) { return; } @@ -1861,6 +2020,11 @@ export class AgentThreadCardView implements ProseMirrorNodeView { this.boundHandleOutsidePointerDown, true, ); + document.removeEventListener( + "pointerdown", + this.boundHandleMessageScrollModePointerDown, + true, + ); document.removeEventListener( "pointerdown", this.boundHandleFullscreenPointerDown, @@ -1884,6 +2048,7 @@ export class AgentThreadCardView implements ProseMirrorNodeView { this.surface.dispose(); this.body.removeEventListener("scroll", this.boundHandleBodyScroll); this.disposeBodyScrollbar(); + this.disposeAgentMessageTypography(); this.runtime.dispose(); this.externalAgentSettings.dispose(); this.notePicker.dispose(); @@ -1892,6 +2057,6 @@ export class AgentThreadCardView implements ProseMirrorNodeView { this.fullscreenLayout.dispose(); this.visibilityObserver?.disconnect(); this.visibilityObserver = null; - this.composerImages.dispose(); + this.composerAttachments.dispose(); } } diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts index 59109165a..ed6833c30 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts @@ -1,3 +1,4 @@ +import type { AgentAccessConfig } from "@/lib/types/agent-workspace.test-support"; import { Editor } from "@tiptap/core"; import { NodeSelection } from "@tiptap/pm/state"; import { closeHistory } from "@tiptap/pm/history"; @@ -24,15 +25,7 @@ const memoStateMock = vi.hoisted(() => ({ })); const agentAccessState = vi.hoisted(() => ({ - config: { entries: [] as Array> } as { - entries: Array>; - defaults?: { - files?: Record< - string, - { workspace?: string; folders: string[]; notebooks: string[] } - >; - }; - }, + config: { version: 1, entries: [] } as AgentAccessConfig, })); const toastMock = vi.hoisted(() => ({ @@ -48,6 +41,9 @@ vi.mock("@features/workspace/use-cases/browser-column-navigation", () => ({ tabId: 'file-browser:/Users/rop/Desktop/vibe/flowix-main', alreadyOpen: false, })), + openBrowserColumnNotebookNote: vi.fn(() => ({ + host: 'browser-column', tabId: 'file:external-note', alreadyOpen: false, + })), openBrowserColumnText: vi.fn(() => ({ host: 'browser-column', tabId: 'file:external-text', alreadyOpen: false, })), @@ -71,19 +67,22 @@ vi.mock("@platform/tauri/client", () => ({ agent: { ...createAgentClientMock(), listSupportedModels: vi.fn(async () => []), - cacheImage: vi.fn(async () => ({ + cacheAttachment: vi.fn(async () => ({ path: "/tmp/cached-agent-image.png", mimeType: "image/png", name: "cached-agent-image.png", })), - deleteCachedImage: vi.fn(async () => true), - readCachedImage: vi.fn(async () => null), + deleteCachedAttachment: vi.fn(async () => true), + readCachedAttachment: vi.fn(async () => null), getDeepSeekHarnessSessionId: vi.fn(async () => null), getCodexSessionId: vi.fn(async () => null), getClaudeSessionId: vi.fn(async () => null), getCodexDefaultModel: vi.fn(async () => "gpt-5.5"), }, + agentWorkspace: { getLegacyPreferences: vi.fn(async () => null) }, piModels: { + getFeatures: vi.fn(async () => ({ toolSearch: false })), + clearLegacyCodeMode: vi.fn(async () => undefined), list: vi.fn(async () => []), }, notes: { @@ -121,20 +120,32 @@ vi.mock("@platform/tauri/client", () => ({ listenToAgentStream: vi.fn(), })); -vi.mock("@features/agent/store/agent-access-store", () => ({ - useAgentAccessStore: { - getState: () => ({ - config: agentAccessState.config, - isLoading: false, - toggle: vi.fn(), - setWorkspace: vi.fn(), - addFolderFromPicker: vi.fn(async () => ({ ok: true })), - removeFolder: vi.fn(), - loadInitial: vi.fn(), - }), - subscribe: vi.fn(() => () => undefined), - }, -})); +vi.mock("@features/agent/store/agent-workspace-store", async (importOriginal) => { + const actual = await importOriginal(); + const getState = () => ({ ...actual.useAgentWorkspaceStore.getState(), + workspace: { + ...actual.useAgentWorkspaceStore.getState().workspace, + ...actual.projectAgentWorkspace({ notebooks: agentAccessState.config.entries.filter(e => e.kind === "notebook"), notebookConfigs: Object.fromEntries( + Object.entries(agentAccessState.config.defaults?.files ?? {}).map(([id, files]) => [id, { + version: 1, revision: 1, addDirs: files.folders.map((path) => ({ id: path, path, label: path, enabled: true })), + }]), + ) }), + }, + workspaceActions: { + ...actual.useAgentWorkspaceStore.getState().workspaceActions, + addFolderFromPicker: vi.fn(async () => ({ ok: true as const })), + attachFolderToNotebook: vi.fn(async () => "saved" as const), + detachFolderFromNotebook: vi.fn(async () => "saved" as const), + getNotebookFiles: (id: string | null | undefined) => id ? agentAccessState.config.defaults?.files?.[id] : undefined, + loadInitial: vi.fn(async () => true), + }, + }); + return { ...actual, ensureAgentWorkspaceReady: vi.fn(async () => {}), useAgentWorkspaceStore: Object.assign( + (selector: Parameters[0]) => selector ? selector(getState()) : getState(), + actual.useAgentWorkspaceStore, + { getState }, + ) }; +}); vi.mock("@features/agent/store/agent-runtime-store", () => ({ useAgentRuntimeStore: { @@ -148,10 +159,13 @@ vi.mock("@features/agent/store/agent-runtime-store", () => ({ })); vi.mock("@features/memo/store/note-store", () => ({ - useNoteStore: { - getState: () => memoStateMock, - subscribe: vi.fn(() => () => undefined), - }, + useNoteStore: Object.assign( + (selector: (state: typeof memoStateMock) => unknown) => selector(memoStateMock), + { + getState: () => memoStateMock, + subscribe: vi.fn(() => () => undefined), + }, + ), })); vi.mock("@features/memo/components/notebook-icon", () => ({ @@ -180,7 +194,7 @@ vi.mock("@features/document/store/document-store", () => ({ }, })); -vi.mock("@features/memo/use-cases/open-by-target", () => ({ +vi.mock("@features/memo/public/note-navigation-api", () => ({ openNoteByDeepLink: vi.fn(), })); @@ -212,7 +226,7 @@ type ComposerInput = HTMLDivElement & { editor?: Editor }; function getComposerInput(root: ParentNode): ComposerInput { const input = root.querySelector( - ".agent-thread-card__composer-input", + ".agent-composer__input", ) as ComposerInput | null; if (!input) throw new Error("Composer input not found"); return input; @@ -262,7 +276,7 @@ async function waitForEnabledSendButton( let button: HTMLButtonElement | null = null; await vi.waitFor(() => { button = root.querySelector( - "button.agent-thread-card__send", + "button.agent-composer__send", ); expect(button).not.toBeNull(); expect(button!.disabled).toBe(false); @@ -271,7 +285,7 @@ async function waitForEnabledSendButton( } async function seedRenderableMessages( - _typeKey: "deepseek-harness" | "codex" | "claude" | "gemini" | "hermes" | "openclaw", + _typeKey: "dsh" | "codex" | "claudecode" | "hermes" | "opencode", threadId: string, messages: ChatMessage[], ): Promise { @@ -387,7 +401,7 @@ describe("AgentThreadCard NodeView streaming", () => { const parsed = parseAgentThreadCardMarkdown({ attrs: markdown }).attrs; expect(parsed.title).toBe(""); expect(parsed.fullscreen).toBe(true); - expect(parsed.inputImages).toEqual([{ + expect(parsed.inputAttachments).toEqual([{ path: "/tmp/pasted.png", mimeType: "image/png", name: "pasted.png", @@ -524,7 +538,7 @@ describe("AgentThreadCard NodeView streaming", () => { true, ); useAgentSessionStore.setState(useAgentSessionStore.getInitialState(), true); - agentAccessState.config = { entries: [] }; + agentAccessState.config = { version: 1, entries: [] }; }); afterEach(() => { @@ -555,7 +569,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "DOM Flow", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -567,7 +581,7 @@ describe("AgentThreadCard NodeView streaming", () => { expect(card).not.toBeNull(); const store = useChatStore.getState(); - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); dispatchChunk(store, { kind: "stream_start", thread_id: threadId }); dispatchChunk(store, { kind: "text", @@ -922,7 +936,7 @@ describe("AgentThreadCard NodeView streaming", () => { const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); const threadId = "thread-card-conversation-run-source"; const instance = useAgentConversationStore.getState().createInstance({ - agentType: "deepseek-harness", + agentType: "dsh", title: "Conversation Run Source", threadId, source: { kind: "thread-card" }, @@ -931,7 +945,7 @@ describe("AgentThreadCard NodeView streaming", () => { kind: "stream_start", thread_id: threadId, run_id: "run-conversation-source", - agent_type: "deepseek-harness", + agent_type: "dsh", }); const host = document.createElement("div"); @@ -949,7 +963,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: instance.instanceId, threadId, title: "Conversation Run Source", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -986,7 +1000,7 @@ describe("AgentThreadCard NodeView streaming", () => { }; useChatStore.setState((state) => ({ - threadTypes: { ...state.threadTypes, [threadId]: "deepseek-harness" }, + threadTypes: { ...state.threadTypes, [threadId]: "dsh" }, threadStates: { ...state.threadStates, [threadId]: { @@ -1002,7 +1016,7 @@ describe("AgentThreadCard NodeView streaming", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, [ + await seedRenderableMessages("dsh", threadId, [ firstMessage, streamingMessage, ]); @@ -1018,7 +1032,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Incremental", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -1048,7 +1062,7 @@ describe("AgentThreadCard NodeView streaming", () => { }, }; }); - await seedRenderableMessages("deepseek-harness", threadId, patchedMessages); + await seedRenderableMessages("dsh", threadId, patchedMessages); await flushAnimationFrame(); @@ -1071,7 +1085,7 @@ describe("AgentThreadCard NodeView streaming", () => { const content = `${"A".repeat(ASSISTANT_MESSAGE_DISPLAY_MAX_CHARS + 20)}${tail}`; useChatStore.setState((state) => ({ - threadTypes: { ...state.threadTypes, [threadId]: "deepseek-harness" }, + threadTypes: { ...state.threadTypes, [threadId]: "dsh" }, threadStates: { ...state.threadStates, [threadId]: { @@ -1087,7 +1101,7 @@ describe("AgentThreadCard NodeView streaming", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, [ + await seedRenderableMessages("dsh", threadId, [ { id: "assistant-large", role: "assistant", @@ -1107,7 +1121,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Display Budget", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -1145,7 +1159,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Editable title", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -1159,7 +1173,7 @@ describe("AgentThreadCard NodeView streaming", () => { expect(title).not.toBeNull(); const store = useChatStore.getState(); - store.bindThreadType(threadId, "deepseek-harness"); + store.bindThreadType(threadId, "dsh"); dispatchChunk(store, { kind: "stream_start", thread_id: threadId }); dispatchChunk(store, { kind: "text", @@ -1210,7 +1224,7 @@ describe("AgentThreadCard NodeView streaming", () => { "@features/agent/thread-card/runtime/thread-card-conversation" ); const instance = useAgentConversationStore.getState().createInstance({ - agentType: "deepseek-harness", + agentType: "dsh", title: "User renamed title", threadId: "thread-existing-title", source: { kind: "thread-card" }, @@ -1218,7 +1232,7 @@ describe("AgentThreadCard NodeView streaming", () => { const result = await upsertAgentThreadCardConversationInstance({ instanceId: instance.instanceId, - agentType: "deepseek-harness", + agentType: "dsh", title: "Prompt generated title", threadId: "thread-existing-title", source: { kind: "thread-card" }, @@ -1305,7 +1319,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Submit Flow", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -1327,7 +1341,7 @@ describe("AgentThreadCard NodeView streaming", () => { threadId, expect.objectContaining({ content: "write a short answer", - agentType: "deepseek-harness", + agentType: "dsh", runtimeConfig: expect.objectContaining({ deepseekHarness: expect.any(Object), }), @@ -1378,7 +1392,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-awaited-initialization", title: "Awaited initialization", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }], @@ -1538,7 +1552,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: null, title: "Failed initialization", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }], @@ -1567,20 +1581,21 @@ describe("AgentThreadCard NodeView streaming", () => { editor = new Editor({ element: host, extensions: [StarterKit, AgentThreadCard], content: { type: "doc", content: [{ type: "agentThreadCard", attrs: { - threadId: "thread-rejected-send", typeKey: "deepseek-harness", collapsed: false, - inputDraft: "please review", inputImages: images, + threadId: "thread-rejected-send", typeKey: "dsh", collapsed: false, + inputDraft: "please review", inputAttachments: images, } }] }, }); const input = getComposerInput(host); + const draftBeforeSend = getComposerValue(input); (await waitForEnabledSendButton(host)).click(); await vi.waitFor(() => expect(toastMock.error).toHaveBeenCalledWith("backend rejected request")); - expect(getComposerValue(input)).toBe("please review"); - expect(editor.getJSON().content?.[0]?.attrs?.inputImages).toEqual(images); - expect(host.querySelector(".agent-thread-card__composer-image-remove")?.disabled).toBe(false); - expect(agent.deleteCachedImage).not.toHaveBeenCalled(); + expect(getComposerValue(input)).toBe(draftBeforeSend); + expect(editor.getJSON().content?.[0]?.attrs?.inputAttachments).toEqual(images); + expect(host.querySelector(".agent-composer__attachment-token-label")?.textContent).toBe("retry.pdf"); + expect(agent.deleteCachedAttachment).not.toHaveBeenCalled(); (await waitForEnabledSendButton(host)).click(); await vi.waitFor(() => expect(getComposerValue(input)).toBe("")); - expect(editor.getJSON().content?.[0]?.attrs?.inputImages).toEqual([]); + expect(editor.getJSON().content?.[0]?.attrs?.inputAttachments).toEqual([]); }); it("persists short Thread Card composer drafts in node attrs", async () => { @@ -1603,7 +1618,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-draft", title: "Draft", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -1647,7 +1662,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-long-draft", title: "Long Draft", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, inputDraft: "short", }, @@ -1690,7 +1705,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Clear Draft", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, inputDraft: "send me", }, @@ -1724,12 +1739,13 @@ describe("AgentThreadCard NodeView streaming", () => { path: "D:\\workspace\\main", }; agentAccessState.config = { + version: 1, // defaults.folders 里的每个 folder 必须在 entries 里有对应的 // enabled && !missing 授权条目, 否则 resolveAuthorizedDefaultFiles // 会把它收窄掉 (防越权)。 entries: [ - { id: "e-main", kind: "folder", path: "D:\\workspace\\main", name: "main", enabled: true, missing: false }, - { id: "e-extra", kind: "folder", path: "D:\\workspace\\extra", name: "extra", enabled: true, missing: false }, + { id: "e-main", kind: "folder", path: "D:\\workspace\\main", name: "main", enabled: true, missing: false, addedAt: 0, updatedAt: 0 }, + { id: "e-extra", kind: "folder", path: "D:\\workspace\\extra", name: "extra", enabled: true, missing: false, addedAt: 0, updatedAt: 0 }, ], defaults: { files: { @@ -1779,6 +1795,7 @@ describe("AgentThreadCard NodeView streaming", () => { content: "check workspace", agentType: "codex", runtimeConfig: { + notebookId: "nb-current", codex: expect.objectContaining({ cwd: "D:\\workspace\\main", workspacePaths: ["D:\\workspace\\extra"], @@ -2019,7 +2036,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Collapsed History", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: true, }, }, @@ -2072,7 +2089,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Cached", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -2083,7 +2100,7 @@ describe("AgentThreadCard NodeView streaming", () => { await flushPromises(); await flushAnimationFrame(); - await seedRenderableMessages("deepseek-harness", threadId, [ + await seedRenderableMessages("dsh", threadId, [ { id: "assistant-cached", role: "assistant", @@ -2166,7 +2183,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Skeleton History", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -2238,7 +2255,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Fullscreen Skeleton", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: true, }, }, @@ -2330,7 +2347,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Viewport History", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -2384,7 +2401,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Fullscreen History", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: true, }, }, @@ -2425,7 +2442,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-fullscreen-persist", threadId: "thread-fullscreen-persist", title: "Fullscreen Persist", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: false, }, @@ -2457,7 +2474,7 @@ describe("AgentThreadCard NodeView streaming", () => { editor = new Editor({ element: host, - extensions: [StarterKit, AgentThreadCard], + extensions: [StarterKit, AgentThreadCard.configure({ workspaceHost: "browser-column" })], content: { type: "doc", content: [ @@ -2467,7 +2484,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-browser-column-fullscreen", threadId: "thread-browser-column-fullscreen", title: "Browser Column conversation", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: false, }, @@ -2515,7 +2532,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-work-column-fullscreen", threadId: "thread-work-column-fullscreen", title: "Work Column conversation", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: false, }, @@ -2572,7 +2589,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-fullscreen-first", threadId: "thread-fullscreen-first", title: "First Fullscreen", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: true, }, @@ -2583,7 +2600,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-fullscreen-second", threadId: "thread-fullscreen-second", title: "Second Fullscreen", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: true, }, @@ -2627,11 +2644,53 @@ describe("AgentThreadCard NodeView streaming", () => { expect(editor.state.doc.child(1).attrs.fullscreen).toBe(true); }); + it("keeps a persisted fullscreen card inline in the browser column", async () => { + const { AgentThreadCard } = + await import("@features/agent/thread-card"); + const workspaceHost = document.createElement("section"); + workspaceHost.dataset.workspaceHost = "browser-column"; + const host = document.createElement("div"); + workspaceHost.append(host); + document.body.append(workspaceHost); + + editor = new Editor({ + element: host, + extensions: [StarterKit, AgentThreadCard.configure({ workspaceHost: "browser-column" })], + content: { + type: "doc", + content: [ + { + type: "agentThreadCard", + attrs: { + instanceId: "instance-browser-column-restore", + threadId: "thread-browser-column-restore", + title: "Note conversation", + typeKey: "dsh", + collapsed: false, + fullscreen: true, + }, + }, + ], + }, + }); + + await flushPromises(); + await flushAnimationFrame(); + + const card = host.querySelector(".agent-thread-card"); + // A secondary browser-column copy must not duplicate the conversation in + // fullscreen and hide the surrounding note content. + expect(card?.classList.contains("agent-thread-card--fullscreen")).toBe(false); + expect(host.querySelector(".agent-thread-card--restoring-fullscreen")).toBeNull(); + // The persisted marker stays untouched so the primary surface can restore it. + expect(editor.state.doc.firstChild?.attrs.fullscreen).toBe(true); + }); + it("defaults cached messages to the bottom after restoring fullscreen", async () => { const { AgentThreadCard } = await import("@features/agent/thread-card"); const threadId = "thread-restored-fullscreen-bottom"; - await seedRenderableMessages("deepseek-harness", threadId, [ + await seedRenderableMessages("dsh", threadId, [ { id: "cached-assistant-message", role: "assistant", @@ -2654,7 +2713,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-restored-fullscreen-bottom", threadId, title: "Restored Fullscreen Bottom", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: true, }, @@ -2693,7 +2752,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-legacy-fullscreen-restore", title: "Legacy Fullscreen Restore", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: true, }, @@ -2734,7 +2793,7 @@ describe("AgentThreadCard NodeView streaming", () => { }); const threadId = "thread-stale-restore-frame"; - await seedRenderableMessages("deepseek-harness", threadId, [ + await seedRenderableMessages("dsh", threadId, [ { id: "stale-frame-message", role: "assistant", @@ -2756,7 +2815,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-stale-restore-frame", threadId, title: "Stale Restore Frame", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: true, }, @@ -2807,7 +2866,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-before-document-switch", threadId: "thread-before-document-switch", title: "Before Document Switch", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: false, }, @@ -2826,7 +2885,7 @@ describe("AgentThreadCard NodeView streaming", () => { instanceId: "instance-after-document-switch", threadId: "thread-after-document-switch", title: "After Document Switch", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, fullscreen: true, }, @@ -2871,7 +2930,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId, title: "Fullscreen Selection", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -2918,7 +2977,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-fullscreen-input-focus", title: "Fullscreen Input Focus", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -2933,8 +2992,8 @@ describe("AgentThreadCard NodeView streaming", () => { const input = getComposerInput(host); const row = input.parentElement; - expect(row?.classList.contains("agent-thread-card__composer-input-row")).toBe(true); - expect(input.closest(".agent-thread-card__composer")?.classList.contains("agent-composer--expanded")).toBe(true); + expect(row?.classList.contains("agent-composer__input-row")).toBe(true); + expect(input.closest(".agent-composer")?.classList.contains("agent-composer--expanded")).toBe(true); input.blur(); row?.dispatchEvent( @@ -2952,7 +3011,7 @@ describe("AgentThreadCard NodeView streaming", () => { .querySelector(".agent-thread-card__fullscreen") ?.click(); await flushAnimationFrame(); - expect(input.closest(".agent-thread-card__composer")?.classList.contains("agent-composer--expanded")).toBe(false); + expect(input.closest(".agent-composer")?.classList.contains("agent-composer--expanded")).toBe(false); }); it("does not refocus the editor when clicking non-interactive card content", async () => { @@ -2976,7 +3035,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-click-focus", title: "Click Focus", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3018,7 +3077,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-event-boundary", title: "Event Boundary", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3061,7 +3120,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-focus-deselect", title: "Focus Deselect", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3117,7 +3176,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-focus-first", title: "First", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3130,7 +3189,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-focus-second", title: "Second", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3180,7 +3239,7 @@ describe("AgentThreadCard NodeView streaming", () => { attrs: { threadId: "thread-card-outside-blur", title: "Outside Blur", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3416,7 +3475,7 @@ describe("AgentThreadCard input history navigation", () => { const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); useChatStore.setState(useChatStore.getInitialState(), true); - agentAccessState.config = { entries: [] }; + agentAccessState.config = { version: 1, entries: [] }; }); afterEach(() => { @@ -3460,7 +3519,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Empty", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3495,7 +3554,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "History", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3537,7 +3596,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -3573,7 +3632,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Draft", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3603,7 +3662,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -3641,7 +3700,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Draft not overwritten", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3671,7 +3730,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -3726,7 +3785,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Down idle", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3755,7 +3814,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -3784,7 +3843,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Typing", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3814,7 +3873,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -3850,7 +3909,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Caret boundaries", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3880,7 +3939,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -3921,7 +3980,7 @@ describe("AgentThreadCard input history navigation", () => { attrs: { threadId, title: "Unmodified", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -3951,7 +4010,7 @@ describe("AgentThreadCard input history navigation", () => { }, }, })); - await seedRenderableMessages("deepseek-harness", threadId, messages); + await seedRenderableMessages("dsh", threadId, messages); await flushAnimationFrame(); const input = getComposerInput(host); @@ -4005,7 +4064,7 @@ describe("AgentThreadCard input latency optimizations", () => { const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); useChatStore.setState(useChatStore.getInitialState(), true); - agentAccessState.config = { entries: [] }; + agentAccessState.config = { version: 1, entries: [] }; }); afterEach(() => { @@ -4038,7 +4097,7 @@ describe("AgentThreadCard input latency optimizations", () => { attrs: { threadId, title: "Lite", - typeKey: "deepseek-harness", + typeKey: "dsh", collapsed: false, }, }, @@ -4155,7 +4214,7 @@ describe("AgentThreadCard composer during agent run", () => { const { useChatStore } = await import("@features/agent/store/agent-session-test-facade"); useChatStore.setState(useChatStore.getInitialState(), true); - agentAccessState.config = { entries: [] }; + agentAccessState.config = { version: 1, entries: [] }; }); afterEach(() => { @@ -4165,7 +4224,7 @@ describe("AgentThreadCard composer during agent run", () => { vi.useRealTimers(); }); - it.each(["deepseek-harness", "pi"] as const)("queues a message while %s is running", async (agentType) => { + it.each(["dsh", "pi"] as const)("queues a message while %s is running", async (agentType) => { const { AgentThreadCard } = await import("@features/agent/thread-card"); const { useAgentSessionStore } = await import( diff --git a/app/flowix-web/features/agent/thread-card/badge-hover-card.test.ts b/app/flowix-web/features/agent/thread-card/badge-hover-card.test.ts index 3ec2bd05f..93938a79d 100644 --- a/app/flowix-web/features/agent/thread-card/badge-hover-card.test.ts +++ b/app/flowix-web/features/agent/thread-card/badge-hover-card.test.ts @@ -58,7 +58,7 @@ describe("BadgeHoverCard", () => { await act(async () => { root.render( createElement(BadgeHoverCard, { - agentType: "deepseek-harness", + agentType: "dsh", sessionId: "provider-session-1", model: "deepseek-chat", usage: { @@ -95,7 +95,7 @@ describe("BadgeHoverCard", () => { await act(async () => { root.render( createElement(BadgeHoverCard, { - agentType: "claude", + agentType: "claudecode", model: "gpt-5", usage: { input_tokens: 10, output_tokens: 2 }, }), diff --git a/app/flowix-web/features/agent/thread-card/badge-hover-card.tsx b/app/flowix-web/features/agent/thread-card/badge-hover-card.tsx index 96ea36092..07de254d8 100644 --- a/app/flowix-web/features/agent/thread-card/badge-hover-card.tsx +++ b/app/flowix-web/features/agent/thread-card/badge-hover-card.tsx @@ -97,7 +97,7 @@ function percentageFromRate(rate: number | undefined): string { } /** - * Agent Thread Card hover Agent 类型徽章弹出的卡片。 + * Agent conversation identity badge 的运行信息卡片。 * * 通用 metadata 协议字段均通过 props 传入,组件本身不读取 store。 * @@ -196,7 +196,7 @@ export function BadgeHoverCard({ diff --git a/app/flowix-web/features/agent/thread-card/chrome/badge-chrome-controller.tsx b/app/flowix-web/features/agent/thread-card/chrome/badge-chrome-controller.tsx index d38a713f6..a52c6f206 100644 --- a/app/flowix-web/features/agent/thread-card/chrome/badge-chrome-controller.tsx +++ b/app/flowix-web/features/agent/thread-card/chrome/badge-chrome-controller.tsx @@ -5,8 +5,7 @@ import { getAgentType } from "@/lib/agent-types"; import { type ThreadState } from "@features/agent/store/thread-runtime-state"; import { useAgentRuntimeStore } from "@features/agent/store/agent-runtime-store"; import { normalizeAgentRuntimeStatus } from "@features/agent/runtime/agent-runtime-status"; -import { useAgentSessionStore } from "@features/agent/store/agent-session-store"; -import { useAgentAccessStore } from "@features/agent/store/agent-access-store"; +import { useAgentDataStore } from "@features/agent/store/agent-data-store"; import { BadgeHoverCard } from "@features/agent/thread-card/badge-hover-card"; import { computeAgentThreadCardBadgeData } from "@features/agent/thread-card/runtime/run-status-presenter"; import { createRuntimeInfoRequester } from "@features/agent/thread-card/runtime/runtime-info-requester"; @@ -66,13 +65,13 @@ export class AgentThreadCardBadgeChromeController { this.badgeIcon.style.setProperty("--agent-icon-src", `url("${type.icon}")`); this.badgeIcon.classList.toggle( "agent-icon--masked", - isThemeAdaptiveAgentIcon(type.key), + isThemeAdaptiveAgentIcon(type.id), ); this.badgeIcon.classList.toggle( "agent-type-badge__icon--image", - !isThemeAdaptiveAgentIcon(type.key), + !isThemeAdaptiveAgentIcon(type.id), ); - this.badgeIcon.classList.toggle("agent-icon--pi", type.key === "pi"); + this.badgeIcon.classList.toggle("agent-icon--pi", type.id === "pi"); this.badgeName.textContent = type.name; this.syncRuntimeState(); // Conversation instances may hydrate after the NodeView is mounted. Repaint @@ -85,7 +84,7 @@ export class AgentThreadCardBadgeChromeController { const type = getAgentType(this.getTypeKey()); const runtime = useAgentRuntimeStore.getState(); const status = normalizeAgentRuntimeStatus( - runtime.statusByType[type.key], + runtime.statusByType[type.id], runtime.isChecking, ); const unavailable = status.state === "not-installed" || status.state === "not-ready"; @@ -170,14 +169,14 @@ export class AgentThreadCardBadgeChromeController { this.getSessionId() ?? (threadId ? getResolvedExternalSessionId(threadId) : null); const typeKey = this.getTypeKey(); - const piCodeMode = useAgentAccessStore.getState().config.defaults - ?.runtime?.pi?.piCodeMode ?? false; + const preferredPiMode = useAgentDataStore.getState().agents.pi.user.mode; + const piCodeMode = preferredPiMode === "code"; const { runId, model, usage } = computeAgentThreadCardBadgeData({ threadState: this.getThreadState(), // Phase 4 (2026-08-02): 真源是 session-store.sessionMeta.settings. codexModel: - useAgentSessionStore.getState().sessionMeta.settings.agentCodexModel, + useAgentDataStore.getState().agents.codex.user.model?.id ?? "inherit", typeKey: this.getTypeKey(), }); this.hoverCardRoot.render( diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-add-menu-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-add-menu-controller.ts index 550b4bff1..e762706f2 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-add-menu-controller.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-add-menu-controller.ts @@ -1,29 +1,46 @@ import type { I18nKey } from "@/lib/i18n"; import type { NotePickerController } from "@features/agent/thread-card/note/note-picker-controller"; -import type { ComposerImageController } from "./composer-image-controller"; -import { COMPOSER_ATTACHMENT_ACCEPT } from "./composer-image-controller"; +import type { ComposerAttachmentController } from "./composer-attachment-controller"; +import { COMPOSER_ATTACHMENT_ACCEPT } from "./composer-attachment-controller"; export interface ComposerAddMenuControllerOptions { trigger: HTMLButtonElement; popover: HTMLDivElement; notePopover: HTMLDivElement; + settingsPopover?: HTMLDivElement; notePicker: NotePickerController; - images: ComposerImageController; + attachments: ComposerAttachmentController; t: (key: I18nKey) => string; isDestroyed: () => boolean; openNotebookAgentSettings: () => void; + settings?: readonly { + label: I18nKey; + popupRole?: "menu" | "dialog"; + onHover: (anchor: HTMLButtonElement) => void; + }[]; + showSettings?: () => boolean; + closeSettingsSubmenu?: (anchor?: HTMLButtonElement) => void; + consumeOutsidePointer?: (event: PointerEvent) => void; } export class ComposerAddMenuController { private open = false; private disposed = false; private noteItem: HTMLButtonElement | null = null; + private activeSettingsItem: HTMLButtonElement | null = null; + private suppressSettingsFocusOpen = false; private submenuCloseTimer: ReturnType | null = null; + private settingsCloseTimer: ReturnType | null = null; constructor(private readonly options: ComposerAddMenuControllerOptions) { options.trigger.addEventListener("click", this.handleTriggerClick); options.notePopover.addEventListener("pointerenter", this.cancelSubmenuClose); options.notePopover.addEventListener("pointerleave", this.handleSubmenuBoundaryLeave); + options.popover.addEventListener("pointerleave", this.handleSettingsBoundaryLeave); + options.popover.addEventListener("keydown", this.handleMenuKeydown); + options.settingsPopover?.addEventListener("pointerenter", this.cancelSettingsClose); + options.settingsPopover?.addEventListener("pointerleave", this.handleSettingsBoundaryLeave); + options.settingsPopover?.addEventListener("keydown", this.handleSettingsKeydown); } close(): void { this.setOpen(false); } @@ -32,38 +49,68 @@ export class ComposerAddMenuController { if (this.disposed) return; this.disposed = true; this.cancelSubmenuClose(); + this.cancelSettingsClose(); this.setOpen(false); this.options.trigger.removeEventListener("click", this.handleTriggerClick); this.options.notePopover.removeEventListener("pointerenter", this.cancelSubmenuClose); this.options.notePopover.removeEventListener("pointerleave", this.handleSubmenuBoundaryLeave); + this.options.popover.removeEventListener("pointerleave", this.handleSettingsBoundaryLeave); + this.options.popover.removeEventListener("keydown", this.handleMenuKeydown); + this.options.settingsPopover?.removeEventListener("pointerenter", this.cancelSettingsClose); + this.options.settingsPopover?.removeEventListener("pointerleave", this.handleSettingsBoundaryLeave); + this.options.settingsPopover?.removeEventListener("keydown", this.handleSettingsKeydown); this.options.popover.remove(); } private readonly handleTriggerClick = (event: MouseEvent): void => { event.preventDefault(); event.stopPropagation(); - this.setOpen(!this.open); + const shouldOpen = !this.open; + this.setOpen(shouldOpen); + if (shouldOpen && event.detail === 0) { + this.options.popover.querySelector("[role='menuitem']")?.focus(); + } }; private setOpen(open: boolean): void { this.open = open; this.options.popover.hidden = !open; this.options.trigger.setAttribute("aria-expanded", String(open)); - this.options.trigger.classList.toggle("agent-thread-card__composer-add-button--open", open); + this.options.trigger.classList.toggle("agent-composer__add-button--open", open); if (open) { this.render(); this.position(); document.addEventListener("pointerdown", this.handleOutsidePointer, true); } else { + this.cancelSettingsClose(); this.noteItem = null; this.options.notePicker.setOpen(false); + this.options.closeSettingsSubmenu?.(this.activeSettingsItem ?? undefined); + this.activeSettingsItem = null; document.removeEventListener("pointerdown", this.handleOutsidePointer, true); } } private readonly handleOutsidePointer = (event: PointerEvent): void => { const target = event.target as Node | null; - if (target && (this.options.popover.contains(target) || this.options.notePopover.contains(target) || this.options.trigger.contains(target))) return; + if ( + target + && ( + this.options.popover.contains(target) + || this.options.notePopover.contains(target) + || this.options.settingsPopover?.contains(target) + || this.options.trigger.contains(target) + ) + ) return; + if ( + this.activeSettingsItem + && this.options.settingsPopover + && !this.options.settingsPopover.hidden + ) { + // The settings controller also listens for outside pointers. Closing the + // parent removes that listener, so consume editor clicks here first. + this.options.consumeOutsidePointer?.(event); + } this.setOpen(false); }; @@ -71,11 +118,21 @@ export class ComposerAddMenuController { this.noteItem?.removeEventListener("pointerleave", this.handleSubmenuBoundaryLeave); const note = this.createItem(this.options.t("editor.threadCard.addNote"), true); const attachment = this.createItem(this.options.t("editor.threadCard.addAttachment"), false); - const items = [note, attachment]; + const items: HTMLElement[] = [note, attachment]; + const settings = this.options.showSettings?.() === false + ? [] + : (this.options.settings ?? []); + if (settings.length > 0) { + items.push(this.createSeparator()); + settings.forEach(({ label, onHover, popupRole }) => { + items.push(this.createSettingsItem(this.options.t(label), onHover, popupRole)); + }); + } const projectAiSettings = this.createItem(this.options.t("editor.threadCard.projectAiSettings"), false, () => { this.setOpen(false); this.options.openNotebookAgentSettings(); }); + if (settings.length > 0) items.push(this.createSeparator()); items.push(projectAiSettings); this.options.popover.replaceChildren(...items); this.noteItem = note; @@ -88,7 +145,7 @@ export class ComposerAddMenuController { item.type = "button"; // Reuse the permission/model dropdown item contract so copy and controls // keep exactly the same typography, spacing and hover treatment. - item.className = "agent-thread-card__codex-settings-item agent-thread-card__composer-add-item"; + item.className = "agent-thread-card__codex-settings-item agent-composer__add-item"; item.setAttribute("role", "menuitem"); item.textContent = label; if (isNote) { @@ -116,6 +173,117 @@ export class ComposerAddMenuController { return item; } + private createSettingsItem( + label: string, + onHover: (anchor: HTMLButtonElement) => void, + popupRole: "menu" | "dialog" = "menu", + ): HTMLButtonElement { + const item = document.createElement("button"); + item.type = "button"; + item.className = "agent-thread-card__codex-settings-item agent-composer__add-item"; + item.classList.add("agent-composer__add-setting-item"); + item.setAttribute("role", "menuitem"); + item.setAttribute("aria-haspopup", popupRole); + item.setAttribute("aria-expanded", "false"); + item.textContent = label; + item.append(this.chevron()); + item.addEventListener("pointerenter", () => { + this.cancelSettingsClose(); + this.activateSettingsItem(item); + onHover(item); + }); + item.addEventListener("focus", () => { + if (this.suppressSettingsFocusOpen) return; + this.cancelSettingsClose(); + this.activateSettingsItem(item); + onHover(item); + }); + item.addEventListener("keydown", (event) => { + if (event.key !== "ArrowRight") return; + if (!this.options.settingsPopover || this.options.settingsPopover.hidden) return; + const firstSubmenuItem = this.options.settingsPopover?.querySelector( + "button:not([disabled])", + ); + if (!firstSubmenuItem) return; + event.preventDefault(); + firstSubmenuItem.focus(); + }); + item.addEventListener("pointerleave", this.handleSettingsBoundaryLeave); + return item; + } + + syncSettingsSubmenuState(open: boolean, anchor: HTMLButtonElement | null): void { + const activeItem = this.activeSettingsItem; + if (!activeItem) return; + if (open && anchor !== activeItem) { + activeItem.setAttribute("aria-expanded", "false"); + this.activeSettingsItem = null; + return; + } + if (!open && anchor && anchor !== activeItem) return; + const focusWasInSubmenu = + !open && Boolean(this.options.settingsPopover?.contains(document.activeElement)); + activeItem.setAttribute("aria-expanded", String(open)); + if (!open) { + this.activeSettingsItem = null; + if (focusWasInSubmenu) this.focusSettingsItemWithoutOpening(activeItem); + } + } + + private activateSettingsItem(item: HTMLButtonElement): void { + if (this.activeSettingsItem === item) return; + this.activeSettingsItem?.setAttribute("aria-expanded", "false"); + this.activeSettingsItem = item; + item.setAttribute("aria-expanded", "false"); + } + + private focusSettingsItemWithoutOpening(item: HTMLButtonElement | null): void { + if (!item || !item.isConnected || document.activeElement === item) return; + this.suppressSettingsFocusOpen = true; + item.focus(); + this.suppressSettingsFocusOpen = false; + } + + private readonly handleMenuKeydown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + if ( + this.activeSettingsItem + && this.options.settingsPopover + && !this.options.settingsPopover.hidden + ) { + const activeItem = this.activeSettingsItem; + this.options.closeSettingsSubmenu?.(activeItem ?? undefined); + this.focusSettingsItemWithoutOpening(activeItem); + return; + } + this.setOpen(false); + this.options.trigger.focus(); + }; + + private readonly handleSettingsKeydown = (event: KeyboardEvent): void => { + if (event.key !== "Escape" && event.key !== "ArrowLeft") return; + if ( + event.key === "ArrowLeft" + && !(event.target instanceof Element && event.target.closest("[role='menuitem']")) + ) return; + const activeItem = this.activeSettingsItem; + if (!activeItem) return; + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Escape") { + this.options.closeSettingsSubmenu?.(activeItem ?? undefined); + } + this.focusSettingsItemWithoutOpening(activeItem); + }; + + private createSeparator(): HTMLDivElement { + const separator = document.createElement("div"); + separator.className = "agent-composer__add-separator"; + separator.setAttribute("role", "separator"); + return separator; + } + private readonly handleSubmenuBoundaryLeave = (event: PointerEvent): void => { const next = event.relatedTarget as Node | null; if (next && (this.noteItem?.contains(next) || this.options.notePopover.contains(next))) return; @@ -135,14 +303,42 @@ export class ComposerAddMenuController { } }; + private readonly handleSettingsBoundaryLeave = (event: PointerEvent): void => { + const next = event.relatedTarget as Node | null; + const current = event.currentTarget as Node | null; + if (next && current?.contains(next)) return; + if (next && this.options.settingsPopover?.contains(next)) return; + if (next instanceof Element && this.options.popover.contains(next)) { + if (next.closest(".agent-composer__add-setting-item")) return; + } + this.cancelSettingsClose(); + this.settingsCloseTimer = setTimeout(() => { + this.settingsCloseTimer = null; + this.options.closeSettingsSubmenu?.(this.activeSettingsItem ?? undefined); + this.activeSettingsItem = null; + }, 100); + }; + + private readonly cancelSettingsClose = (): void => { + if (this.settingsCloseTimer !== null) { + clearTimeout(this.settingsCloseTimer); + this.settingsCloseTimer = null; + } + }; + private pickAttachments(): void { + this.options.attachments.captureInsertionAnchor(); const input = document.createElement("input"); input.type = "file"; input.accept = COMPOSER_ATTACHMENT_ACCEPT; input.multiple = true; input.hidden = true; input.addEventListener("change", () => { - this.options.images.addFiles(Array.from(input.files ?? [])); + this.options.attachments.addFiles(Array.from(input.files ?? [])); + input.remove(); + }, { once: true }); + input.addEventListener("cancel", () => { + this.options.attachments.cancelInsertionAnchor(); input.remove(); }, { once: true }); document.body.append(input); @@ -159,9 +355,10 @@ export class ComposerAddMenuController { const width = popoverRect.width || 168; const height = popoverRect.height || 80; const left = Math.max(padding, Math.min(rect.left, window.innerWidth - width - padding)); - const top = rect.top >= height + offset + padding + const preferredTop = rect.top >= height + offset + padding ? rect.top - height - offset : rect.bottom + offset; + const top = Math.max(padding, Math.min(preferredTop, window.innerHeight - height - padding)); Object.assign(this.options.popover.style, { left: `${left}px`, top: `${top}px` }); } @@ -169,7 +366,7 @@ export class ComposerAddMenuController { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); - svg.classList.add("agent-thread-card__composer-add-chevron"); + svg.classList.add("agent-composer__add-chevron"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "m9 6 6 6-6 6"); path.setAttribute("fill", "none"); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-compat.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-compat.ts new file mode 100644 index 000000000..e90fa7dde --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-compat.ts @@ -0,0 +1,14 @@ +/** + * Persisted attachment names retained for old Thread Card drafts and HTML. + * Keep these aliases at the serialization boundary; editor code uses + * attachment terminology throughout. + */ +export const COMPOSER_ATTACHMENT_NODE_NAME = "composerAttachmentToken"; + +export const COMPOSER_ATTACHMENT_HTML_ATTRIBUTE = "data-composer-attachment"; +export const LEGACY_COMPOSER_IMAGE_HTML_ATTRIBUTE = "data-composer-image"; + +export const THREAD_CARD_ATTACHMENTS_ATTRIBUTE = "inputAttachments"; +export const LEGACY_THREAD_CARD_IMAGES_ATTRIBUTE = "inputImages"; +export const THREAD_CARD_ATTACHMENTS_HTML_ATTRIBUTE = "data-input-attachments"; +export const LEGACY_THREAD_CARD_IMAGES_HTML_ATTRIBUTE = "data-input-images"; diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.test.ts new file mode 100644 index 000000000..7cf3d075c --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ComposerAttachmentDocumentPort } from './composer-attachment-document'; +import { ComposerAttachmentController, type AgentThreadCardInputAttachment } from './composer-attachment-controller'; + +const mocks = vi.hoisted(() => ({ cacheAttachment: vi.fn(), deleteCachedAttachment: vi.fn(), readCachedAttachment: vi.fn() })); +vi.mock('@platform/tauri/client', () => ({ agent: mocks })); +const controllers: ComposerAttachmentController[] = []; +function setup(initialAttachments: AgentThreadCardInputAttachment[] = []) { + const input = document.createElement('div'); + document.body.append(input); + const onChange = vi.fn(); + const onLimitExceeded = vi.fn(); + const controller = new ComposerAttachmentController({ + input, initialAttachments, onChange, onLimitExceeded, onError: vi.fn(), onStateChange: vi.fn(), + }); + const insertAttachmentToken = vi.fn((_attachment: AgentThreadCardInputAttachment, point = { from: 1, to: 1 }) => ({ from: point.from + 1, to: point.from + 1 })); + const composer: ComposerAttachmentDocumentPort = { + captureAttachmentInsertionAnchor: () => undefined, + insertAttachmentToken, + removeAttachmentToken: vi.fn(), + setAttachmentTokensChanged: vi.fn(), + dispose: vi.fn(), + }; + controller.connectDocument(composer); + controllers.push(controller); + return { controller, input, onChange, onLimitExceeded, insertAttachmentToken }; +} +beforeEach(() => { + mocks.cacheAttachment.mockImplementation(async (_content: string, mimeType: string) => ({ path: `/tmp/${mocks.cacheAttachment.mock.calls.length}`, mimeType, name: 'generated' })); + mocks.readCachedAttachment.mockResolvedValue('data:image/png;base64,AQID'); + mocks.deleteCachedAttachment.mockResolvedValue(true); +}); +afterEach(() => { + controllers.splice(0).forEach((controller) => controller.dispose()); + document.body.replaceChildren(); + vi.resetAllMocks(); +}); + +describe('ComposerAttachmentController', () => { + it('pastes an image into the editor and retains the original filename', async () => { + const { controller, input, insertAttachmentToken } = setup(); + const file = new File(['image'], 'photo.png', { type: 'image/png' }); + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { items: [{ kind: 'file', type: file.type, getAsFile: () => file }] } }); + input.dispatchEvent(event); + await vi.waitFor(() => expect(controller.hasPending).toBe(false)); + expect(event.defaultPrevented).toBe(true); + expect(insertAttachmentToken).toHaveBeenCalledWith(expect.objectContaining({ name: 'photo.png' }), undefined); + expect(controller.readyAttachments[0].name).toBe('photo.png'); + }); + + it('restores image and PDF attachments into the editor without an external preview container', () => { + const attachments = [ + { path: '/tmp/photo.png', mimeType: 'image/png', name: 'photo.png' }, + { path: '/tmp/report.pdf', mimeType: 'application/pdf', name: 'report.pdf' }, + ]; + const { insertAttachmentToken, controller } = setup(attachments); + expect(insertAttachmentToken.mock.calls.map(([attachment]) => attachment)).toEqual(attachments); + expect(controller.readyAttachments).toEqual(attachments); + }); + + it('keeps removed attachments available for undo and cleans them when the surface closes', () => { + const attachment = { path: '/tmp/report.pdf', mimeType: 'application/pdf', name: 'report.pdf' }; + const { controller } = setup([attachment]); + controller.syncInlineAttachments([]); + expect(controller.hasAttachments).toBe(false); + expect(mocks.deleteCachedAttachment).not.toHaveBeenCalled(); + controller.syncInlineAttachments([attachment]); + expect(controller.readyAttachments).toEqual([attachment]); + controller.syncInlineAttachments([]); + controller.dispose(); + expect(mocks.deleteCachedAttachment).toHaveBeenCalledWith(attachment.path); + }); + + it('rebuilds attachments from persisted editor nodes even when host metadata is absent', () => { + const { controller } = setup(); + const attachment = { path: '/tmp/report.pdf', mimeType: 'application/pdf', name: 'report.pdf' }; + controller.syncInlineAttachments([attachment]); + expect(controller.readyAttachments).toEqual([attachment]); + }); + + it('does not delete files belonging to an in-flight submission when its visible draft clears', () => { + const attachment = { path: '/tmp/report.pdf', mimeType: 'application/pdf', name: 'report.pdf' }; + const { controller } = setup([attachment]); + controller.setLocked(true); + controller.syncInlineAttachments([]); + controller.clearAfterSubmit(); + expect(mocks.deleteCachedAttachment).not.toHaveBeenCalled(); + }); + + it('reserves all queued upload slots and enforces the file size limit', async () => { + const { controller, onLimitExceeded } = setup(); + const files = Array.from({ length: 6 }, (_, index) => new File(['x'], `${index}.pdf`, { type: 'application/pdf' })); + controller.addFiles([...files, new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'large.pdf', { type: 'application/pdf' })]); + controller.addFiles([files[0]]); + await vi.waitFor(() => expect(controller.hasPending).toBe(false)); + expect(mocks.cacheAttachment).toHaveBeenCalledTimes(5); + expect(onLimitExceeded).toHaveBeenCalledWith('count'); + expect(onLimitExceeded).toHaveBeenCalledWith('size'); + }); + + it('removes missing cached-image metadata and its inline node', async () => { + mocks.readCachedAttachment.mockResolvedValue(null); + const { controller, onChange } = setup([{ path: '/tmp/missing.png', name: 'missing.png', mimeType: 'image/png' }]); + await vi.waitFor(() => expect(controller.hasAttachments).toBe(false)); + expect(onChange).toHaveBeenCalledWith([]); + expect(mocks.deleteCachedAttachment).not.toHaveBeenCalled(); + }); +}); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.ts new file mode 100644 index 000000000..94f70219d --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.ts @@ -0,0 +1,290 @@ +import { agent } from "@platform/tauri/client"; +import type { ComposerInsertionAnchor, ComposerInsertionPoint } from "./composer-insertion-anchor"; +import type { ComposerAttachmentDocumentPort } from "./composer-attachment-document"; +import type { AgentThreadCardInputAttachment } from "./composer-attachment-types"; + +export type { AgentThreadCardInputAttachment } from "./composer-attachment-types"; + +const MAX_COMPOSER_ATTACHMENT_BYTES = 5 * 1024 * 1024; +const MAX_COMPOSER_ATTACHMENT_COUNT = 5; + +export const COMPOSER_ATTACHMENT_ACCEPT = [ + "image/*", ".doc", ".docx", ".pdf", ".xls", ".xlsx", ".ppt", ".pptx", +].join(","); + +const SUPPORTED_ATTACHMENT_MIME_TYPES = new Set([ + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/pdf", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", +]); + +function isSupportedAttachment(file: File): boolean { + return Boolean(getAttachmentMimeType(file)); +} + +function getAttachmentMimeType(file: File): string | null { + const mimeType = file.type.toLowerCase(); + if (mimeType.startsWith("image/") || SUPPORTED_ATTACHMENT_MIME_TYPES.has(mimeType)) { + return mimeType; + } + const extension = file.name.toLowerCase().split(".").pop(); + const byExtension: Record = { + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + pdf: "application/pdf", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }; + return (extension && byExtension[extension]) || null; +} + +function isImageAttachment(file: { mimeType: string }): boolean { + return file.mimeType.toLowerCase().startsWith("image/"); +} + +export interface ComposerAttachmentControllerOptions { + input: HTMLElement; + initialAttachments: AgentThreadCardInputAttachment[]; + onChange: (attachments: AgentThreadCardInputAttachment[]) => void; + onStateChange: () => void; + onError: (message: string) => void; + onLimitExceeded: (kind: "count" | "size") => void; +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error ?? new Error("Failed to read attachment")); + reader.readAsDataURL(file); + }); +} + +export class ComposerAttachmentController { + private attachments: AgentThreadCardInputAttachment[]; + /** Detached tokens stay cached while editor undo/history can restore them. */ + private readonly detachedAttachments = new Map(); + private pending = 0; + private disposed = false; + private locked = false; + private readonly submittedPaths = new Set(); + private insertionAnchor?: ComposerInsertionAnchor; + private readonly activeAnchors = new Set(); + private document?: ComposerAttachmentDocumentPort; + + constructor(private readonly options: ComposerAttachmentControllerOptions) { + this.attachments = options.initialAttachments.slice(0, MAX_COMPOSER_ATTACHMENT_COUNT); + options.input.addEventListener("paste", this.handlePaste); + for (const image of this.attachments) { + if (isImageAttachment(image)) void this.validateCachedImage(image); + } + } + + get readyAttachments(): AgentThreadCardInputAttachment[] { + return [...this.attachments]; + } + + get hasAttachments(): boolean { + return this.attachments.length > 0; + } + + get hasPending(): boolean { + return this.pending > 0; + } + + connectDocument(document: ComposerAttachmentDocumentPort): void { + if (this.disposed) return; + this.document?.dispose(); + this.document = document; + + // Restore persisted attachments into the editor before tracking removals. + let position: ComposerInsertionPoint = { from: 1, to: 1 }; + for (const image of this.attachments) { + position = document.insertAttachmentToken(image, position) ?? position; + } + document.setAttachmentTokensChanged((attachments) => this.syncInlineAttachments(attachments)); + } + + captureInsertionAnchor(): void { + this.cancelInsertionAnchor(); + this.insertionAnchor = this.document?.captureAttachmentInsertionAnchor(); + } + + cancelInsertionAnchor(): void { + this.insertionAnchor?.dispose(); + this.insertionAnchor = undefined; + } + + syncInlineAttachments(attachments: readonly AgentThreadCardInputAttachment[]): void { + if (this.disposed) return; + const paths = new Set(attachments.map((attachment) => attachment.path)); + for (const image of this.attachments) { + if (!paths.has(image.path)) this.detachedAttachments.set(image.path, image); + } + const nextAttachments = [...new Map(attachments.map((attachment) => [attachment.path, attachment])).values()]; + for (const image of nextAttachments) this.detachedAttachments.delete(image.path); + + const unchanged = nextAttachments.length === this.attachments.length && + nextAttachments.every((image, index) => image.path === this.attachments[index]?.path); + if (unchanged) return; + this.attachments = nextAttachments; + this.options.onChange(this.readyAttachments); + this.options.onStateChange(); + } + + /** Add picker-selected attachments through the same cache path as pasted attachments. */ + addFiles(files: File[]): void { + if (this.locked || this.disposed) return; + const eligibleFiles = files.filter((file) => { + if (!isSupportedAttachment(file)) return false; + if (file.size <= MAX_COMPOSER_ATTACHMENT_BYTES) return true; + this.options.onLimitExceeded("size"); + return false; + }); + const availableSlots = Math.max( + 0, + MAX_COMPOSER_ATTACHMENT_COUNT - this.attachments.length - this.pending, + ); + if (eligibleFiles.length > availableSlots) { + this.options.onLimitExceeded("count"); + } + const anchor = this.insertionAnchor; + this.insertionAnchor = undefined; + const acceptedFiles = eligibleFiles.slice(0, availableSlots); + this.pending += acceptedFiles.length; + this.options.onStateChange(); + void this.cacheFilesInOrder(acceptedFiles, anchor); + } + + clearAfterSubmit(): void { + this.attachments = []; + for (const image of this.detachedAttachments.values()) { + if (!this.submittedPaths.has(image.path)) { + void agent.deleteCachedAttachment(image.path).catch(() => undefined); + } + } + this.detachedAttachments.clear(); + this.options.onChange([]); + // Acceptance may leave an edited follow-up draft visible. Rebuild from that document. + this.document?.setAttachmentTokensChanged((attachments) => this.syncInlineAttachments(attachments)); + this.options.onStateChange(); + } + + /** Keep in-flight request files available until the request is accepted. */ + setLocked(locked: boolean): void { + if (this.disposed || this.locked === locked) return; + this.locked = locked; + this.submittedPaths.clear(); + if (locked) for (const image of this.attachments) this.submittedPaths.add(image.path); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.options.input.removeEventListener("paste", this.handlePaste); + this.document?.dispose(); + this.document = undefined; + this.cancelInsertionAnchor(); + for (const anchor of this.activeAnchors) anchor.dispose(); + this.activeAnchors.clear(); + for (const image of this.detachedAttachments.values()) { + if (!this.submittedPaths.has(image.path)) { + void agent.deleteCachedAttachment(image.path).catch(() => undefined); + } + } + this.detachedAttachments.clear(); + } + + private readonly handlePaste = (event: ClipboardEvent): void => { + const files = Array.from(event.clipboardData?.items ?? []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => !!file); + if (files.length === 0) return; + + // Clipboard image payloads often also contain a generated filename as text. + // Prevent that text from being inserted; genuine text-only paste stays native. + event.preventDefault(); + this.captureInsertionAnchor(); + this.addFiles(files); + }; + + private async cache(file: File, anchor?: ComposerInsertionAnchor): Promise { + try { + const content = await readFileAsDataUrl(file); + const result = await agent.cacheAttachment(content, getAttachmentMimeType(file) ?? file.type); + const cached = { ...result, name: file.name }; + if (this.disposed) { + void agent.deleteCachedAttachment(cached.path).catch(() => undefined); + return; + } + this.attachments = [...this.attachments, cached]; + if (this.document) { + const nextPosition = this.document.insertAttachmentToken(cached, anchor?.resolve()); + if (nextPosition) anchor?.advance(nextPosition); + } + this.options.onChange(this.readyAttachments); + } catch (error) { + if (!this.disposed) this.options.onError(error instanceof Error ? error.message : String(error)); + } finally { + this.pending = Math.max(0, this.pending - 1); + if (!this.disposed) this.options.onStateChange(); + } + } + + private async cacheFilesInOrder(files: File[], anchor?: ComposerInsertionAnchor): Promise { + if (anchor) this.activeAnchors.add(anchor); + let processed = 0; + try { + for (const file of files) { + if (this.disposed || this.locked) break; + await this.cache(file, anchor); + processed += 1; + } + } finally { + this.pending = Math.max(0, this.pending - (files.length - processed)); + anchor?.dispose(); + if (anchor) this.activeAnchors.delete(anchor); + if (!this.disposed) this.options.onStateChange(); + } + } + + private remove( + image: AgentThreadCardInputAttachment, + deleteCachedFile: boolean = true, + ): void { + if (this.locked && deleteCachedFile) return; + this.attachments = this.attachments.filter((candidate) => candidate.path !== image.path); + this.options.onChange(this.readyAttachments); + this.options.onStateChange(); + if (deleteCachedFile) { + void agent.deleteCachedAttachment(image.path).catch(() => undefined); + } else { + this.document?.removeAttachmentToken(image.path); + } + } + + private async validateCachedImage(image: AgentThreadCardInputAttachment): Promise { + try { + const content = await agent.readCachedAttachment(image.path); + if (this.disposed) return; + if (!this.attachments.some((candidate) => candidate.path === image.path)) return; + if (!content) { + this.remove(image, false); + return; + } + } catch (error) { + if (!this.disposed) { + this.options.onError( + error instanceof Error ? error.message : String(error), + ); + } + } + } +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-document.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-document.ts new file mode 100644 index 000000000..7221c60a5 --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-document.ts @@ -0,0 +1,124 @@ +import type { Editor } from "@tiptap/core"; +import type { AgentThreadCardInputAttachment } from "./composer-attachment-types"; +import { COMPOSER_ATTACHMENT_NODE_NAME } from "./composer-attachment-compat"; +import { + captureComposerInsertionAnchor, + type ComposerInsertionAnchor, + type ComposerInsertionPoint, +} from "./composer-insertion-anchor"; + +export interface ComposerAttachmentDocumentPort { + captureAttachmentInsertionAnchor(): ComposerInsertionAnchor | undefined; + insertAttachmentToken( + attachment: AgentThreadCardInputAttachment, + position?: ComposerInsertionPoint, + ): ComposerInsertionPoint | undefined; + removeAttachmentToken(path: string): void; + setAttachmentTokensChanged( + handler: (attachments: readonly AgentThreadCardInputAttachment[]) => void, + ): void; + dispose(): void; +} + +export function insertComposerAttachmentToken( + editor: Editor, + attachment: AgentThreadCardInputAttachment, + position?: ComposerInsertionPoint, +): ComposerInsertionPoint { + const { doc } = editor.state; + const maxPosition = Math.max(1, doc.content.size - 1); + const from = position ? Math.max(1, Math.min(position.from, maxPosition)) : 1; + const to = position ? Math.max(from, Math.min(position.to, maxPosition)) : from; + const before = from > 1 ? doc.textBetween(from - 1, from, "\n", "\n") : ""; + const after = to < maxPosition ? doc.textBetween(to, to + 1, "\n", "\n") : ""; + const content: Array> = []; + if (before && !/\s/u.test(before)) content.push({ type: "text", text: " " }); + content.push({ + type: COMPOSER_ATTACHMENT_NODE_NAME, + attrs: { path: attachment.path, name: attachment.name, mimeType: attachment.mimeType }, + }); + if (after && !/\s/u.test(after)) content.push({ type: "text", text: " " }); + editor.chain().focus(null, { scrollIntoView: false }).insertContentAt({ from, to }, content).run(); + const end = editor.state.selection.to; + return { from: end, to: end }; +} + +/** Tiptap document operations and token synchronization for composer files. */ +export class ComposerAttachmentDocument implements ComposerAttachmentDocumentPort { + private changed?: (attachments: readonly AgentThreadCardInputAttachment[]) => void; + + constructor(private readonly editor: Editor) { + editor.on("transaction", this.handleTransaction); + } + + dispose(): void { + this.changed = undefined; + if (!this.editor.isDestroyed) this.editor.off("transaction", this.handleTransaction); + } + + captureAttachmentInsertionAnchor(): ComposerInsertionAnchor | undefined { + if (this.editor.isDestroyed) return undefined; + return captureComposerInsertionAnchor(this.editor); + } + + insertAttachmentToken( + attachment: AgentThreadCardInputAttachment, + position?: ComposerInsertionPoint, + ): ComposerInsertionPoint | undefined { + if (this.editor.isDestroyed) return undefined; + let exists = false; + this.editor.state.doc.descendants((node) => { + if (node.type.name === COMPOSER_ATTACHMENT_NODE_NAME && node.attrs.path === attachment.path) { + exists = true; + } + }); + return exists ? position : this.insert(attachment, position); + } + + removeAttachmentToken(path: string): void { + if (this.editor.isDestroyed) return; + const matches: Array<{ from: number; to: number }> = []; + this.editor.state.doc.descendants((node, pos) => { + if (node.type.name === COMPOSER_ATTACHMENT_NODE_NAME && node.attrs.path === path) { + matches.push({ from: pos, to: pos + node.nodeSize }); + } + }); + if (matches.length === 0) return; + const transaction = this.editor.state.tr; + for (const match of matches.reverse()) transaction.delete(match.from, match.to); + this.editor.view.dispatch(transaction); + } + + setAttachmentTokensChanged( + handler: (attachments: readonly AgentThreadCardInputAttachment[]) => void, + ): void { + this.changed = handler; + this.reportChanges(); + } + + private reportChanges(): void { + if (!this.changed || this.editor.isDestroyed) return; + const attachments = new Map(); + this.editor.state.doc.descendants((node) => { + if (node.type.name !== COMPOSER_ATTACHMENT_NODE_NAME || !node.attrs.path) return; + const path = String(node.attrs.path); + attachments.set(path, { + path, + name: String(node.attrs.name ?? ""), + mimeType: String(node.attrs.mimeType ?? "application/octet-stream"), + }); + }); + this.changed([...attachments.values()]); + } + + private readonly handleTransaction = ({ transaction }: { transaction: { docChanged: boolean } }): void => { + if (transaction.docChanged) this.reportChanges(); + }; + + private insert( + attachment: AgentThreadCardInputAttachment, + position?: ComposerInsertionPoint, + ): ComposerInsertionPoint { + return insertComposerAttachmentToken(this.editor, attachment, position); + } +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-node-view.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-node-view.ts new file mode 100644 index 000000000..a4c52d301 --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-node-view.ts @@ -0,0 +1,54 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorView } from "@tiptap/pm/view"; +import { + createInlineAtomCloseIcon, + createInlineAtomNodeView, + deleteInlineAtomNode, +} from "@features/editor/extensions/shared/inline-atom-nodeview"; +import { createComposerAttachmentPreview } from "./composer-attachment-preview"; + +export function createComposerAttachmentNodeView(options: { + node: ProseMirrorNode; + view: EditorView; + getPos: (() => number | undefined) | boolean; +}) { + const { node, view, getPos } = options; + const path = String(node.attrs.path ?? ""); + const name = String(node.attrs.name ?? ""); + const mimeType = String(node.attrs.mimeType ?? "application/octet-stream"); + const content = document.createElement("span"); + content.className = "agent-composer__attachment-token"; + content.title = name; + content.setAttribute("aria-label", `附件 ${name}`); + + const preview = createComposerAttachmentPreview(path, name, mimeType, content); + const label = document.createElement("span"); + label.className = "agent-composer__attachment-token-label"; + label.textContent = name; + label.title = name; + content.append(preview.element, label); + + const nodeView = createInlineAtomNodeView({ + caretGeometry: "single-line", + wrapperClassName: "agent-composer__inline-atom-wrapper agent-composer__attachment-token-wrapper", + content, + title: name, + onClick: () => preview.onClick(), + hoverActions: [{ + ariaLabel: `移除附件 ${name}`, + icon: createInlineAtomCloseIcon(), + onClick: () => deleteInlineAtomNode( + view, + node, + () => typeof getPos === "function" ? getPos() : undefined, + ), + }], + }); + + return { + ...nodeView, + destroy: () => { + preview.destroy(); + }, + }; +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-preview.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-preview.ts new file mode 100644 index 000000000..e74a14c60 --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-preview.ts @@ -0,0 +1,143 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { agent } from "@platform/tauri/client"; +import { NotebookTreeResourceIcon } from "@features/memo/public/file-icon-api"; + +const IMAGE_MIME_RE = /^image\//i; + +export interface ComposerAttachmentPreview { + element: Element; + onClick: () => void; + destroy: () => void; +} + +/** Owns attachment thumbnail loading and the portaled click preview lifecycle. */ +export function createComposerAttachmentPreview( + path: string, + name: string, + mimeType: string, + anchor: HTMLElement, +): ComposerAttachmentPreview { + if (!IMAGE_MIME_RE.test(mimeType)) { + const template = document.createElement("template"); + template.innerHTML = renderToStaticMarkup(createElement(NotebookTreeResourceIcon, { + path: name, + className: "agent-composer__attachment-token-icon", + })); + const fileIcon = template.content.firstElementChild as SVGSVGElement; + return { + element: fileIcon, + onClick: () => undefined, + destroy: () => undefined, + }; + } + + const imagePreview = document.createElement("img"); + imagePreview.className = "agent-composer__attachment-token-preview"; + imagePreview.alt = ""; + imagePreview.draggable = false; + + let imageDataUrl: string | undefined; + let popover: HTMLDivElement | undefined; + let popoverImage: HTMLImageElement | undefined; + let popoverOpen = false; + let destroyed = false; + + const positionPopover = (): void => { + if (!popoverOpen || !popover?.isConnected) return; + const anchorRect = anchor.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + const width = popoverRect.width || 272; + const height = popoverRect.height || 120; + const padding = 8; + const left = Math.min( + Math.max(anchorRect.left + (anchorRect.width - width) / 2, padding), + Math.max(padding, window.innerWidth - width - padding), + ); + const top = Math.max(padding, anchorRect.top - height - 8); + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; + }; + + const handleViewportChange = (): void => positionPopover(); + function closePopover(): void { + popoverOpen = false; + if (popover) popover.hidden = true; + anchor.setAttribute("aria-expanded", "false"); + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + document.removeEventListener("pointerdown", handleOutsidePointer, true); + document.removeEventListener("keydown", handleKeyDown, true); + } + function openPopover(): void { + if (destroyed) return; + if (!popover) { + popover = document.createElement("div"); + popover.className = "agent-composer__image-preview-popover"; + popover.hidden = true; + popover.setAttribute("role", "dialog"); + popover.setAttribute("aria-label", `图片预览:${name}`); + popoverImage = document.createElement("img"); + popoverImage.alt = name; + popoverImage.draggable = false; + popoverImage.addEventListener("load", () => { + if (popoverOpen) positionPopover(); + }); + popover.append(popoverImage); + document.body.append(popover); + } + if (imageDataUrl && popoverImage && popoverImage.src !== imageDataUrl) { + popoverImage.src = imageDataUrl; + } + popoverOpen = true; + anchor.setAttribute("aria-expanded", "true"); + popover.hidden = !imageDataUrl; + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + document.addEventListener("pointerdown", handleOutsidePointer, true); + document.addEventListener("keydown", handleKeyDown, true); + if (imageDataUrl) positionPopover(); + } + + function handleOutsidePointer(event: PointerEvent): void { + const target = event.target as Node | null; + if (target && (anchor.contains(target) || popover?.contains(target))) return; + closePopover(); + } + function handleKeyDown(event: KeyboardEvent): void { + if (event.key !== "Escape" || !popoverOpen) return; + event.preventDefault(); + event.stopPropagation(); + closePopover(); + } + const togglePopover = (): void => { + if (popoverOpen) closePopover(); + else openPopover(); + }; + + anchor.setAttribute("aria-haspopup", "dialog"); + anchor.setAttribute("aria-expanded", "false"); + + void agent.readCachedAttachment(path) + .then((dataUrl) => { + if (destroyed || !dataUrl) return; + imageDataUrl = dataUrl; + imagePreview.src = dataUrl; + if (popoverImage) popoverImage.src = dataUrl; + if (popoverOpen && popover) popover.hidden = false; + if (popoverOpen) positionPopover(); + }) + .catch(() => undefined); + + return { + element: imagePreview, + onClick: togglePopover, + destroy: () => { + destroyed = true; + closePopover(); + popover?.remove(); + popover = undefined; + popoverImage = undefined; + }, + }; +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.test.ts new file mode 100644 index 000000000..7786c9d93 --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.test.ts @@ -0,0 +1,124 @@ +import { Editor } from '@tiptap/core'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { UndoRedo } from '@tiptap/extensions/undo-redo'; +import { Markdown } from '@tiptap/markdown'; +import { closeHistory } from '@tiptap/pm/history'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComposerAttachmentToken } from './composer-attachment-token'; +import { insertComposerAttachmentToken } from './composer-attachment-document'; +import { COMPOSER_ATTACHMENT_NODE_NAME } from './composer-attachment-compat'; +import { ComposerAttachmentController, type AgentThreadCardInputAttachment } from './composer-attachment-controller'; +import { captureComposerInsertionAnchor } from './composer-insertion-anchor'; +import { NotebookTreeResourceIcon } from '@features/memo/public/file-icon-api'; + +const mocks = vi.hoisted(() => ({ cacheAttachment: vi.fn(), deleteCachedAttachment: vi.fn(), readCachedAttachment: vi.fn() })); +vi.mock('@platform/tauri/client', () => ({ agent: mocks })); +const cleanups: Array<() => void> = []; +beforeEach(() => { + mocks.cacheAttachment.mockImplementation(async (_data: string, mimeType: string) => ({ path: `/tmp/${mocks.cacheAttachment.mock.calls.length}`, name: 'generated', mimeType })); + mocks.readCachedAttachment.mockResolvedValue('data:image/png;base64,AQID'); + mocks.deleteCachedAttachment.mockResolvedValue(true); + Object.defineProperties(Range.prototype, { + getClientRects: { configurable: true, value: () => [] }, + getBoundingClientRect: { configurable: true, value: () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }) }, + }); +}); +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + document.body.replaceChildren(); + vi.resetAllMocks(); +}); +function setup(content = '') { + const input = document.createElement('div'); + document.body.append(input); + const editor = new Editor({ element: { mount: input }, extensions: [Document, Paragraph, Text, UndoRedo, Markdown, ComposerAttachmentToken], content, contentType: 'markdown' }); + const attachments = new ComposerAttachmentController({ input, initialAttachments: [], onChange: vi.fn(), onStateChange: vi.fn(), onError: vi.fn(), onLimitExceeded: vi.fn() }); + attachments.connectDocument({ + captureAttachmentInsertionAnchor: () => captureComposerInsertionAnchor(editor), + insertAttachmentToken: (attachment, position) => insertComposerAttachmentToken(editor, attachment, position), + removeAttachmentToken: () => undefined, + setAttachmentTokensChanged(handler) { + const sync = () => { + const tokens: AgentThreadCardInputAttachment[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === COMPOSER_ATTACHMENT_NODE_NAME) tokens.push({ path: node.attrs.path, name: node.attrs.name, mimeType: node.attrs.mimeType }); + }); + handler(tokens); + }; + editor.on('update', sync); + sync(); + }, + dispose: () => undefined, + }); + cleanups.push(() => { attachments.dispose(); editor.destroy(); }); + return { editor, attachments, input }; +} +const pdf = (name: string) => new File(['pdf'], name, { type: 'application/pdf' }); + +describe('Inline composer attachments', () => { + it('replaces the selected text once when uploading multiple files', async () => { + const { editor, attachments } = setup('before replace after'); + editor.commands.setTextSelection({ from: 8, to: 15 }); + attachments.captureInsertionAnchor(); + attachments.addFiles([pdf('first.pdf'), pdf('second.pdf')]); + await vi.waitFor(() => expect(attachments.hasPending).toBe(false)); + expect(editor.state.doc.textContent).toContain('before'); + expect(editor.state.doc.textContent).toContain('after'); + expect(editor.state.doc.textContent).not.toContain('replace'); + expect(attachments.readyAttachments.map((attachment) => attachment.name)).toEqual(['first.pdf', 'second.pdf']); + }); + + it('maps the captured insertion position through edits made while the file caches', async () => { + const { editor, attachments } = setup('abcd'); + let resolveCache!: (value: AgentThreadCardInputAttachment) => void; + mocks.cacheAttachment.mockImplementation(() => new Promise((resolve) => { resolveCache = resolve; })); + editor.commands.setTextSelection(5); + attachments.captureInsertionAnchor(); + attachments.addFiles([pdf('report.pdf')]); + await vi.waitFor(() => expect(mocks.cacheAttachment).toHaveBeenCalled()); + editor.commands.insertContentAt(1, 'prefix '); + resolveCache({ path: '/tmp/report.pdf', name: 'generated', mimeType: 'application/pdf' }); + await vi.waitFor(() => expect(attachments.hasPending).toBe(false)); + let attachmentPosition = -1; + editor.state.doc.descendants((node, pos) => { if (node.type.name === COMPOSER_ATTACHMENT_NODE_NAME) attachmentPosition = pos; }); + expect(editor.state.doc.textBetween(0, attachmentPosition)).toBe('prefix abcd '); + }); + + it('keeps an attachment submit-ready after delete and undo', () => { + const { editor, attachments } = setup(); + const attachment = { path: '/tmp/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }; + insertComposerAttachmentToken(editor, attachment, { from: 1, to: 1 }); + editor.view.dispatch(closeHistory(editor.state.tr)); + editor.commands.deleteRange({ from: 1, to: 2 }); + expect(attachments.readyAttachments).toEqual([]); + editor.commands.undo(); + expect(attachments.readyAttachments).toEqual([attachment]); + expect(mocks.deleteCachedAttachment).not.toHaveBeenCalled(); + }); + + it('uses the matching SVG icon from the workspace file tree for non-image attachments', () => { + const { editor, input } = setup(); + insertComposerAttachmentToken(editor, { path: '/tmp/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }); + const icon = input.querySelector('.agent-composer__attachment-token-icon'); + const expectedTemplate = document.createElement('template'); + expectedTemplate.innerHTML = renderToStaticMarkup(createElement(NotebookTreeResourceIcon, { + path: 'report.pdf', + className: 'agent-composer__attachment-token-icon', + })); + expect(icon?.outerHTML).toBe(expectedTemplate.content.firstElementChild?.outerHTML); + }); + + it('round-trips attachment cards in the saved Markdown draft', () => { + const { editor, attachments } = setup(); + const attachment = { path: '/tmp/report.pdf', name: 'report.pdf', mimeType: 'application/pdf' }; + insertComposerAttachmentToken(editor, attachment); + const markdown = editor.getMarkdown(); + editor.commands.setContent(markdown, { contentType: 'markdown' }); + expect(attachments.readyAttachments).toEqual([attachment]); + expect(editor.state.doc.firstChild?.firstChild?.type.name).toBe(COMPOSER_ATTACHMENT_NODE_NAME); + }); +}); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.ts new file mode 100644 index 000000000..084331c5d --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.ts @@ -0,0 +1,135 @@ +import { Node, type JSONContent, type MarkdownToken } from "@tiptap/core"; +import { + COMPOSER_ATTACHMENT_HTML_ATTRIBUTE, + COMPOSER_ATTACHMENT_NODE_NAME, + LEGACY_COMPOSER_IMAGE_HTML_ATTRIBUTE, +} from "./composer-attachment-compat"; +import { createComposerAttachmentNodeView } from "./composer-attachment-node-view"; +import { deleteAdjacentInlineAtom } from "@features/editor/extensions/shared/inline-atom-nodeview"; + +const TOKEN_RE = /^\u27e6attachment:([^:\u27e7]*):([^:\u27e7]*):([^:\u27e7]*)\u27e7/; +const TOKEN_GLOBAL_RE = /\u27e6attachment:([^:\u27e7]*):([^:\u27e7]*):([^:\u27e7]*)\u27e7/g; + +function decode(value: string): string { + try { return decodeURIComponent(value); } catch { return value; } +} + +/** Inline attachment data and serialization. View behavior lives in adjacent modules. */ +export const ComposerAttachmentToken = Node.create({ + name: COMPOSER_ATTACHMENT_NODE_NAME, + priority: 1100, + group: "inline", + inline: true, + atom: true, + selectable: true, + addAttributes() { + return { + path: { default: "" }, + name: { default: "" }, + // Preserve the old image-token default for drafts created before MIME + // data was serialized; new generic attachments always store their type. + mimeType: { default: "image/png" }, + }; + }, + parseHTML() { + return [{ + tag: `span[${COMPOSER_ATTACHMENT_HTML_ATTRIBUTE}], span[${LEGACY_COMPOSER_IMAGE_HTML_ATTRIBUTE}]`, + getAttrs: (dom) => { + const element = dom as HTMLElement; + return { + path: element.getAttribute(COMPOSER_ATTACHMENT_HTML_ATTRIBUTE) + ?? element.getAttribute(LEGACY_COMPOSER_IMAGE_HTML_ATTRIBUTE) + ?? "", + name: element.getAttribute("data-name") ?? "", + mimeType: element.getAttribute("data-mime-type") ?? "image/png", + }; + }, + }]; + }, + renderHTML({ node }) { + const path = String(node.attrs.path ?? ""); + return ["span", { + [COMPOSER_ATTACHMENT_HTML_ATTRIBUTE]: path, + // Older Flowix releases read this attribute when pasting saved HTML. + [LEGACY_COMPOSER_IMAGE_HTML_ATTRIBUTE]: path, + "data-name": String(node.attrs.name ?? ""), + "data-mime-type": String(node.attrs.mimeType ?? "application/octet-stream"), + class: "agent-composer__attachment-token", + }, String(node.attrs.name ?? "")]; + }, + markdownTokenizer: { + name: COMPOSER_ATTACHMENT_NODE_NAME, + level: "inline" as const, + start(src: string) { return src.indexOf("⟦attachment:"); }, + tokenize(src: string) { + const match = TOKEN_RE.exec(src); + return match ? { + type: COMPOSER_ATTACHMENT_NODE_NAME, + raw: match[0], + path: decode(match[1]), + name: decode(match[2]), + mimeType: decode(match[3]), + } : undefined; + }, + }, + parseMarkdown(token: MarkdownToken) { + return { + type: COMPOSER_ATTACHMENT_NODE_NAME, + attrs: { + path: String(token.path ?? ""), + name: String(token.name ?? ""), + mimeType: String(token.mimeType ?? "image/png"), + }, + }; + }, + renderMarkdown(node: JSONContent) { + const { path, name, mimeType } = node.attrs ?? {}; + return `⟦attachment:${encodeURIComponent(String(path ?? ""))}:${encodeURIComponent(String(name ?? ""))}:${encodeURIComponent(String(mimeType ?? "image/png"))}⟧`; + }, + addNodeView() { + return ({ node, view, getPos }) => createComposerAttachmentNodeView({ node, view, getPos }); + }, + addKeyboardShortcuts() { + return { + Backspace: () => deleteAdjacentInlineAtom(this.editor, COMPOSER_ATTACHMENT_NODE_NAME, "before"), + Delete: () => deleteAdjacentInlineAtom(this.editor, COMPOSER_ATTACHMENT_NODE_NAME, "after"), + }; + }, +}); + +function toLocalFileUrl(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const encodeSegments = (value: string) => value.split("/").map(encodeURIComponent).join("/"); + + if (normalized.startsWith("//")) { + const [host = "", ...segments] = normalized.slice(2).split("/"); + return `file://${encodeURIComponent(host)}/${segments.map(encodeURIComponent).join("/")}`; + } + const windowsDrive = /^([a-z]):\/(.*)$/i.exec(normalized); + if (windowsDrive) { + return `file:///${windowsDrive[1].toUpperCase()}:/${encodeSegments(windowsDrive[2])}`; + } + const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`; + return `file://${encodeSegments(absolutePath)}`; +} + +function escapeMarkdownLinkLabel(label: string): string { + return label + .replace(/[\u0000-\u001f\u007f]/gu, " ") + .replace(/\\/g, "\\\\") + .replace(/([\[\]])/g, "\\$1"); +} + +export function composerAttachmentMarkdownToPrompt(markdown: string): string { + return markdown.replace( + TOKEN_GLOBAL_RE, + (_token, encodedPath: string, encodedName: string, encodedMimeType: string) => { + const path = decode(encodedPath); + const name = decode(encodedName) || path.split(/[\\/]/u).pop() || "Attachment"; + const mimeType = decode(encodedMimeType).toLowerCase(); + if (mimeType.startsWith("image/")) return ""; + if (!path) return ""; + return `[${escapeMarkdownLinkLabel(name)}](${toLocalFileUrl(path)})`; + }, + ); +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-attachment-types.ts b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-types.ts new file mode 100644 index 000000000..6ba2a47c2 --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-attachment-types.ts @@ -0,0 +1,9 @@ +import type { CachedAgentAttachment } from "@platform/tauri/client"; + +export type AgentThreadCardInputAttachment = CachedAgentAttachment; + +export function isAgentThreadCardImageAttachment( + attachment: Pick, +): boolean { + return attachment.mimeType.toLowerCase().startsWith("image/"); +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-commands.ts b/app/flowix-web/features/agent/thread-card/composer/composer-commands.ts index bfef70782..bd0c19215 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-commands.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-commands.ts @@ -14,13 +14,13 @@ export interface ComposerSlashCommand { } export const COMPOSER_SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ - { name: "compact", description: "压缩较早的对话上下文", agentType: "deepseek-harness", owner: "dsh", interaction: "direct", execution: "dsh-command" }, - { name: "skill", description: "选择一个 DSH Skill", agentType: "deepseek-harness", owner: "dsh", interaction: "drilldown", execution: "dsh-skill" }, - { name: "goal", description: "设置或查看长时任务目标", agentType: "deepseek-harness", owner: "dsh", interaction: "prompt", execution: "dsh-command" }, - { name: "plan", description: "进入或退出计划模式", agentType: "deepseek-harness", owner: "dsh", interaction: "prompt", execution: "dsh-command" }, - { name: "model", description: "选择本次会话使用的模型", agentType: "deepseek-harness", owner: "flowix", interaction: "drilldown", execution: "host-action" }, - { name: "permission", description: "切换权限预设", agentType: "deepseek-harness", owner: "flowix", interaction: "drilldown", execution: "host-action" }, - { name: "export", description: "导出当前 DSH 会话记录", agentType: "deepseek-harness", owner: "dsh", interaction: "direct", execution: "dsh-command" }, + { name: "compact", description: "压缩较早的对话上下文", agentType: "dsh", owner: "dsh", interaction: "direct", execution: "dsh-command" }, + { name: "skill", description: "选择一个 DSH Skill", agentType: "dsh", owner: "dsh", interaction: "drilldown", execution: "dsh-skill" }, + { name: "goal", description: "设置或查看长时任务目标", agentType: "dsh", owner: "dsh", interaction: "prompt", execution: "dsh-command" }, + { name: "plan", description: "进入或退出计划模式", agentType: "dsh", owner: "dsh", interaction: "prompt", execution: "dsh-command" }, + { name: "model", description: "选择本次会话使用的模型", agentType: "dsh", owner: "flowix", interaction: "drilldown", execution: "host-action" }, + { name: "permission", description: "切换权限预设", agentType: "dsh", owner: "flowix", interaction: "drilldown", execution: "host-action" }, + { name: "export", description: "导出当前 DSH 会话记录", agentType: "dsh", owner: "dsh", interaction: "direct", execution: "dsh-command" }, { name: "compact", description: "压缩较早的 Codex 对话上下文", agentType: "codex", owner: "flowix", interaction: "direct", execution: "codex-command" }, { name: "skill", description: "选择一个 Codex Skill", agentType: "codex", owner: "flowix", interaction: "drilldown", execution: "codex-skill" }, { name: "goal", description: "设置或查看 Codex 长时任务目标", agentType: "codex", owner: "flowix", interaction: "prompt", execution: "codex-command" }, diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-controller.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-controller.test.ts index 925a6be1a..56dbb908d 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-controller.test.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-controller.test.ts @@ -9,6 +9,7 @@ import { disposeAgentComposerDom, type ComposerControllerOptions, } from "./index"; +import { insertComposerAttachmentToken } from "./composer-attachment-document"; import { insertComposerSkillToken } from "./composer-skill-token"; const controllers: ComposerController[] = []; @@ -84,7 +85,7 @@ describe("ComposerController note references", () => { controller.editorInstance.commands.setContent("hello", { contentType: "markdown" }); const input = controller.getInputElement(); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); - const button = () => document.querySelector(".agent-thread-card__send")!; + const button = () => document.querySelector(".agent-composer__send")!; await vi.waitFor(() => expect(button()?.disabled).toBe(true)); button().click(); expect(submit).not.toHaveBeenCalled(); @@ -210,7 +211,7 @@ describe("ComposerController note references", () => { .getInputElement() .parentElement; - expect(row?.classList.contains("agent-thread-card__composer-input-row")).toBe(true); + expect(row?.classList.contains("agent-composer__input-row")).toBe(true); row?.dispatchEvent( new MouseEvent("pointerdown", { bubbles: true, @@ -238,7 +239,7 @@ describe("ComposerController note references", () => { draft.flush(); expect(controller.getPrompt()).toBe( - "before [Reference](flowix://open?notebookId=notebook-a&relativePath=reference.md) after", + "before [reference](flowix://open?notebookId=notebook-a&relativePath=reference.md) after", ); expect(editor.getJSON().content?.[0]?.content).toEqual( expect.arrayContaining([ @@ -246,7 +247,7 @@ describe("ComposerController note references", () => { ]), ); expect(getPersistedDraft()).toBe( - "before [Reference](flowix://open?notebookId=notebook-a&relativePath=reference.md) after", + "before [reference](flowix://open?notebookId=notebook-a&relativePath=reference.md) after", ); }); @@ -274,7 +275,7 @@ describe("ComposerController note references", () => { { contentType: "markdown" }, ); - expect(controller.getInputElement().querySelector(".agent-thread-card__skill-token")?.textContent) + expect(controller.getInputElement().querySelector(".agent-composer__skill-token-label")?.textContent) .toBe("Browser"); expect(controller.getPrompt()).toBe("$browser:control-in-app-browser"); expect(editor.getMarkdown()).toBe( @@ -290,7 +291,7 @@ describe("ComposerController note references", () => { { contentType: "markdown" }, ); - expect(controller.getInputElement().querySelector(".agent-thread-card__skill-token")?.textContent) + expect(controller.getInputElement().querySelector(".agent-composer__skill-token-label")?.textContent) .toBe("control-in-app-browser"); expect(controller.getPrompt()).toBe("$browser:control-in-app-browser"); }); @@ -303,8 +304,29 @@ describe("ComposerController note references", () => { "Browser", ); - expect(controller.getInputElement().querySelector(".agent-thread-card__skill-token")?.textContent) + expect(controller.getInputElement().querySelector(".agent-composer__skill-token-label")?.textContent) .toBe("Browser"); expect(controller.getPrompt()).toBe("$browser:control-in-app-browser "); }); }); + + +describe("composer draft history", () => { + it("restores inline attachments and skills when returning from message history", () => { + const { controller } = setup({ getUserHistoryMessages: () => ["previous message"] }); + insertComposerSkillToken(controller.editorInstance, "figma:figma-use"); + insertComposerAttachmentToken(controller.editorInstance, { + path: "/tmp/report.pdf", + name: "report.pdf", + mimeType: "application/pdf", + }); + const draft = controller.getDraftMarkdown(); + controller.editorInstance.commands.setTextSelection(1); + controller.getInputElement().dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true })); + expect(controller.getPrompt()).toBe("previous message"); + controller.getInputElement().dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })); + expect(controller.getDraftMarkdown()).toBe(draft); + expect(controller.getInputElement().querySelector(".agent-composer__attachment-token")).not.toBeNull(); + expect(controller.getInputElement().querySelector(".agent-composer__skill-token")).not.toBeNull(); + }); +}); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-controller.ts index 471f501b2..1025c9adb 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-controller.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-controller.ts @@ -37,7 +37,10 @@ import type { ComposerSlashSkill, } from "@features/agent/thread-card/composer/composer-slash-command-controller"; import type { AgentTypeKey } from "@/types/agent"; -import { NoteReference } from "@features/editor/extensions/note-link"; +import { ComposerNoteReference } from "@features/editor/extensions/note-link"; +import { ComposerStableCaret } from "@features/editor/extensions/stable-caret"; +import { ComposerAttachmentToken, composerAttachmentMarkdownToPrompt } from "./composer-attachment-token"; +import { ComposerInlineAtomCaret } from "./composer-inline-atom-caret"; import type { MemoRef } from "@features/agent/thread-card/note/note-picker-controller"; import type { Root } from "react-dom/client"; @@ -66,6 +69,7 @@ export interface ComposerControllerOptions { getHasAttachments: () => boolean; getHasPendingAttachments: () => boolean; agentType?: AgentTypeKey; + getSkillsScopeId?: () => string | undefined; listDshSkills?: () => Promise; listCodexSkills?: () => Promise; listPiSkills?: () => Promise; @@ -136,12 +140,15 @@ export class ComposerController { Text, HardBreak, UndoRedo, + ComposerStableCaret, + ComposerInlineAtomCaret, Markdown.configure({ markedOptions: { gfm: true, breaks: true }, }), - NoteReference, + ComposerNoteReference, ComposerSkillToken, ComposerFolderToken, + ComposerAttachmentToken, ComposerSlashToken.configure({ onRemove: () => removeSlashToken?.(), }), @@ -150,7 +157,7 @@ export class ComposerController { contentType: "markdown", editorProps: { attributes: { - class: "agent-thread-card__composer-editor", + class: "agent-composer__input-content", spellcheck: "true", "aria-label": this.input.dataset.placeholder ?? "", }, @@ -171,8 +178,8 @@ export class ComposerController { // Restore the composer-owned hooks after initialization so document // editor styles can explicitly exclude this nested editor. this.input.classList.add( - "agent-thread-card__composer-input", - "agent-thread-card__composer-editor", + "agent-composer__input", + "agent-composer__input-content", ); this.slashCommands = new ComposerSlashCommandController({ @@ -180,6 +187,7 @@ export class ComposerController { input: this.input, composer: this.composer, agentType: options.agentType, + getSkillsScopeId: options.getSkillsScopeId, listDshSkills: options.listDshSkills, listCodexSkills: options.listCodexSkills, listPiSkills: options.listPiSkills, @@ -238,6 +246,7 @@ export class ComposerController { // Capture before ProseMirror's own keymap so plain Enter submits without // first inserting an empty paragraph into the composer document. this.input.addEventListener("keydown", this.handleKeydown, true); + this.composer.addEventListener("pointermove", this.handleComposerPointerMove); this.input.addEventListener("beforeinput", this.handleBeforeInput, true); this.input.addEventListener("compositionstart", this.handleCompositionStart); this.input.addEventListener("compositionend", this.handleCompositionEnd); @@ -258,9 +267,9 @@ export class ComposerController { // hardBreak. Markdown serializes that node as ` \n`; the agent protocol // should receive the same newline the user entered, without Markdown's // visual line-break marker becoming part of the prompt. - return composerFolderMarkdownToPrompt( + return composerAttachmentMarkdownToPrompt(composerFolderMarkdownToPrompt( composerSkillMarkdownToPrompt(composerSlashMarkdownToPrompt(this.getDraftMarkdown())), - ) + )) .replace(/ {2}\n/g, "\n"); } @@ -297,6 +306,7 @@ export class ComposerController { clear(): void { if (this.editor.isDestroyed) return; + this.disableComposerHoverUntilPointerMove(); this.editor .chain() .setContent("", { contentType: "markdown", emitUpdate: false }) @@ -315,11 +325,14 @@ export class ComposerController { options: { persistDraft?: boolean } = {}, ): void { if (this.editor.isDestroyed) return; + this.disableComposerHoverUntilPointerMove(); this.editor .chain() .setContent(content, { contentType: "markdown", emitUpdate: false }) .focus("end", { scrollIntoView: false }) .run(); + // setContent intentionally suppresses editor updates for history navigation; + // keep inline attachments in sync with the newly loaded draft explicitly. if (options.persistDraft) this.persistInputDraft(content); this.updateMultiLineState(); } @@ -340,7 +353,6 @@ export class ComposerController { content.push({ type: "noteReference", attrs: { - memoId: null, notebookId: ref.notebookId, relativePath: ref.relativePath, notebookName: "", @@ -364,14 +376,14 @@ export class ComposerController { updateMultiLineState(): void { const prompt = this.getPrompt(); if (prompt === "") { - this.composer.classList.remove("agent-thread-card__composer--multi-line"); + this.composer.classList.remove("agent-composer--multi-line"); this.setSendButtonState(""); return; } const isMulti = this.input.scrollHeight > 30; this.composer.classList.toggle( - "agent-thread-card__composer--multi-line", + "agent-composer--multi-line", isMulti, ); this.setSendButtonState(prompt.trim()); @@ -415,6 +427,7 @@ export class ComposerController { this.removeUndoHandler(); this.removeRedoHandler(); this.input.removeEventListener("keydown", this.handleKeydown, true); + this.composer.removeEventListener("pointermove", this.handleComposerPointerMove); this.input.removeEventListener("beforeinput", this.handleBeforeInput, true); this.input.removeEventListener("compositionstart", this.handleCompositionStart); this.input.removeEventListener("compositionend", this.handleCompositionEnd); @@ -433,6 +446,7 @@ export class ComposerController { private readonly handleEditorUpdate = (): void => { if (this.suppressUpdates || this.disposed) return; + this.disableComposerHoverUntilPointerMove(); if (!this.isCurrentHistoryEntryUnmodified()) this.resetHistoryNavigation(); if (!this.isComposing) this.persistInputDraft(); this.updateMultiLineState(); @@ -447,6 +461,17 @@ export class ComposerController { if (!this.disposed) this.slashCommands.refresh(); }; + private readonly handleComposerPointerMove = (event: PointerEvent): void => { + // Re-arm CSS hover only after physical mouse movement. The OS can hide the + // cursor while typing, but its stationary hit-test position remains active. + if (event.pointerType !== "mouse") return; + this.composer.classList.add("agent-composer--pointer-hover-ready"); + }; + + private disableComposerHoverUntilPointerMove(): void { + this.composer.classList.remove("agent-composer--pointer-hover-ready"); + } + /** * Keep the caret visible when a hard break grows the editor past its * max-height. The editor height/class can change during the update, so wait @@ -500,6 +525,7 @@ export class ComposerController { } private readonly handleKeydown = (event: KeyboardEvent): void => { + this.disableComposerHoverUntilPointerMove(); if (this.isComposing || event.isComposing || event.keyCode === 229) return; // The composer is a nested contenteditable inside the document editor. @@ -592,7 +618,7 @@ export class ComposerController { if (direction === "up") { if (this.historyCursor === null && this.preNavDraft === null) { - this.preNavDraft = this.getPrompt(); + this.preNavDraft = this.getDraftMarkdown(); } const next = this.historyCursor === null ? messages.length - 1 diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.test.ts index 7ed3a17fe..3775c1f2d 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.test.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.test.ts @@ -105,12 +105,11 @@ describe("createAgentComposerDom click-to-focus delegation", () => { disposeAgentComposerDom(parts); }); - it("focuses the textarea when clicking inside composer-images container", () => { + it("focuses the editor when clicking inside the factory-owned input row", () => { const parts = createAgentComposerDom({ t: noopT }); document.body.append(parts.composer); - parts.composerImages.hidden = false; const imageSlot = document.createElement("span"); - parts.composerImages.append(imageSlot); + parts.inputRow.append(imageSlot); dispatchPointerDown(imageSlot); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.ts b/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.ts index 5b5651dec..9714f68f7 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-dom-factory.ts @@ -9,7 +9,7 @@ export interface AgentComposerDomFactoryOptions { export interface AgentComposerDomParts { composer: HTMLElement; - composerImages: HTMLDivElement; + inputRow: HTMLDivElement; composerActions: HTMLDivElement; composerRoleIcon: HTMLButtonElement; composerAddPopover: HTMLDivElement; @@ -87,7 +87,7 @@ function handleComposerPointerDown(event: PointerEvent): void { // 所以不会影响发送、添加内容、模型选择等按钮的 click 行为。 event.preventDefault(); event.stopPropagation(); - const input = composer.querySelector('.agent-thread-card__composer-input'); + const input = composer.querySelector('.agent-composer__input'); if (!input) return; focusComposerInput(composer, input); } @@ -98,7 +98,7 @@ function handleComposerClick(event: MouseEvent): void { const target = event.target as Element | null; if (!target || isComposerInteractiveTarget(composer, target)) return; event.stopPropagation(); - const input = composer.querySelector('.agent-thread-card__composer-input'); + const input = composer.querySelector('.agent-composer__input'); if (!input) return; focusComposerInput(composer, input); } @@ -108,16 +108,15 @@ export function createAgentComposerDom( options: AgentComposerDomFactoryOptions, ): AgentComposerDomParts { const composer = document.createElement('div'); - composer.className = 'agent-thread-card__composer'; + composer.className = 'agent-composer'; if (options.variant === 'expanded') { composer.classList.add('agent-composer--expanded'); } - const composerImages = document.createElement('div'); - composerImages.className = 'agent-thread-card__composer-images'; - composerImages.hidden = true; + const inputRow = document.createElement('div'); + inputRow.className = 'agent-composer__input-row'; const composerRoleIcon = document.createElement('button'); composerRoleIcon.type = 'button'; - composerRoleIcon.className = 'agent-thread-card__composer-add-button'; + composerRoleIcon.className = 'agent-composer__add-button'; composerRoleIcon.setAttribute('aria-haspopup', 'menu'); composerRoleIcon.setAttribute('aria-expanded', 'false'); composerRoleIcon.setAttribute('aria-label', options.t('editor.threadCard.addContent')); @@ -127,7 +126,7 @@ export function createAgentComposerDom( // point as a plain div lets the same imperative DOM contract work for both // the note-embedded NodeView and the standalone conversation surface. const input = document.createElement('div'); - input.className = 'agent-thread-card__composer-input'; + input.className = 'agent-composer__input'; input.contentEditable = 'true'; // Explicitly tabbable so the mount point remains focusable before Tiptap // initializes (and in embedded WebViews/jsdom where contenteditable alone @@ -138,19 +137,19 @@ export function createAgentComposerDom( input.dataset.composerInitialDraft = options.inputDraft ?? ''; input.spellcheck = true; const sendButtonMount = document.createElement('span'); - sendButtonMount.className = 'agent-thread-card__send-tooltip'; + sendButtonMount.className = 'agent-composer__send-tooltip'; const composerActions = document.createElement('div'); - composerActions.className = 'agent-thread-card__composer-actions'; + composerActions.className = 'agent-composer__actions'; const codexSettingsPopover = document.createElement('div'); codexSettingsPopover.className = 'agent-thread-card__codex-settings-popover'; codexSettingsPopover.setAttribute('role', 'menu'); codexSettingsPopover.hidden = true; const composerRolePopover = document.createElement('div'); - composerRolePopover.className = 'agent-thread-card__composer-note-popover'; + composerRolePopover.className = 'agent-composer__note-popover'; composerRolePopover.setAttribute('role', 'menu'); composerRolePopover.hidden = true; const composerAddPopover = document.createElement('div'); - composerAddPopover.className = 'agent-thread-card__composer-add-popover'; + composerAddPopover.className = 'agent-composer__add-popover'; composerAddPopover.setAttribute('role', 'menu'); composerAddPopover.hidden = true; composerActions.append(composerRoleIcon); @@ -158,7 +157,8 @@ export function createAgentComposerDom( // flex (默认 row, DOM 顺序 = 视觉顺序) 状态下添加按钮显示在 input // 左侧, 跟全屏态 grid 布局 `grid-column: 1` 把 actions 显式钉在第一列 // ── 视觉位置对齐。 send button 仍以 sendButtonMount 收尾, 留在最右。 - composer.append(composerImages, composerActions, input, sendButtonMount); + inputRow.append(input); + composer.append(composerActions, inputRow, sendButtonMount); document.body.append(codexSettingsPopover, composerAddPopover, composerRolePopover); // 空区域点击聚焦 ── 见上方 COMPOSER_FOCUS_INTERACTIVE_SELECTOR 注释。 // 优先使用 pointerdown,避免 click 时浏览器先把焦点移到 body; @@ -168,7 +168,7 @@ export function createAgentComposerDom( composer.addEventListener('click', handleComposerClick); return { composer, - composerImages, + inputRow, composerActions, composerRoleIcon, input, diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.test.ts index 461afc8ac..607f94175 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.test.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.test.ts @@ -155,7 +155,7 @@ describe("ComposerFolderController", () => { const markdown = editor.getMarkdown(); expect(markdown).toContain("flowix://folder/%2FUsers%2Frop%2FDesktop%2Fvibe%2Fflowix-main"); expect(composerFolderMarkdownToPrompt(markdown)).toBe("请操作 @flowix-main "); - expect(document.querySelector(".agent-thread-card__folder-token")?.textContent) + expect(document.querySelector(".agent-composer__folder-token")?.textContent) .toBe("@flowix-main"); controller.dispose(); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.ts index b18940b83..95dfe7de9 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-folder-controller.ts @@ -13,6 +13,7 @@ export interface ComposerFolderReference { } const NOTE_SEARCH_DEBOUNCE_MS = 150; +const MENU_SCROLLBAR_HIDE_DELAY_MS = 700; export interface ComposerFolderControllerOptions { editor: Editor; @@ -49,6 +50,7 @@ export class ComposerFolderController { private notesLoading = false; private notesRequestGeneration = 0; private notesSearchTimer: ReturnType | null = null; + private menuScrollbarHideTimer: ReturnType | null = null; private activeIndex = 0; private isKeyboardNavigation = true; private dismissedValue: string | null = null; @@ -296,12 +298,27 @@ export class ComposerFolderController { this.menu.className = "agent-composer-slash-menu agent-composer-slash-menu--folder"; this.menu.setAttribute("role", "listbox"); this.menu.setAttribute("aria-label", "资料和笔记"); + this.menu.setAttribute("data-flowix-surface", "popover"); + this.menu.addEventListener("scroll", this.handleMenuScroll, { passive: true }); document.body.append(this.menu); } this.renderMenuItems(); this.updateMenuPosition(); } + private readonly handleMenuScroll = (): void => { + const menu = this.menu; + if (!menu) return; + menu.dataset.scrolling = "true"; + if (this.menuScrollbarHideTimer !== null) { + clearTimeout(this.menuScrollbarHideTimer); + } + this.menuScrollbarHideTimer = setTimeout(() => { + if (this.menu === menu) delete menu.dataset.scrolling; + this.menuScrollbarHideTimer = null; + }, MENU_SCROLLBAR_HIDE_DELAY_MS); + }; + private renderMenuItems(): void { const menu = this.menu; if (!menu) return; @@ -439,6 +456,10 @@ export class ComposerFolderController { } private closeMenu(): void { + if (this.menuScrollbarHideTimer !== null) { + clearTimeout(this.menuScrollbarHideTimer); + this.menuScrollbarHideTimer = null; + } this.menu?.remove(); this.menu = null; this.mentionRange = null; diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-folder-data.ts b/app/flowix-web/features/agent/thread-card/composer/composer-folder-data.ts index 5ffa301d8..8153dc584 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-folder-data.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-folder-data.ts @@ -1,38 +1,15 @@ -import { resolveNotebookAgentFiles } from "@/lib/agent-access-defaults"; -import { useAgentAccessStore } from "@features/agent/store/agent-access-store"; +import { getWorkspaceAgentRepositories } from "@features/agent/public/workspace-api"; import { useNoteStore } from "@features/memo/store/note-store"; import type { ComposerFolderReference } from "./composer-folder-controller"; -function comparablePath(path: string): string { - return path.trim().replace(/[\\/]+$/, "").toLowerCase(); -} - -function fallbackFolderName(path: string): string { - const normalized = path.replace(/[\\/]+$/, ""); - return normalized.split(/[\\/]/).pop() || normalized || path; -} - /** Read the folders configured for the notebook that owns the current note. */ export function getCurrentNotebookComposerFolders(): readonly ComposerFolderReference[] { const memoState = useNoteStore.getState(); const notebook = memoState.selectedNotebook; if (!notebook) return []; - const accessState = useAgentAccessStore.getState(); - const configured = resolveNotebookAgentFiles( - accessState.config, - accessState.notebookConfigs, - notebook.id, - ); - const entries = accessState.config.entries; - const entryByPath = new Map( - entries - .filter((entry) => entry.kind === "folder") - .map((entry) => [comparablePath(entry.path), entry]), - ); - - return (configured?.folders ?? []).map((path) => ({ - path, - name: entryByPath.get(comparablePath(path))?.name || fallbackFolderName(path), + return getWorkspaceAgentRepositories(notebook.id).map((repository) => ({ + path: repository.path, + name: repository.name, })); } diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-folder-token.ts b/app/flowix-web/features/agent/thread-card/composer/composer-folder-token.ts index f3a67c2ab..d0d4c2ac9 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-folder-token.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-folder-token.ts @@ -1,4 +1,11 @@ import { Node, type Editor, type JSONContent, type MarkdownToken } from "@tiptap/core"; +import folderOutlineSvg from "@/assets/folder-outline.svg?raw"; +import { + createInlineAtomCloseIcon, + createInlineAtomNodeView, + deleteInlineAtomNode, + deleteAdjacentInlineAtom, +} from "@features/editor/extensions/shared/inline-atom-nodeview"; export interface ComposerFolderTokenValue { path: string; @@ -59,7 +66,7 @@ export const ComposerFolderToken = Node.create({ return ["span", { "data-composer-folder": path, "data-composer-folder-display-name": displayName, - class: "agent-thread-card__folder-token", + class: "agent-composer__folder-token", }, `@${displayName}`]; }, @@ -110,20 +117,38 @@ export const ComposerFolderToken = Node.create({ path, typeof node.attrs.displayName === "string" ? node.attrs.displayName : undefined, ); - const button = document.createElement("button"); - button.type = "button"; - button.className = "agent-thread-card__folder-token"; - button.textContent = `@${displayName}`; - button.title = path; - button.setAttribute("aria-label", `移除资料库 ${displayName}`); - button.addEventListener("mousedown", (event) => event.preventDefault()); - button.addEventListener("click", () => { - const pos = typeof getPos === "function" ? getPos() : undefined; - if (pos === undefined) return; - view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize)); - view.focus(); + const iconTemplate = document.createElement("template"); + iconTemplate.innerHTML = folderOutlineSvg; + const folderIcon = iconTemplate.content.firstElementChild; + folderIcon?.classList.add("agent-composer__folder-token-icon"); + const nodeView = createInlineAtomNodeView({ + caretGeometry: 'single-line', + wrapperClassName: "agent-composer__inline-atom-wrapper agent-composer__folder-token-wrapper", + cardClassName: "agent-composer__folder-token", + leadingIcon: folderIcon ?? undefined, + label: displayName, + labelClassName: "agent-composer__folder-token-label", + title: path, + hoverActions: [{ + ariaLabel: `移除资料库 ${displayName}`, + icon: createInlineAtomCloseIcon(), + onClick: () => deleteInlineAtomNode( + view, + node, + () => typeof getPos === "function" ? getPos() : undefined, + ), + }], }); - return { dom: button }; + nodeView.dom.setAttribute("data-composer-folder", path); + nodeView.dom.setAttribute("data-composer-folder-display-name", displayName); + return nodeView; + }; + }, + + addKeyboardShortcuts() { + return { + Backspace: () => deleteAdjacentInlineAtom(this.editor, "composerFolderToken", "before"), + Delete: () => deleteAdjacentInlineAtom(this.editor, "composerFolderToken", "after"), }; }, }); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-image-controller.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-image-controller.test.ts deleted file mode 100644 index af6d06de9..000000000 --- a/app/flowix-web/features/agent/thread-card/composer/composer-image-controller.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - cacheImage: vi.fn(), - deleteCachedImage: vi.fn(), - readCachedImage: vi.fn(), -})); - -vi.mock("@platform/tauri/client", () => ({ - agent: { - cacheImage: mocks.cacheImage, - deleteCachedImage: mocks.deleteCachedImage, - readCachedImage: mocks.readCachedImage, - }, -})); - -import { ComposerImageController } from "./composer-image-controller"; - -describe("ComposerImageController", () => { - afterEach(() => { - document.body.replaceChildren(); - vi.clearAllMocks(); - }); - - it("caches a pasted image, renders a card, and deletes it on request", async () => { - mocks.cacheImage.mockResolvedValue({ - path: "/tmp/cached.png", - mimeType: "image/png", - name: "cached.png", - }); - mocks.deleteCachedImage.mockResolvedValue(true); - const input = document.createElement("textarea"); - const container = document.createElement("div"); - const composer = document.createElement("div"); - composer.append(container, input); - document.body.append(composer); - const onChange = vi.fn(); - const controller = new ComposerImageController({ - input, - container, - initialImages: [], - onChange, - onStateChange: vi.fn(), - onError: vi.fn(), - onLimitExceeded: vi.fn(), - }); - const file = new File([new Uint8Array([1, 2, 3])], "paste.png", { - type: "image/png", - }); - const paste = new Event("paste", { bubbles: true, cancelable: true }); - Object.defineProperty(paste, "clipboardData", { - value: { - items: [{ kind: "file", type: "image/png", getAsFile: () => file }], - }, - }); - input.dispatchEvent(paste); - - await vi.waitFor(() => { - expect(controller.hasPending).toBe(false); - }); - expect(mocks.cacheImage).toHaveBeenCalled(); - expect(container.querySelector("img")).not.toBeNull(); - expect(onChange).toHaveBeenCalledWith([ - expect.objectContaining({ path: "/tmp/cached.png" }), - ]); - - (container.querySelector("button") as HTMLButtonElement).click(); - expect(container.querySelector("img")).toBeNull(); - expect(mocks.deleteCachedImage).toHaveBeenCalledWith("/tmp/cached.png"); - controller.dispose(); - }); - - it("loads persisted image previews through the guarded cache reader", async () => { - mocks.readCachedImage.mockResolvedValue("data:image/png;base64,AQID"); - const input = document.createElement("textarea"); - const container = document.createElement("div"); - const composer = document.createElement("div"); - composer.append(container, input); - document.body.append(composer); - const controller = new ComposerImageController({ - input, - container, - initialImages: [{ - path: "/tmp/persisted.png", - mimeType: "image/png", - name: "persisted.png", - }], - onChange: vi.fn(), - onStateChange: vi.fn(), - onError: vi.fn(), - onLimitExceeded: vi.fn(), - }); - - await Promise.resolve(); - await Promise.resolve(); - expect(mocks.readCachedImage).toHaveBeenCalledWith("/tmp/persisted.png"); - expect(container.querySelector("img")?.src).toContain( - "data:image/png;base64,AQID", - ); - controller.dispose(); - }); - - it("removes persisted metadata when the cached file is missing", async () => { - mocks.readCachedImage.mockResolvedValue(null); - const input = document.createElement("textarea"); - const container = document.createElement("div"); - const composer = document.createElement("div"); - composer.append(container, input); - const onChange = vi.fn(); - const controller = new ComposerImageController({ - input, - container, - initialImages: [{ - path: "/tmp/missing.png", - mimeType: "image/png", - name: "missing.png", - }], - onChange, - onStateChange: vi.fn(), - onError: vi.fn(), - onLimitExceeded: vi.fn(), - }); - - await Promise.resolve(); - await Promise.resolve(); - expect(controller.hasImages).toBe(false); - expect(onChange).toHaveBeenCalledWith([]); - expect(mocks.deleteCachedImage).not.toHaveBeenCalled(); - controller.dispose(); - }); - - it("accepts at most five images and rejects files larger than 5 MB", async () => { - mocks.cacheImage.mockImplementation(async (_content: string, _type: string) => ({ - path: `/tmp/${mocks.cacheImage.mock.calls.length}.png`, - mimeType: "image/png", - name: "cached.png", - })); - const input = document.createElement("textarea"); - const container = document.createElement("div"); - const composer = document.createElement("div"); - composer.append(container, input); - const onError = vi.fn(); - const onLimitExceeded = vi.fn(); - const controller = new ComposerImageController({ - input, - container, - initialImages: [], - onChange: vi.fn(), - onStateChange: vi.fn(), - onError, - onLimitExceeded, - }); - const smallFiles = Array.from({ length: 6 }, (_, index) => - new File([new Uint8Array([index])], `${index}.png`, { type: "image/png" }), - ); - const oversized = new File( - [new Uint8Array(5 * 1024 * 1024 + 1)], - "large.png", - { type: "image/png" }, - ); - const paste = new Event("paste", { bubbles: true, cancelable: true }); - Object.defineProperty(paste, "clipboardData", { - value: { - items: [...smallFiles, oversized].map((file) => ({ - kind: "file", - type: "image/png", - getAsFile: () => file, - })), - }, - }); - input.dispatchEvent(paste); - - await vi.waitFor(() => { - expect(controller.hasPending).toBe(false); - }); - expect(mocks.cacheImage).toHaveBeenCalledTimes(5); - expect(onLimitExceeded).toHaveBeenCalledWith("size"); - expect(onLimitExceeded).toHaveBeenCalledWith("count"); - expect(onError).not.toHaveBeenCalled(); - controller.dispose(); - }); -}); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-image-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-image-controller.ts deleted file mode 100644 index 61c7ea9e9..000000000 --- a/app/flowix-web/features/agent/thread-card/composer/composer-image-controller.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { agent, type CachedAgentImage } from "@platform/tauri/client"; - -export type AgentThreadCardInputImage = CachedAgentImage; - -const MAX_COMPOSER_IMAGE_BYTES = 5 * 1024 * 1024; -const MAX_COMPOSER_IMAGE_COUNT = 5; - -export const COMPOSER_ATTACHMENT_ACCEPT = [ - "image/*", ".doc", ".docx", ".pdf", ".xls", ".xlsx", ".ppt", ".pptx", -].join(","); - -const SUPPORTED_ATTACHMENT_MIME_TYPES = new Set([ - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/pdf", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.ms-powerpoint", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", -]); - -function isSupportedAttachment(file: File): boolean { - return Boolean(getAttachmentMimeType(file)); -} - -function getAttachmentMimeType(file: File): string | null { - const mimeType = file.type.toLowerCase(); - if (mimeType.startsWith("image/") || SUPPORTED_ATTACHMENT_MIME_TYPES.has(mimeType)) { - return mimeType; - } - const extension = file.name.toLowerCase().split(".").pop(); - const byExtension: Record = { - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - pdf: "application/pdf", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - }; - return (extension && byExtension[extension]) || null; -} - -function isImageAttachment(file: { mimeType: string }): boolean { - return file.mimeType.toLowerCase().startsWith("image/"); -} - -export interface ComposerImageControllerOptions { - input: HTMLElement; - container: HTMLElement; - initialImages: AgentThreadCardInputImage[]; - onChange: (images: AgentThreadCardInputImage[]) => void; - onStateChange: () => void; - onError: (message: string) => void; - onLimitExceeded: (kind: "count" | "size") => void; -} - -function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result ?? "")); - reader.onerror = () => reject(reader.error ?? new Error("Failed to read image")); - reader.readAsDataURL(file); - }); -} - -export class ComposerImageController { - private images: AgentThreadCardInputImage[]; - private readonly previews = new Map(); - private pending = 0; - private disposed = false; - private locked = false; - - constructor(private readonly options: ComposerImageControllerOptions) { - this.images = options.initialImages.slice(0, MAX_COMPOSER_IMAGE_COUNT); - options.input.addEventListener("paste", this.handlePaste); - this.render(); - for (const image of this.images) { - if (isImageAttachment(image)) void this.loadPreview(image); - } - } - - get readyImages(): AgentThreadCardInputImage[] { - return [...this.images]; - } - - get hasImages(): boolean { - return this.images.length > 0; - } - - get hasPending(): boolean { - return this.pending > 0; - } - - /** Add picker-selected attachments through the same cache path as pasted images. */ - addFiles(files: File[]): void { - if (this.locked || this.disposed) return; - const eligibleFiles = files.filter((file) => { - if (!isSupportedAttachment(file)) return false; - if (file.size <= MAX_COMPOSER_IMAGE_BYTES) return true; - this.options.onLimitExceeded("size"); - return false; - }); - const availableSlots = Math.max( - 0, - MAX_COMPOSER_IMAGE_COUNT - this.images.length - this.pending, - ); - if (eligibleFiles.length > availableSlots) { - this.options.onLimitExceeded("count"); - } - for (const file of eligibleFiles.slice(0, availableSlots)) { - void this.cache(file); - } - } - - clearAfterSubmit(): void { - this.images = []; - this.previews.clear(); - this.options.onChange([]); - this.render(); - this.options.onStateChange(); - } - - /** Keep in-flight request files available until the request is accepted. */ - setLocked(locked: boolean): void { - if (this.disposed || this.locked === locked) return; - this.locked = locked; - this.render(); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.options.input.removeEventListener("paste", this.handlePaste); - this.options.container.replaceChildren(); - this.previews.clear(); - } - - private readonly handlePaste = (event: ClipboardEvent): void => { - const files = Array.from(event.clipboardData?.items ?? []) - .filter((item) => item.kind === "file" && item.type.startsWith("image/")) - .map((item) => item.getAsFile()) - .filter((file): file is File => !!file); - if (files.length === 0) return; - - // Clipboard image payloads often also contain a generated filename as text. - // Prevent that text from being inserted; genuine text-only paste stays native. - event.preventDefault(); - this.addFiles(files); - }; - - private async cache(file: File): Promise { - this.pending += 1; - this.options.onStateChange(); - try { - const content = await readFileAsDataUrl(file); - const cached = await agent.cacheImage(content, getAttachmentMimeType(file) ?? file.type); - if (this.disposed) { - void agent.deleteCachedImage(cached.path).catch(() => undefined); - return; - } - this.previews.set(cached.path, content); - this.images = [...this.images, cached]; - this.options.onChange(this.readyImages); - this.render(); - } catch (error) { - this.options.onError(error instanceof Error ? error.message : String(error)); - } finally { - this.pending = Math.max(0, this.pending - 1); - this.options.onStateChange(); - } - } - - private remove( - image: AgentThreadCardInputImage, - deleteCachedFile: boolean = true, - ): void { - if (this.locked && deleteCachedFile) return; - this.images = this.images.filter((candidate) => candidate.path !== image.path); - this.previews.delete(image.path); - this.options.onChange(this.readyImages); - this.render(); - this.options.onStateChange(); - if (deleteCachedFile) { - void agent.deleteCachedImage(image.path).catch(() => undefined); - } - } - - private async loadPreview(image: AgentThreadCardInputImage): Promise { - try { - const content = await agent.readCachedImage(image.path); - if (this.disposed) return; - if (!content) { - this.remove(image, false); - return; - } - if (!this.images.some((candidate) => candidate.path === image.path)) return; - this.previews.set(image.path, content); - this.render(); - } catch (error) { - if (!this.disposed) { - this.options.onError( - error instanceof Error ? error.message : String(error), - ); - } - } - } - - private render(): void { - const cards = this.images.map((image) => { - const card = document.createElement("span"); - card.className = "agent-thread-card__composer-image"; - - const previewUrl = this.previews.get(image.path); - const preview = previewUrl ? document.createElement("img") : null; - if (preview && previewUrl) { - preview.src = previewUrl; - preview.alt = image.name; - preview.draggable = false; - } - - const remove = document.createElement("button"); - remove.type = "button"; - remove.disabled = this.locked; - remove.className = "agent-thread-card__composer-image-remove"; - remove.setAttribute("aria-label", "Remove image"); - remove.textContent = "×"; - remove.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.remove(image); - }); - if (preview) { - card.append(preview); - } else if (!isImageAttachment(image)) { - const name = document.createElement("span"); - name.className = "agent-thread-card__composer-attachment-name"; - name.textContent = image.name; - name.title = image.name; - card.append(name); - } - card.append(remove); - return card; - }); - this.options.container.replaceChildren(...cards); - this.options.container.hidden = cards.length === 0; - this.options.container.parentElement?.classList.toggle( - "agent-thread-card__composer--has-images", - cards.length > 0, - ); - } -} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-inline-atom-caret.ts b/app/flowix-web/features/agent/thread-card/composer/composer-inline-atom-caret.ts new file mode 100644 index 000000000..eabaa0d2a --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-inline-atom-caret.ts @@ -0,0 +1,39 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { DecorationSet } from "@tiptap/pm/view"; +import { createTerminalInlineAtomCaretDecorations } from "@features/editor/extensions/shared/terminal-inline-atom-caret"; +import { COMPOSER_ATTACHMENT_NODE_NAME } from "./composer-attachment-compat"; + +const composerInlineAtomCaretPluginKey = new PluginKey( + "composerInlineAtomCaret", +); + +/** Supplies a terminal caret anchor so browser trailing breaks do not add an + * extra line after composer-only inline chips. */ +export const ComposerInlineAtomCaret = Extension.create({ + name: "composerInlineAtomCaret", + + addProseMirrorPlugins() { + const nodeNames = [ + "composerFolderToken", + "composerSkillToken", + COMPOSER_ATTACHMENT_NODE_NAME, + ]; + + return [new Plugin({ + key: composerInlineAtomCaretPluginKey, + state: { + init: (_, state) => createTerminalInlineAtomCaretDecorations(state.doc, nodeNames), + apply: (transaction, value, _oldState, newState) => ( + transaction.docChanged + ? createTerminalInlineAtomCaretDecorations(newState.doc, nodeNames) + : value + ), + }, + props: { + decorations: (state) => composerInlineAtomCaretPluginKey.getState(state) + ?? DecorationSet.empty, + }, + })]; + }, +}); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-insertion-anchor.ts b/app/flowix-web/features/agent/thread-card/composer/composer-insertion-anchor.ts new file mode 100644 index 000000000..0aef01f1d --- /dev/null +++ b/app/flowix-web/features/agent/thread-card/composer/composer-insertion-anchor.ts @@ -0,0 +1,36 @@ +import type { Editor } from '@tiptap/core'; +import { TextSelection, type Transaction } from '@tiptap/pm/state'; + +export interface ComposerInsertionPoint { from: number; to: number } + +/** A saved editor selection mapped through every transaction while files load. */ +export interface ComposerInsertionAnchor { + resolve(): ComposerInsertionPoint | undefined; + advance(point: ComposerInsertionPoint): void; + dispose(): void; +} + +export function captureComposerInsertionAnchor(editor: Editor): ComposerInsertionAnchor { + let bookmark = editor.state.selection.getBookmark(); + let disposed = false; + const mapBookmark = ({ transaction }: { transaction: Transaction }) => { + bookmark = bookmark.map(transaction.mapping); + }; + editor.on('transaction', mapBookmark); + return { + resolve() { + if (disposed || editor.isDestroyed) return undefined; + const selection = bookmark.resolve(editor.state.doc); + return { from: selection.from, to: selection.to }; + }, + advance(point) { + if (disposed || editor.isDestroyed) return; + bookmark = TextSelection.create(editor.state.doc, point.to).getBookmark(); + }, + dispose() { + if (disposed) return; + disposed = true; + editor.off('transaction', mapBookmark); + }, + }; +} diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-skill-token.ts b/app/flowix-web/features/agent/thread-card/composer/composer-skill-token.ts index 4131395b5..93645ccda 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-skill-token.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-skill-token.ts @@ -1,4 +1,11 @@ import { Node, type Editor, type JSONContent, type MarkdownToken } from "@tiptap/core"; +import skillLibraryIconSvg from "@/assets/skill-library.svg?raw"; +import { + createInlineAtomCloseIcon, + createInlineAtomNodeView, + deleteInlineAtomNode, + deleteAdjacentInlineAtom, +} from "@features/editor/extensions/shared/inline-atom-nodeview"; export interface ComposerSkillTokenValue { name: string; @@ -64,7 +71,7 @@ export const ComposerSkillToken = Node.create({ return ["span", { "data-composer-skill": name, ...(displayName ? { "data-composer-skill-display-name": displayName } : {}), - class: "agent-thread-card__skill-token", + class: "agent-composer__skill-token", }, displayNameForComposerSkill(name, displayName)]; }, @@ -115,20 +122,34 @@ export const ComposerSkillToken = Node.create({ name, typeof node.attrs.displayName === "string" ? node.attrs.displayName : undefined, ); - const button = document.createElement("button"); - button.type = "button"; - button.className = "agent-thread-card__skill-token"; - button.textContent = displayName; - button.title = `$${name}`; - button.setAttribute("aria-label", `移除 Skill ${displayName}`); - button.addEventListener("mousedown", (event) => event.preventDefault()); - button.addEventListener("click", () => { - const pos = typeof getPos === "function" ? getPos() : undefined; - if (pos === undefined) return; - view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize)); - view.focus(); + const iconTemplate = document.createElement("template"); + iconTemplate.innerHTML = skillLibraryIconSvg; + const nodeView = createInlineAtomNodeView({ + caretGeometry: 'single-line', + wrapperClassName: "agent-composer__inline-atom-wrapper agent-composer__skill-token-wrapper", + cardClassName: "agent-composer__skill-token", + leadingIcon: iconTemplate.content.firstElementChild ?? undefined, + label: displayName, + labelClassName: "agent-composer__skill-token-label", + title: `$${name}`, + hoverActions: [{ + ariaLabel: `移除 Skill ${displayName}`, + icon: createInlineAtomCloseIcon(), + onClick: () => deleteInlineAtomNode( + view, + node, + () => typeof getPos === "function" ? getPos() : undefined, + ), + }], }); - return { dom: button }; + return nodeView; + }; + }, + + addKeyboardShortcuts() { + return { + Backspace: () => deleteAdjacentInlineAtom(this.editor, "composerSkillToken", "before"), + Delete: () => deleteAdjacentInlineAtom(this.editor, "composerSkillToken", "after"), }; }, }); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.test.ts index e366559d9..735d003cf 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.test.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.test.ts @@ -1,6 +1,8 @@ import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import { Markdown } from '@tiptap/markdown'; +import { translate } from '@/lib/i18n'; +import { getCurrentAppLanguage } from '@features/preferences/public/runtime-api'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -12,7 +14,7 @@ import { import { ComposerSlashToken } from './composer-slash-token'; function setup(options: { - agentType?: 'deepseek-harness' | 'codex'; + agentType?: 'dsh' | 'codex'; onModelSelect?: () => void; onPermissionSelect?: () => void; onDirectCommand?: (command: ComposerSlashCommand) => void; @@ -21,7 +23,10 @@ function setup(options: { const composer = document.createElement('div'); const input = document.createElement('div'); input.contentEditable = 'true'; - composer.append(input); + const inputRow = document.createElement('div'); + inputRow.className = 'agent-composer__input-row'; + inputRow.append(input); + composer.append(inputRow); document.body.append(composer); vi.spyOn(composer, 'getBoundingClientRect').mockReturnValue({ x: 20, @@ -36,8 +41,7 @@ function setup(options: { }); const editor = new Editor({ // Use the same mount contract as ComposerController. The input itself is - // ProseMirror's root; the slash controller then moves that root into the - // input row without introducing a nested editor. + // ProseMirror's root inside the factory-owned input row. element: { mount: input }, extensions: [StarterKit, Markdown, ComposerSlashToken], content: '', @@ -47,7 +51,7 @@ function setup(options: { input, composer, editor, - agentType: options.agentType ?? 'deepseek-harness', + agentType: options.agentType ?? 'dsh', onModelSelect: options.onModelSelect, onPermissionSelect: options.onPermissionSelect, onDirectCommand: options.onDirectCommand, @@ -73,7 +77,7 @@ describe('ComposerSlashCommandController', () => { type(editor, '/export'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onDirectCommand).toHaveBeenCalledWith(expect.objectContaining({ name: 'export' })); - expect(editor.getMarkdown()).toContain('flowix://slash/deepseek-harness/export'); + expect(editor.getMarkdown()).toContain('flowix://slash/dsh/export'); controller.dispose(); editor.destroy(); }); @@ -99,7 +103,7 @@ describe('ComposerSlashCommandController', () => { const { input, editor, controller } = setup(); const row = input.parentElement; - expect(row?.classList.contains('agent-thread-card__composer-input-row')).toBe(true); + expect(row?.classList.contains('agent-composer__input-row')).toBe(true); input.blur(); row?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, @@ -149,16 +153,16 @@ describe('ComposerSlashCommandController', () => { type(editor, '/goal'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/deepseek-harness/goal) '); - expect(composer.querySelector('.agent-thread-card__slash-token')?.textContent).toBe('/goal'); + expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/dsh/goal) '); + expect(composer.querySelector('.agent-thread-card__slash-token')?.textContent).toBe(translate(getCurrentAppLanguage(), 'editor.threadCard.composer.longTask')); expect(composer.querySelector('.agent-thread-card__slash-token--control')).not.toBeNull(); const wrapper = composer.querySelector('.agent-thread-card__slash-token-wrapper'); - expect(wrapper?.childNodes).toHaveLength(2); - expect(wrapper?.firstChild?.textContent).toBe('/goal'); + expect(wrapper?.childNodes).toHaveLength(3); + expect(wrapper?.firstChild?.textContent).toBe('\u200B'); expect(wrapper?.lastChild?.textContent).toBe('\u200B'); editor.commands.insertContent('继续输入'); - expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/deepseek-harness/goal) 继续输入'); + expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/dsh/goal) 继续输入'); editor.commands.focus('start'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); @@ -198,7 +202,10 @@ describe('ComposerSlashCommandController', () => { const onModelSelect = vi.fn(); const composer = document.createElement('div'); const input = document.createElement('div'); - composer.append(input); + const inputRow = document.createElement('div'); + inputRow.className = 'agent-composer__input-row'; + inputRow.append(input); + composer.append(inputRow); document.body.append(composer); const editor = new Editor({ element: { mount: input }, @@ -282,7 +289,7 @@ describe('ComposerSlashCommandController', () => { )].find((candidate) => candidate.textContent?.includes('/goal')); item?.click(); - expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/deepseek-harness/goal) '); + expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/dsh/goal) '); expect(document.querySelector('.agent-composer-slash-menu')).toBeNull(); controller.dispose(); editor.destroy(); @@ -293,7 +300,7 @@ describe('ComposerSlashCommandController', () => { editor.commands.setContent('[/goal](flowix://slash/goal)', { contentType: 'markdown' }); expect(editor.getMarkdown()).toBe('[/goal](flowix://slash/goal)'); - expect(composer.querySelector('.agent-thread-card__slash-token')?.textContent).toBe('/goal'); + expect(composer.querySelector('.agent-thread-card__slash-token')?.textContent).toBe(translate(getCurrentAppLanguage(), 'editor.threadCard.composer.longTask')); controller.dispose(); editor.destroy(); @@ -302,7 +309,10 @@ describe('ComposerSlashCommandController', () => { it('shows only Codex-supported commands in Codex composers', () => { const composer = document.createElement('div'); const input = document.createElement('div'); - composer.append(input); + const inputRow = document.createElement('div'); + inputRow.className = 'agent-composer__input-row'; + inputRow.append(input); + composer.append(inputRow); document.body.append(composer); const editor = new Editor({ element: { mount: input }, @@ -331,7 +341,10 @@ describe('ComposerSlashCommandController', () => { it('does not open for a composer without an Agent type', () => { const composer = document.createElement('div'); const input = document.createElement('div'); - composer.append(input); + const inputRow = document.createElement('div'); + inputRow.className = 'agent-composer__input-row'; + inputRow.append(input); + composer.append(inputRow); document.body.append(composer); const editor = new Editor({ element: { mount: input }, @@ -364,7 +377,10 @@ describe('ComposerSlashCommandController', () => { // focused on the static-menu path. const nextComposer = document.createElement('div'); const nextInput = document.createElement('div'); - nextComposer.append(nextInput); + const nextRow = document.createElement('div'); + nextRow.className = 'agent-composer__input-row'; + nextRow.append(nextInput); + nextComposer.append(nextRow); document.body.append(nextComposer); const nextEditor = new Editor({ element: { mount: nextInput }, @@ -377,7 +393,7 @@ describe('ComposerSlashCommandController', () => { input: nextInput, composer: nextComposer, editor: nextEditor, - agentType: 'deepseek-harness', + agentType: 'dsh', listDshSkills, }); type(nextEditor, '/'); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.ts index c08b6e26c..1068ff5bd 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-slash-command-controller.ts @@ -11,8 +11,11 @@ import { import type { AgentTypeKey } from "@/types/agent"; import { COMPOSER_SLASH_COMMANDS, type ComposerSlashCommand } from "./composer-commands"; +import { useAgentDataStore } from "@features/agent/store/agent-data-store"; export { COMPOSER_SLASH_COMMANDS, type ComposerSlashCommand } from "./composer-commands"; +const MENU_SCROLLBAR_HIDE_DELAY_MS = 700; + export interface ComposerSlashSkill { name: string; description: string; @@ -51,6 +54,7 @@ export interface ComposerSlashCommandControllerOptions { composer: HTMLElement; commands?: readonly ComposerSlashCommand[]; agentType?: AgentTypeKey; + getSkillsScopeId?: () => string | undefined; listDshSkills?: () => Promise; listCodexSkills?: () => Promise; listPiSkills?: () => Promise; @@ -75,6 +79,7 @@ export class ComposerSlashCommandController { private readonly composer: HTMLElement; private readonly commands: readonly ComposerSlashCommand[]; private readonly agentType: AgentTypeKey | undefined; + private readonly getSkillsScopeId: (() => string | undefined) | undefined; private readonly listDshSkills: (() => Promise) | undefined; private readonly listCodexSkills: (() => Promise) | undefined; private readonly listPiSkills: (() => Promise) | undefined; @@ -95,6 +100,7 @@ export class ComposerSlashCommandController { private dismissedValue: string | null = null; private slashActive = false; private disposed = false; + private menuScrollbarHideTimer: ReturnType | null = null; constructor(options: ComposerSlashCommandControllerOptions) { this.editor = options.editor; @@ -102,6 +108,7 @@ export class ComposerSlashCommandController { this.composer = options.composer; this.commands = options.commands ?? COMPOSER_SLASH_COMMANDS; this.agentType = options.agentType; + this.getSkillsScopeId = options.getSkillsScopeId; this.listDshSkills = options.listDshSkills; this.listCodexSkills = options.listCodexSkills; this.listPiSkills = options.listPiSkills; @@ -115,10 +122,11 @@ export class ComposerSlashCommandController { this.editor.view.focus(); }); - this.inputRow = document.createElement("div"); - this.inputRow.className = "agent-thread-card__composer-input-row"; - this.input.before(this.inputRow); - this.inputRow.append(this.input); + const inputRow = this.input.parentElement; + if (!inputRow?.classList.contains("agent-composer__input-row")) { + throw new Error("Composer DOM must provide its input row before initialization"); + } + this.inputRow = inputRow as HTMLDivElement; this.input.addEventListener("keydown", this.handleKeydown, true); // The row is the actual expanded/fullscreen grid item. It has clickable @@ -151,10 +159,7 @@ export class ComposerSlashCommandController { window.removeEventListener("scroll", this.updateMenuPosition, true); this.closeMenu(); removeComposerSlashToken(this.editor); - if (this.inputRow.isConnected) { - this.inputRow.before(this.input); - this.inputRow.remove(); - } + } refresh(): void { @@ -183,7 +188,7 @@ export class ComposerSlashCommandController { // Refresh once per slash entry, including after a transient failure. // Filtering an open slash must not start another catalog request. - if ((this.agentType === "deepseek-harness" || this.agentType === "pi") && !this.slashActive && + if ((this.agentType === "dsh" || this.agentType === "pi") && !this.slashActive && (this.skillsStatus === "ready" || this.skillsStatus === "error")) { this.skillsStatus = "idle"; } @@ -324,12 +329,27 @@ export class ComposerSlashCommandController { this.menu.className = "agent-composer-slash-menu"; this.menu.setAttribute("role", "listbox"); this.menu.setAttribute("aria-label", "Slash commands"); + this.menu.setAttribute("data-flowix-surface", "popover"); + this.menu.addEventListener("scroll", this.handleMenuScroll, { passive: true }); document.body.append(this.menu); } this.renderMenuItems(); this.updateMenuPosition(); } + private readonly handleMenuScroll = (): void => { + const menu = this.menu; + if (!menu) return; + menu.dataset.scrolling = "true"; + if (this.menuScrollbarHideTimer !== null) { + clearTimeout(this.menuScrollbarHideTimer); + } + this.menuScrollbarHideTimer = setTimeout(() => { + if (this.menu === menu) delete menu.dataset.scrolling; + this.menuScrollbarHideTimer = null; + }, MENU_SCROLLBAR_HIDE_DELAY_MS); + }; + private renderMenuItems(): void { const menu = this.menu; if (!menu) return; @@ -476,17 +496,25 @@ export class ComposerSlashCommandController { this.skillsStatus = "ready"; return; } - this.skillsStatus = "loading"; + const scopeId = this.getSkillsScopeId?.()?.trim() || "global"; + const cached = useAgentDataStore.getState().agents[this.agentType].catalog.skillsByNotebookId[scopeId]; + if (cached) this.skills = cached.items; + this.skillsStatus = cached ? "ready" : "loading"; this.renderMenuItems(); - this.skillsPromise = Promise.resolve().then(() => listSkills()); + this.skillsPromise = useAgentDataStore.getState().loadSkillCatalog( + this.agentType, + scopeId, + async () => [...await listSkills()], + ); try { const skills = await this.skillsPromise; - if (this.disposed) return; + const currentScopeId = this.getSkillsScopeId?.()?.trim() || "global"; + if (this.disposed || currentScopeId !== scopeId) return; this.skills = skills; this.skillsStatus = "ready"; } catch (error) { if (this.disposed) return; - this.skillsStatus = "error"; + this.skillsStatus = this.skills.length ? "ready" : "error"; console.warn(`Failed to load ${this.agentType} skills`, error); } this.activeIndex = Math.min(this.activeIndex, Math.max(0, this.filtered.length - 1)); @@ -498,9 +526,9 @@ export class ComposerSlashCommandController { if (this.agentType === "codex") { insertComposerSkillToken(this.editor, skill.name, skill.displayName); } else if (this.agentType === "pi") { - insertComposerSlashToken(this.editor, `skill:${skill.name}`, "pi"); + insertComposerSlashToken(this.editor, `skill:${skill.name}`, "pi", skill.displayName); } else { - insertComposerSlashToken(this.editor, skill.name, "deepseek-harness"); + insertComposerSlashToken(this.editor, skill.name, "dsh"); } this.onCommandChange(); } @@ -513,6 +541,10 @@ export class ComposerSlashCommandController { } private closeMenu(): void { + if (this.menuScrollbarHideTimer !== null) { + clearTimeout(this.menuScrollbarHideTimer); + this.menuScrollbarHideTimer = null; + } this.menu?.remove(); this.menu = null; } diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-slash-token.ts b/app/flowix-web/features/agent/thread-card/composer/composer-slash-token.ts index bf1a0390f..f85760e7b 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-slash-token.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-slash-token.ts @@ -1,7 +1,19 @@ import { Node, type Editor, type JSONContent, type MarkdownToken } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { NodeSelection, Plugin, type EditorState } from "@tiptap/pm/state"; +import { Plugin, type EditorState } from "@tiptap/pm/state"; +import skillLibraryIconSvg from "@/assets/skill-library.svg?raw"; import type { AgentTypeKey } from "@/types/agent"; +import { loadLanguage, translate } from "@/lib/i18n"; +import { + getCurrentAppLanguage, + subscribeAppLanguage, +} from "@features/preferences/public/runtime-api"; +import { + createInlineAtomCloseIcon, + createInlineAtomNodeView, + deleteInlineAtomNode, + deleteAdjacentInlineAtom, +} from "@features/editor/extensions/shared/inline-atom-nodeview"; import { createTerminalInlineAtomCaretDecorations } from "@features/editor/extensions/shared/terminal-inline-atom-caret"; export interface ComposerSlashTokenOptions { @@ -13,8 +25,8 @@ export interface ComposerSlashTokenValue { agentType?: AgentTypeKey; } -const SLASH_TOKEN_RE = /^\[\/([a-z0-9._:-]+)\]\(flowix:\/\/slash\/(?:(deepseek-harness|codex|pi)\/)?([a-z0-9._:-]+)\)/i; -const SLASH_TOKEN_GLOBAL_RE = /\[\/([a-z0-9._:-]+)\]\(flowix:\/\/slash\/(?:(deepseek-harness|codex|pi)\/)?([a-z0-9._:-]+)\)/gi; +const SLASH_TOKEN_RE = /^\[((?:\\.|[^\]\n])*)\]\(flowix:\/\/slash\/(?:(deepseek-harness|codex|pi)\/)?([a-z0-9._:-]+)\)/i; +const SLASH_TOKEN_GLOBAL_RE = /\[((?:\\.|[^\]\n])*)\]\(flowix:\/\/slash\/(?:(deepseek-harness|codex|pi)\/)?([a-z0-9._:-]+)\)/gi; const LEGACY_DSH_COMMANDS = new Set([ "compact", "skill", @@ -30,12 +42,58 @@ function isControlCommand(command: string): boolean { return CONTROL_COMMANDS.has(command.trim().toLowerCase()); } +function isPiSkillCommand(command: string, agentType: string): boolean { + return agentType === "pi" && command.toLowerCase().startsWith("skill:"); +} + +function piSkillName(command: string): string { + return command.slice("skill:".length); +} + +function escapeSlashTokenLabel(label: string): string { + return label.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]"); +} + +function unescapeSlashTokenLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, "$1"); +} + +function findSlashTokenStart(src: string, hrefIndex: number): number { + let start = src.lastIndexOf("[", hrefIndex); + while (start >= 0) { + let escapeCount = 0; + for (let index = start - 1; index >= 0 && src[index] === "\\"; index -= 1) { + escapeCount += 1; + } + if (escapeCount % 2 === 0) return start; + start = src.lastIndexOf("[", start - 1); + } + return -1; +} + function slashTokenClass(command: string): string { return isControlCommand(command) ? "agent-thread-card__slash-token agent-thread-card__slash-token--control" : "agent-thread-card__slash-token"; } +function slashTokenLabel(command: string): string { + const normalized = command.trim().toLowerCase(); + if (normalized === "plan") { + return translate(getCurrentAppLanguage(), "editor.threadCard.composer.planMode"); + } + if (normalized === "goal") { + return translate(getCurrentAppLanguage(), "editor.threadCard.composer.longTask"); + } + return `/${command}`; +} + +function createSkillLibraryIcon(): SVGSVGElement { + const template = document.createElement("template"); + template.innerHTML = skillLibraryIconSvg; + return template.content.firstElementChild as SVGSVGElement; +} + export const ComposerSlashToken = Node.create({ name: "composerSlashToken", priority: 1000, @@ -49,6 +107,7 @@ export const ComposerSlashToken = Node.create({ return { command: { default: "" }, agentType: { default: null }, + displayName: { default: null }, }; }, @@ -56,6 +115,7 @@ export const ComposerSlashToken = Node.create({ return [{ tag: "span[data-composer-slash]", getAttrs: (dom) => ({ command: (dom as HTMLElement).getAttribute("data-composer-slash") ?? "", agentType: (dom as HTMLElement).getAttribute("data-composer-slash-agent") || null, + displayName: (dom as HTMLElement).getAttribute("data-composer-slash-display-name") || null, }) }]; }, @@ -65,6 +125,9 @@ export const ComposerSlashToken = Node.create({ return ["span", { "data-composer-slash": command, ...(agentType ? { "data-composer-slash-agent": agentType } : {}), + ...(node.attrs?.displayName + ? { "data-composer-slash-display-name": String(node.attrs.displayName) } + : {}), class: "agent-thread-card__slash-token-wrapper", }, ["button", { class: slashTokenClass(command), @@ -76,8 +139,8 @@ export const ComposerSlashToken = Node.create({ name: "composerSlashToken", level: "inline" as const, start(src: string) { - const index = src.indexOf("flowix://slash/"); - return index >= 0 ? index : -1; + const hrefIndex = src.indexOf("flowix://slash/"); + return hrefIndex >= 0 ? findSlashTokenStart(src, hrefIndex) : -1; }, tokenize(src: string) { const match = SLASH_TOKEN_RE.exec(src); @@ -85,7 +148,7 @@ export const ComposerSlashToken = Node.create({ type: "composerSlashToken", raw: match[0], href: `flowix://slash/${match[2] ? `${match[2]}/` : ""}${match[3]}`, - text: `/${match[1]}`, + text: match[1], } : undefined; }, }, @@ -94,60 +157,82 @@ export const ComposerSlashToken = Node.create({ const href = String(token.href ?? ""); const path = href.replace(/^flowix:\/\/slash\//i, ""); const parts = path.split("/"); - const agentType = parts[0] === "deepseek-harness" || parts[0] === "codex" || parts[0] === "pi" + const agentType = parts[0] === "dsh" || parts[0] === "codex" || parts[0] === "pi" ? parts[0] : undefined; const command = (agentType ? parts[1] : parts[0]) || String(token.text ?? "").replace(/^\//, ""); - return { type: "composerSlashToken", attrs: { command, agentType } }; + const label = unescapeSlashTokenLabel(String(token.text ?? "").trim()); + const isPiSkill = agentType === "pi" && command.toLowerCase().startsWith("skill:"); + const displayName = isPiSkill && label !== `/${command}` ? label || null : null; + return { type: "composerSlashToken", attrs: { command, agentType, displayName } }; }, renderMarkdown(node: JSONContent) { const command = String(node.attrs?.command ?? ""); const agentType = String(node.attrs?.agentType ?? ""); - return `[/${command}](flowix://slash/${agentType ? `${agentType}/` : ""}${command})`; + const displayName = String(node.attrs?.displayName ?? "").trim(); + const label = isPiSkillCommand(command, agentType) + ? displayName || piSkillName(command) + : `/${command}`; + return `[${escapeSlashTokenLabel(label)}](flowix://slash/${agentType ? `${agentType}/` : ""}${command})`; }, addNodeView() { return ({ node, view, getPos }) => { - // Keep the atom's visual card separate from its terminal caret landing - // point. The trailing text node is deliberately real: a span boundary - // is not a reliable caret anchor in Chromium/WebKit. There is no - // leading spacer because it makes the native caret render inside the - // chip, unlike the other composer reference cards. - const wrapper = document.createElement("span"); - wrapper.className = "agent-thread-card__slash-token-wrapper"; - wrapper.contentEditable = "false"; - wrapper.setAttribute("data-composer-slash", String(node.attrs.command ?? "")); - const agentType = String(node.attrs.agentType ?? ""); - if (agentType) wrapper.setAttribute("data-composer-slash-agent", agentType); - - const button = document.createElement("button"); const command = String(node.attrs.command ?? ""); - button.type = "button"; - button.className = slashTokenClass(command); - button.textContent = `/${command}`; - button.title = "点击移除命令"; - button.setAttribute("aria-label", `移除 /${command} 命令`); - button.addEventListener("mousedown", (event) => event.preventDefault()); - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - const pos = typeof getPos === "function" ? getPos() : undefined; - if (pos === undefined) return; - view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize)); - view.focus(); + const agentType = String(node.attrs.agentType ?? ""); + const displayName = String(node.attrs.displayName ?? "").trim(); + const piSkill = isPiSkillCommand(command, agentType); + const skillLabel = displayName || piSkillName(command); + const nodeView = createInlineAtomNodeView({ + caretGeometry: 'single-line', + wrapperClassName: "agent-composer__inline-atom-wrapper agent-thread-card__slash-token-wrapper", + cardClassName: piSkill + ? "agent-composer__skill-token" + : slashTokenClass(command), + ...(piSkill + ? { + leadingIcon: createSkillLibraryIcon(), + labelClassName: "agent-composer__skill-token-label", + } + : {}), + label: piSkill ? skillLabel : slashTokenLabel(command), + title: `/${command}`, + ariaLabel: piSkill ? `Skill ${skillLabel}` : `/${command} 命令`, + hoverActions: [{ + ariaLabel: piSkill ? `移除 Skill ${skillLabel}` : `移除 /${command} 命令`, + icon: createInlineAtomCloseIcon(), + onClick: () => deleteInlineAtomNode( + view, + node, + () => typeof getPos === "function" ? getPos() : undefined, + ), + }], + stopEvent: (event) => !event.type.startsWith("composition"), }); - - wrapper.append(button, document.createTextNode("\u200B")); + const wrapper = nodeView.dom; + const button = wrapper.querySelector(".agent-thread-card__slash-token"); + const updateLocalizedLabel = () => { + if (button) button.textContent = slashTokenLabel(command); + }; + const refreshLabelForCurrentLanguage = () => { + void loadLanguage(getCurrentAppLanguage()).then(updateLocalizedLabel, updateLocalizedLabel); + }; + const unsubscribeLanguage = piSkill + ? () => undefined + : (() => { + refreshLabelForCurrentLanguage(); + return subscribeAppLanguage(refreshLabelForCurrentLanguage); + })(); + wrapper.setAttribute("data-composer-slash", String(node.attrs.command ?? "")); + if (agentType) wrapper.setAttribute("data-composer-slash-agent", agentType); + if (displayName) wrapper.setAttribute("data-composer-slash-display-name", displayName); return { - dom: wrapper, + ...nodeView, contentDOM: null, - selectNode: () => wrapper.classList.add("is-selected"), - deselectNode: () => wrapper.classList.remove("is-selected"), - stopEvent: (event: Event) => !event.type.startsWith("composition"), - ignoreMutation: () => true, + destroy: unsubscribeLanguage, }; }; }, @@ -176,8 +261,8 @@ export const ComposerSlashToken = Node.create({ addKeyboardShortcuts() { return { - Backspace: () => deleteAdjacentComposerSlashToken(this.editor, "before"), - Delete: () => deleteAdjacentComposerSlashToken(this.editor, "after"), + Backspace: () => deleteAdjacentInlineAtom(this.editor, "composerSlashToken", "before"), + Delete: () => deleteAdjacentInlineAtom(this.editor, "composerSlashToken", "after"), }; }, }); @@ -215,25 +300,6 @@ function removeHardBreaksAroundComposerSlashTokens(state: EditorState) { return tr; } -function deleteAdjacentComposerSlashToken( - editor: Editor, - direction: "before" | "after", -): boolean { - const { selection } = editor.state; - if (selection instanceof NodeSelection && selection.node.type.name === "composerSlashToken") { - editor.commands.deleteSelection(); - return true; - } - - const { $from } = selection; - const node = direction === "before" ? $from.nodeBefore : $from.nodeAfter; - if (!node || node.type.name !== "composerSlashToken") return false; - - const from = direction === "before" ? $from.pos - node.nodeSize : $from.pos; - editor.commands.deleteRange({ from, to: from + node.nodeSize }); - return true; -} - export function getComposerSlashToken(editor: Editor): ComposerSlashTokenValue | null { let value: ComposerSlashTokenValue | null = null; editor.state.doc.descendants((node) => { @@ -254,13 +320,14 @@ export function insertComposerSlashToken( editor: Editor, command: string, agentType?: AgentTypeKey, + displayName?: string, ): void { const { selection } = editor.state; editor.chain().focus().deleteRange({ from: 1, to: selection.to }) .insertContent([ { type: "composerSlashToken", - attrs: { command, agentType: agentType ?? null }, + attrs: { command, agentType: agentType ?? null, displayName: displayName ?? null }, }, { type: "text", text: " " }, ]).run(); @@ -270,12 +337,12 @@ export function insertComposerSlashToken( export function composerSlashMarkdownToPrompt(markdown: string): string { return markdown.replace( SLASH_TOKEN_GLOBAL_RE, - (match, command: string, scopedAgent: string | undefined, _encoded: string, offset: number, source: string) => { + (match, _label: string, scopedAgent: string | undefined, command: string, offset: number, source: string) => { // Unscoped chips are legacy data. Preserve old DSH drafts while keeping // the product-only permission chips UI-only as before. if ( scopedAgent && - scopedAgent.toLowerCase() !== "deepseek-harness" && + scopedAgent.toLowerCase() !== "dsh" && scopedAgent.toLowerCase() !== "codex" && scopedAgent.toLowerCase() !== "pi" || (!scopedAgent && !LEGACY_DSH_COMMANDS.has(command.toLowerCase())) diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.test.ts index cb8b8bfb6..2d34475cd 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.test.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.test.ts @@ -25,8 +25,8 @@ function setup() { resetHistoryNavigation: vi.fn(), updateMultiLineState: vi.fn(), }; const images = { - hasImages: true, hasPending: false, - readyImages: [{ path: "/tmp/file.png", mimeType: "image/png", name: "file.png" }], + hasAttachments: true, hasPending: false, + readyAttachments: [{ path: "/tmp/file.png", mimeType: "image/png", name: "file.png" }], setLocked: vi.fn(), clearAfterSubmit: vi.fn(), }; const phases: string[] = []; @@ -126,8 +126,9 @@ describe("composer submission transaction", () => { gate.resolve(); await first; expect(composer.getDraftMarkdown()).toBe("a new follow-up"); - expect(composer.clear).not.toHaveBeenCalled(); - expect(options.clearPersistedDraft).not.toHaveBeenCalled(); + // The submitted draft clears at dispatch; acceptance must not clear the new draft again. + expect(composer.clear).toHaveBeenCalledOnce(); + expect(options.clearPersistedDraft).toHaveBeenCalledOnce(); }); it("rechecks a command/stop that appeared during binding", async () => { diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.ts b/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.ts index 79203efc6..2af5a80ca 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-submission-controller.ts @@ -1,5 +1,6 @@ import type { ComposerController } from "./composer-controller"; -import type { ComposerImageController, AgentThreadCardInputImage } from "./composer-image-controller"; +import type { ComposerAttachmentController } from "./composer-attachment-controller"; +import type { AgentThreadCardInputAttachment } from "./composer-attachment-types"; import { selectComposerSubmissionState, type ComposerRuntimeState, @@ -17,7 +18,7 @@ export type ComposerSubmissionPhase = export interface ComposerSubmissionSnapshot { markdown: string; prompt: string; - images: AgentThreadCardInputImage[]; + attachments: AgentThreadCardInputAttachment[]; } type SubmissionComposer = Pick< @@ -60,8 +61,8 @@ export class ComposerSubmissionController { async submit(options: { composer: SubmissionComposer; images: Pick< - ComposerImageController, - "hasImages" | "hasPending" | "readyImages" | "setLocked" | "clearAfterSubmit" + ComposerAttachmentController, + "hasAttachments" | "hasPending" | "readyAttachments" | "setLocked" | "clearAfterSubmit" >; getRuntime: () => ComposerRuntimeState; isDisposed: () => boolean; @@ -80,7 +81,7 @@ export class ComposerSubmissionController { ...options.getRuntime(), isSubmitting: this.isSubmitting, prompt: composer.getPrompt(), - hasAttachments: images.hasImages, + hasAttachments: images.hasAttachments, hasPendingAttachments: images.hasPending, }); if (options.isDisposed() || !state.canSubmit) return; @@ -88,7 +89,7 @@ export class ComposerSubmissionController { const snapshot: ComposerSubmissionSnapshot = { markdown: composer.getDraftMarkdown(), prompt: composer.getPrompt().trim() || "Analyze the attached image(s).", - images: images.readyImages.map((image) => ({ ...image })), + attachments: images.readyAttachments.map((attachment) => ({ ...attachment })), }; this.snapshot = snapshot; const transition = (phase: ComposerSubmissionPhase): void => { @@ -128,7 +129,7 @@ export class ComposerSubmissionController { ...options.getRuntime(), isSubmitting: false, prompt: snapshot.prompt, - hasAttachments: snapshot.images.length > 0, + hasAttachments: snapshot.attachments.length > 0, hasPendingAttachments: false, kind: state.kind, }).canSubmit) { diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.test.ts b/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.test.ts index e9392274f..887946de2 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.test.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.test.ts @@ -9,11 +9,11 @@ const idle = { describe("composer submission policy", () => { it("uses one command catalog for typed prompts and menu commands", () => { - expect(getComposerSubmissionKind("deepseek-harness", "/export")).toBe("command"); - expect(getComposerSubmissionKind("deepseek-harness", "/compact")).toBe("command"); + expect(getComposerSubmissionKind("dsh", "/export")).toBe("command"); + expect(getComposerSubmissionKind("dsh", "/compact")).toBe("command"); expect(getComposerSubmissionKind("codex", "/goal set ship it")).toBe("command"); expect(getComposerSubmissionKind("codex", "/goalkeeper")).toBe("message"); - expect(getComposerSubmissionKind("claude", "/goal")).toBe("message"); + expect(getComposerSubmissionKind("claudecode", "/goal")).toBe("message"); expect(getComposerSubmissionKind("codex", "$browser:review")).toBe("message"); }); @@ -37,13 +37,13 @@ describe("composer submission policy", () => { .toMatchObject({ action, disabled, canSubmit }); }); - it.each(["codex", "deepseek-harness", "pi"] as const)("allows %s messages to queue, but not native commands", (agentType) => { + it.each(["codex", "dsh", "pi"] as const)("allows %s messages to queue, but not native commands", (agentType) => { expect(selectComposerSubmissionState({ ...idle, agentType, isModelRunning: true }).canSubmit).toBe(true); expect(selectComposerSubmissionState({ ...idle, agentType, isModelRunning: true, kind: "command" }).canSubmit).toBe(false); }); it("keeps Stop available for agents that cannot queue without permitting Enter to send", () => { - expect(selectComposerSubmissionState({ ...idle, agentType: "claude", isModelRunning: true })) + expect(selectComposerSubmissionState({ ...idle, agentType: "claudecode", isModelRunning: true })) .toMatchObject({ action: "stop", disabled: false, canSubmit: false }); }); }); diff --git a/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.ts b/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.ts index 55d42e82a..b30bada14 100644 --- a/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.ts +++ b/app/flowix-web/features/agent/thread-card/composer/composer-submission-state.ts @@ -40,7 +40,7 @@ export function selectComposerSubmissionState(input: ComposerRuntimeState & { }): ComposerSubmissionState { const kind = input.kind ?? getComposerSubmissionKind(input.agentType, input.prompt); const hasInput = !!input.prompt.trim() || input.hasAttachments; - const supportsQueue = ["codex", "deepseek-harness", "pi"].includes(input.agentType); + const supportsQueue = ["codex", "dsh", "pi"].includes(input.agentType); // Codex's native command endpoint has no attachment payload. const supportsAttachments = kind !== "command" || input.agentType !== "codex" || !input.hasAttachments; const canSubmit = input.isAvailable && hasInput && diff --git a/app/flowix-web/features/agent/thread-card/composer/index.ts b/app/flowix-web/features/agent/thread-card/composer/index.ts index 6788c3f37..374109ae7 100644 --- a/app/flowix-web/features/agent/thread-card/composer/index.ts +++ b/app/flowix-web/features/agent/thread-card/composer/index.ts @@ -9,10 +9,16 @@ export { } from "./composer-draft-controller"; export { getPersistableInputDraft } from "./composer-draft"; export { - ComposerImageController, - type AgentThreadCardInputImage, - type ComposerImageControllerOptions, -} from "./composer-image-controller"; + ComposerAttachmentController, + type AgentThreadCardInputAttachment, + type ComposerAttachmentControllerOptions, +} from "./composer-attachment-controller"; +export { + ComposerAttachmentToken, + composerAttachmentMarkdownToPrompt, +} from "./composer-attachment-token"; +export { ComposerAttachmentDocument, type ComposerAttachmentDocumentPort } from "./composer-attachment-document"; +export { insertComposerAttachmentToken } from "./composer-attachment-document"; export { ComposerAddMenuController, type ComposerAddMenuControllerOptions, diff --git a/app/flowix-web/features/agent/thread-card/composer/send-button-renderer.tsx b/app/flowix-web/features/agent/thread-card/composer/send-button-renderer.tsx index 4431c68b0..6b9dfd68e 100644 --- a/app/flowix-web/features/agent/thread-card/composer/send-button-renderer.tsx +++ b/app/flowix-web/features/agent/thread-card/composer/send-button-renderer.tsx @@ -21,9 +21,9 @@ export function renderAgentThreadCardSendButton( // "flushSync was called from inside a lifecycle method" 报错。root.render // 只负责调度更新, DOM 提交可能发生在随后的事件循环。 const className = [ - "agent-thread-card__send", - options.wantStop && "agent-thread-card__send--stop", - options.isRunning && "agent-thread-card__send--running", + "agent-composer__send", + options.wantStop && "agent-composer__send--stop", + options.isRunning && "agent-composer__send--running", ].filter(Boolean).join(" "); options.root.render( @@ -43,7 +43,7 @@ export function renderAgentThreadCardSendButton( }} > {options.isRunning ? ( -
{error && } @@ -417,7 +421,7 @@ function AgentTabs({ active, onChange, ariaLabel, keys = AGENT_RESOURCE_KEYS }: : "text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]", )} > - {agentKey === "codex" ? "Codex" : agentKey === "deepseek-harness" ? "DeepSeek Harness" : "通用"} + {agentKey === "codex" ? "Codex" : agentKey === "dsh" ? "DeepSeek Harness" : "通用"} ); })} @@ -575,40 +579,71 @@ function CodexSkillGroup({ title, items }: { title: string; items: readonly Code ); } +const EMPTY_CODEX_SKILLS: readonly CodexSkillCatalogItem[] = []; + +function normalizeNotebookPath(path: string): string { + return path.trim().replace(/[\\/]+$/, "").toLowerCase(); +} + +function getNotebookSkillScopeId(notebookPath: string): string { + const normalizedPath = normalizeNotebookPath(notebookPath); + const notebookId = useNoteStore.getState().notebooks.find( + (notebook) => normalizeNotebookPath(notebook.path) === normalizedPath, + )?.id; + const workspaceNotebookId = Object.values(useAgentWorkspaceStore.getState().workspace.byNotebookId).find( + (notebook) => normalizeNotebookPath(notebook.notebook.path) === normalizedPath, + )?.notebook.id; + return notebookId ?? workspaceNotebookId ?? normalizedPath; +} + function CodexSkillsList({ notebookPath }: { notebookPath: string }) { + const normalizedPath = normalizeNotebookPath(notebookPath); + const notebookId = useNoteStore((state) => + state.notebooks.find((notebook) => normalizeNotebookPath(notebook.path) === normalizedPath)?.id ?? null, + ); + const workspaceNotebookId = useAgentWorkspaceStore((state) => + Object.values(state.workspace.byNotebookId).find( + (notebook) => normalizeNotebookPath(notebook.notebook.path) === normalizedPath, + )?.notebook.id ?? null, + ); + const scopeId = notebookId ?? workspaceNotebookId ?? normalizedPath; + const catalog = useAgentDataStore((state) => + scopeId ? state.agents.codex.catalog.skillsByNotebookId[scopeId] : undefined, + ); + const skills = (catalog?.items ?? EMPTY_CODEX_SKILLS) as readonly CodexSkillCatalogItem[]; const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); - const [skills, setSkills] = useState([]); const [error, setError] = useState(null); useEffect(() => { let active = true; - setStatus("loading"); - setSkills([]); + const cached = useAgentDataStore.getState().agents.codex.catalog.skillsByNotebookId[scopeId]; + setStatus(cached ? "ready" : "loading"); setError(null); - void (async () => { - try { - const capabilities = await agent.getCodexProjectCapabilities(notebookPath); - if (!capabilities.skills.ok) { - throw new Error(capabilities.skills.error ?? "Codex Skill 加载失败"); - } + void loadNotebookSkillCatalog("codex", scopeId, notebookPath) + .then(() => { if (!active) return; - setSkills(parseCodexSkillCatalog(capabilities.skills.value)); setStatus("ready"); - } catch (cause) { + }) + .catch((cause) => { if (!active) return; + const latest = useAgentDataStore.getState().agents.codex.catalog.skillsByNotebookId[scopeId]; + if (latest) { + setStatus("ready"); + setError(cause instanceof Error ? cause.message : String(cause)); + return; + } setError(cause instanceof Error ? cause.message : String(cause)); setStatus("error"); - } - })(); + }); return () => { active = false; }; - }, [notebookPath]); + }, [notebookPath, scopeId]); - if (status === "loading") { + if (status === "loading" && !catalog) { return
加载中…
; } - if (status === "error") { + if (status === "error" && !catalog) { return
暂时无法读取 Codex Skill 列表
; } if (!skills.length) { @@ -1077,6 +1112,15 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: setWorkspace(next); setOriginal(cloneWorkspace(next)); closeEditor(); + const skillScopeId = getNotebookSkillScopeId(notebookPath); + if (skillScopeId) { + for (const agentType of ["codex", "pi"] as const) { + invalidateNotebookSkills(agentType, skillScopeId); + void loadNotebookSkillCatalog(agentType, skillScopeId, notebookPath, { force: true }).catch((cause) => { + console.warn(`Failed to refresh ${agentType} skills after saving notebook Agent settings`, cause); + }); + } + } } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -1145,7 +1189,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: {tab === "mcp" && <> openMcp(null)} disabled={!!editor}>添加 : null} /> { setMcpAgent(agentKey); closeEditor(); }} /> - {mcpAgent === "codex" ? : mcpAgent === "deepseek-harness" ? : <> + {mcpAgent === "codex" ? : mcpAgent === "dsh" ? : <> {editor?.kind === "mcp" && mcpForm && setMcpForm((current) => current ? { ...current, ...patch } : current)} onCancel={closeEditor} onSave={saveMcpDraft} error={formError} saving={false} />} openMcp(null)} onEdit={openMcp} onDelete={deleteMcp} onToggle={toggleMcp} /> } @@ -1153,7 +1197,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: {tab === "skills" && <> openFile("skill", null)} disabled={!!editor}>创建 : null} /> { setSkillAgent(agentKey); closeEditor(); }} /> - {skillAgent === "codex" ? : skillAgent === "deepseek-harness" ? :
+ {skillAgent === "codex" ? : skillAgent === "dsh" ? :
{editor?.kind === "file" && editor.fileKind === "skill" && fileForm && setFileForm((current) => current ? { ...current, ...patch } : current)} onCancel={closeEditor} onSave={saveFileDraft} error={formError} />} openFile("skill", null)} onEdit={(id) => openFile("skill", id)} onDelete={(id) => deleteFile("skill", id)} onToggle={(id, enabled) => toggleFile("skill", id, enabled)} />
} @@ -1161,7 +1205,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: {tab === "agents" && <> openFile("agent", null)} disabled={!!editor}>创建 : null} /> { setChildAgent(agentKey); closeEditor(); }} /> - {childAgent === "codex" ? : childAgent === "deepseek-harness" ? :
+ {childAgent === "codex" ? : childAgent === "dsh" ? :
{editor?.kind === "file" && editor.fileKind === "agent" && fileForm && setFileForm((current) => current ? { ...current, ...patch } : current)} onCancel={closeEditor} onSave={saveFileDraft} error={formError} />} openFile("agent", null)} onEdit={(id) => openFile("agent", id)} onDelete={(id) => deleteFile("agent", id)} onToggle={(id, enabled) => toggleFile("agent", id, enabled)} />
} @@ -1187,7 +1231,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath:
{dirty ? 有未保存更改 : }
-
+
diff --git a/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts b/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts index 52a7afb1d..e3019a0ab 100644 --- a/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts +++ b/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts @@ -44,7 +44,7 @@ export interface AgentThreadCardDomParts { loadingIndicator: HTMLDivElement; queuedMessages: HTMLDivElement; composer: HTMLElement; - composerImages: HTMLDivElement; + inputRow: HTMLDivElement; composerActions: HTMLDivElement; composerRoleIcon: HTMLButtonElement; composerAddPopover: HTMLDivElement; @@ -58,7 +58,7 @@ export function createAgentThreadCardDom( options: AgentThreadCardDomFactoryOptions, ): AgentThreadCardDomParts { const dom = document.createElement("section"); - dom.className = "agent-thread-card"; + dom.className = "agent-thread-card agent-conversation-surface"; dom.contentEditable = "false"; dom.tabIndex = -1; dom.dataset.agentThreadCard = "true"; @@ -166,7 +166,9 @@ export function createAgentThreadCardDom( body.dataset.noContextMenuScroll = ""; body.addEventListener("click", options.onBodyClick); body.addEventListener("scroll", options.onBodyScroll, { passive: true }); - body.addEventListener("wheel", options.onBodyWheel, { passive: true }); + // Inline cards may route wheel input to the document until the message + // viewport is explicitly activated, so this listener must be cancellable. + body.addEventListener("wheel", options.onBodyWheel, { passive: false }); const disposeBodyScrollbar = () => bodyScrollbar?.destroy(); const loadingIndicator = document.createElement("div"); @@ -203,7 +205,7 @@ export function createAgentThreadCardDom( }); const { composer, - composerImages, + inputRow, composerActions, composerRoleIcon, composerAddPopover, @@ -263,7 +265,7 @@ export function createAgentThreadCardDom( loadingIndicator, queuedMessages, composer, - composerImages, + inputRow, composerActions, composerRoleIcon, composerAddPopover, diff --git a/app/flowix-web/features/agent/tool-display.test.ts b/app/flowix-web/features/agent/tool-display.test.ts index d5b2f67c4..b940fe674 100644 --- a/app/flowix-web/features/agent/tool-display.test.ts +++ b/app/flowix-web/features/agent/tool-display.test.ts @@ -157,7 +157,7 @@ describe("Codex protocol tool summaries", () => { describe("DeepSeek Harness (DSH) tool displays", () => { const dsh = (toolName: string, input: unknown) => - createAgentToolDisplay({ agentType: "deepseek-harness", toolName, input }); + createAgentToolDisplay({ agentType: "dsh", toolName, input }); it.each([ ["read", { file_path: "/tmp/a/main.rs" }, "main.rs", "file"], @@ -407,7 +407,7 @@ describe("parseAgentCommandInput", () => { it("normalizes Flowix shell command tools to command display", () => { expect( createAgentToolDisplay({ - agentType: "deepseek-harness", + agentType: "dsh", toolName: "shell", input: { command: "npm run build" }, }), @@ -420,7 +420,7 @@ describe("parseAgentCommandInput", () => { it("normalizes Claude Code Bash command tools to command display", () => { expect( createAgentToolDisplay({ - agentType: "claude", + agentType: "claudecode", toolName: "Bash", input: { command: "npm test", description: "run tests" }, }), diff --git a/app/flowix-web/features/agent/use-cases/new-conversation.ts b/app/flowix-web/features/agent/use-cases/new-conversation.ts new file mode 100644 index 000000000..19ef14503 --- /dev/null +++ b/app/flowix-web/features/agent/use-cases/new-conversation.ts @@ -0,0 +1,54 @@ +import { buildInitialInstanceRuntimeConfig } from '@features/agent/store/initial-runtime-config'; +import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; +import { useNoteStore } from '@features/memo/store/note-store'; +import { isAgentTypeEnabled } from '@features/preferences/public/runtime-api'; +import { + getNewConversationAgentSelection, + type NewConversationAgentSelection, +} from '@features/agent/runtime/new-conversation-agent-selection'; +import type { AgentTypeKey } from '@/types/agent'; + +/** Refresh status in the background and decide from the latest known snapshot. */ +export function requestNewConversationAgentSelection(): NewConversationAgentSelection { + const runtime = useAgentRuntimeStore.getState(); + void runtime.refreshIfStale(); + return getNewConversationAgentSelection( + runtime.statusByType, + runtime.isChecking, + isAgentTypeEnabled, + ); +} + +function createAgentConversationInstance(typeKey: AgentTypeKey, notebookId: string | null) { + return useAgentSessionStore.getState().createInstance({ + agentType: typeKey, + title: '', + threadId: null, + source: { kind: 'dedicated', notebookId, documentPath: null }, + runtimeConfig: buildInitialInstanceRuntimeConfig(typeKey), + }); +} + +function openAgentConversation(instanceId: string): void { + void openWorkspaceSurface({ + target: { kind: 'agent-conversation', instanceId, blankIfMissing: true }, + }); +} + +/** Create a conversation instance for the browser column's host-specific navigation. */ +export function createBrowserColumnAgentConversation(typeKey: AgentTypeKey, notebookId: string): string { + return createAgentConversationInstance(typeKey, notebookId).instanceId; +} + +/** Create and open a blank conversation scoped to a notebook. */ +export function createAndOpenAgentConversation(typeKey: AgentTypeKey, notebookId: string | null): void { + const instance = createAgentConversationInstance(typeKey, notebookId); + openAgentConversation(instance.instanceId); +} + +export function createAndOpenPiConversation(): void { + const notebookId = useNoteStore.getState().selectedNotebook?.id ?? null; + createAndOpenAgentConversation('pi', notebookId); +} diff --git a/app/flowix-web/features/collection/dataset-filter-location.test.ts b/app/flowix-web/features/collection/dataset-filter-location.test.ts new file mode 100644 index 000000000..00da8c121 --- /dev/null +++ b/app/flowix-web/features/collection/dataset-filter-location.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest'; +import { resolveDatasetFilterForLocation } from './dataset-filter-location'; +import { matchesMediaCollectionFilter, createMediaLibrary, mediaCollectionFilterForSave } from '../media-library/model'; + +it('follows a moved folder index without changing conditions or the stored document', () => { + const filter = { path: 'Old', match: 'any', conditions: [{ field: 'file_name', op: 'contains', value: 'photo' }] }; + const effective = resolveDatasetFilterForLocation(filter, 'folder', '/book/New/.index.yaml', '/book'); + expect(matchesMediaCollectionFilter('New/photo.png', 'image', effective)).toBe(true); + expect(matchesMediaCollectionFilter('Old/photo.png', 'image', effective)).toBe(false); + expect(matchesMediaCollectionFilter('New/other.png', 'image', effective)).toBe(false); + expect(filter.path).toBe('Old'); + expect(resolveDatasetFilterForLocation(filter, 'standalone', '/book/New/file.index.yaml', '/book')).toBe(filter); +}); + +it('resolves legacy path rules and notebook root locations', () => { + const filter = { all: [{ field: 'path', op: 'contains', value: 'Old' }], any: [] }; + const effective = resolveDatasetFilterForLocation(filter, 'folder', '/book/New/.index.yaml', '/book'); + expect(matchesMediaCollectionFilter('New/photo.png', 'image', effective)).toBe(true); + expect(resolveDatasetFilterForLocation({ path: 'Old', match: 'all', conditions: [] }, 'folder', '/book/.index.yaml', '/book')?.path).toBe(''); +}); + +it('checks the latest media filter before replacing it and synchronizes folder paths on save', () => { + const document = createMediaLibrary('Media'); + const before = document.dataset.filter; + expect(mediaCollectionFilterForSave(document, { file_name_contains: 'photo' }, '/book/media.index.yaml', '/book')).toMatchObject({ path: null }); + document.dataset.filter = { all: [], any: [{ field: 'file_name', op: 'contains', value: 'external' }] }; + expect(() => mediaCollectionFilterForSave(document, { file_name_contains: 'photo' }, '/book/media.index.yaml', '/book')).toThrow('已取消保存'); + expect(document.dataset.filter.any).toHaveLength(1); + document.dataset.filter = before; + document.collection.placement = 'folder'; + expect(mediaCollectionFilterForSave(document, {}, '/book/New/.index.yaml', '/book')).toEqual({ path: 'New', match: 'all', conditions: [] }); +}); diff --git a/app/flowix-web/features/collection/dataset-filter-location.ts b/app/flowix-web/features/collection/dataset-filter-location.ts new file mode 100644 index 000000000..1a9be41f5 --- /dev/null +++ b/app/flowix-web/features/collection/dataset-filter-location.ts @@ -0,0 +1,24 @@ +import { notebookRelativeDirectoryPath, parentDirectoryPath } from '@/lib/path'; +import type { CollectionPlacement } from './model'; + +/** Folder paths follow the current index location, including filesystem moves. */ +export function resolveDatasetFilterForLocation( + filter: Record | null, + placement: CollectionPlacement, + filePath: string | null, + notebookPath: string | null, +): Record | null { + if (!filter || placement !== 'folder' || !filePath || !notebookPath) return filter; + const path = notebookRelativeDirectoryPath(notebookPath, parentDirectoryPath(filePath, notebookPath)); + if (path === null) return filter; + if (Array.isArray(filter.conditions)) return { ...filter, path }; + const relocate = (rule: Record): Record => { + if (rule.field === 'path') return { ...rule, value: path }; + return { + ...rule, + ...(Array.isArray(rule.all) ? { all: rule.all.map(relocate) } : {}), + ...(Array.isArray(rule.any) ? { any: rule.any.map(relocate) } : {}), + }; + }; + return relocate(filter); +} diff --git a/app/flowix-web/features/collection/folder-collection.ts b/app/flowix-web/features/collection/folder-collection.ts index 11c5a76bb..1959eb962 100644 --- a/app/flowix-web/features/collection/folder-collection.ts +++ b/app/flowix-web/features/collection/folder-collection.ts @@ -1,20 +1,25 @@ import { createTableDocument, serializeTableDocument } from '@features/multidimensional-table/public/create-api'; import { createMediaLibrary, serializeMediaLibrary } from '@features/media-library/model'; -import { parseCollectionDocumentV2, serializeCollectionDocumentV2, type CollectionType } from './model'; +import { collectionDisplayName, parseCollectionDocumentV2, serializeCollectionDocumentV2, type CollectionType } from './model'; import { externalDocuments } from '@platform/tauri/client'; -import { canonicalPath } from '@/lib/path'; +import { canonicalPath, notebookRelativeDirectoryPath } from '@/lib/path'; -export function createFolderCollectionContent(kind: 'notes' | 'media', folderName: string): string { +export function createFolderCollectionContent(kind: 'notes' | 'media', folderName: string, folderPath: string | null = null): string { if (kind === 'notes') { const document = createTableDocument('table', folderName, 'folder'); document.dataset.source = { scope: 'containing_folder', depth: 'descendants' }; - document.dataset.filter = { all: [{ field: 'kind', op: 'eq', value: 'note' }], any: [] }; + document.dataset.filter = { + path: folderPath, + match: 'all', + conditions: [{ field: 'kind', op: 'eq', value: 'note' }], + }; return serializeTableDocument(document); } const document = createMediaLibrary(folderName); document.collection.placement = 'folder'; document.dataset.source = { scope: 'containing_folder', depth: 'descendants' }; + document.dataset.filter = { path: folderPath, match: 'all', conditions: [] }; return serializeMediaLibrary(document); } @@ -25,11 +30,13 @@ export async function switchFolderCollectionType(filePath: string, notebookPath: throw new Error('仅文件夹集合支持切换视图类型'); } if (current.collection.type === type) return current.collection.id; - const template = parseCollectionDocumentV2(createFolderCollectionContent(type === 'table' ? 'notes' : 'media', current.collection.name)); + const folderPath = canonicalPath(filePath).replace(/[\\/][^\\/]*$/, ''); + const relativeFolder = notebookRelativeDirectoryPath(notebookPath, folderPath) ?? ''; + const folderName = collectionDisplayName(filePath, 'folder'); + const template = parseCollectionDocumentV2(createFolderCollectionContent(type === 'table' ? 'notes' : 'media', folderName, relativeFolder)); const next = { ...template.collection, id: current.collection.id, - name: current.collection.name, revision: current.collection.revision + 1, created_at: current.collection.created_at, updated_at: new Date().toISOString(), diff --git a/app/flowix-web/features/collection/model.test.ts b/app/flowix-web/features/collection/model.test.ts index 878c6cc7d..a47e4c922 100644 --- a/app/flowix-web/features/collection/model.test.ts +++ b/app/flowix-web/features/collection/model.test.ts @@ -14,7 +14,7 @@ describe('collection file identity and properties', () => { expect(tableFile.payload.dataset).not.toHaveProperty('id'); expect(parseTableDocument(serializeTableDocument(table))).toEqual(table); const library = createMediaLibrary('照片'); - library.dataset.records.include.push(createMediaLibraryRecord('attachments/photo.png')); + library.dataset.records.include.push(createMediaLibraryRecord('notebook-1', 'attachments/photo.png')); library.collection.properties = table.collection.properties; expect(parseMediaLibrary(serializeMediaLibrary(library))).toEqual(library); expect(library.collection.id).toMatch(/^col_[0-9a-f]{32}$/); diff --git a/app/flowix-web/features/collection/model.ts b/app/flowix-web/features/collection/model.ts index 614fd5b14..b733aaa83 100644 --- a/app/flowix-web/features/collection/model.ts +++ b/app/flowix-web/features/collection/model.ts @@ -19,7 +19,8 @@ export interface CollectionMetadata { id: string; type: CollectionType; placement: CollectionPlacement; - name: string; + /** Legacy name retained when reading old files; the path is authoritative. */ + name?: string; revision: number; created_at: string; updated_at: string; @@ -46,11 +47,16 @@ export function createUuidV7(): string { export const createCollectionId = () => `col_${createUuidV7()}`; export function createCollectionMetadata( type: CollectionType, - name: string, + _name: string, placement: CollectionPlacement = 'standalone', ): CollectionMetadata { const now = new Date().toISOString(); - return { id: createCollectionId(), type, placement, name: name.trim(), revision: 0, created_at: now, updated_at: now, properties: {} }; + return { id: createCollectionId(), type, placement, revision: 0, created_at: now, updated_at: now, properties: {} }; +} +export function collectionDisplayName(filePath: string, placement: CollectionPlacement): string { + const parts = filePath.split(/[\\/]/).filter(Boolean); + if (placement === 'folder') return parts[parts.length - 2] ?? ''; + return (parts[parts.length - 1] ?? '').replace(/\.(?:index\.ya?ml|(?:table|lib)\.ya?ml)$/i, ''); } export function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -91,6 +97,19 @@ function ruleFilterMatchesCollectionType(filter: unknown, type: CollectionType): return false; }); } +function datasetFilterMatchesCollectionType(filter: unknown, type: CollectionType): boolean { + if (!isRecord(filter) || !hasOnlyKeys(filter, ['path', 'match', 'conditions'])) { + return ruleFilterMatchesCollectionType(filter, type); + } + if (!(filter.path === null || filter.path === '' || isNotebookRelativePath(filter.path)) + || (filter.match !== 'all' && filter.match !== 'any') + || !Array.isArray(filter.conditions) + || filter.conditions.some((condition) => !isRecord(condition) || 'all' in condition || 'any' in condition)) return false; + if (type === 'media_library' && filter.match !== 'all') return false; + const legacyShape = { all: filter.conditions, any: [] }; + if (!filter.conditions.every((condition) => isRecord(condition) && condition.field !== 'path')) return false; + return ruleFilterMatchesCollectionType(legacyShape, type); +} export function hasOnlyKeys(value: object, keys: readonly string[]): boolean { return Object.keys(value).every((key) => keys.includes(key)); } @@ -116,7 +135,7 @@ export function validateCollectionMetadata(value: unknown, type?: CollectionType || typeof value.id !== 'string' || !/^col_[0-9a-f]{32}$/.test(value.id) || !['table', 'media_library'].includes(String(normalizedType)) || (type && normalizedType !== type) || (value.placement !== undefined && !['standalone', 'folder'].includes(String(value.placement))) - || typeof value.name !== 'string' || !value.name.trim() || value.name !== value.name.trim() + || (value.name !== undefined && (typeof value.name !== 'string' || !value.name.trim() || value.name !== value.name.trim())) || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0 || typeof value.created_at !== 'string' || !Number.isFinite(Date.parse(value.created_at)) || typeof value.updated_at !== 'string' || !Number.isFinite(Date.parse(value.updated_at)) @@ -186,7 +205,7 @@ function validateV2Envelope(value: Record): { collection: Colle || (collection.type === 'media_library' && (sort.field !== 'created_at' || sort.direction !== 'desc')))))) throw new Error('集合视图筛选、排序或布局无效'); // A deleted remembered table view is recoverable; the table model selects the first view. if (collection.type !== 'table' && !viewItems.some((view) => isRecord(view) && view.id === views.default_id)) throw new Error('默认集合视图不存在'); - if (value.payload.dataset.filter !== null && value.payload.dataset.filter !== undefined && !ruleFilterMatchesCollectionType(value.payload.dataset.filter, collection.type)) { + if (value.payload.dataset.filter !== null && value.payload.dataset.filter !== undefined && !datasetFilterMatchesCollectionType(value.payload.dataset.filter, collection.type)) { throw new Error('集合规则筛选格式无效'); } @@ -357,7 +376,6 @@ export function serializeCollectionDocumentV2(document: CollectionDocumentV2): s id: checked.id, type: checked.type === 'table' ? 'notes' : 'media', placement: checked.placement, - name: checked.name, revision: checked.revision, created_at: checked.created_at, updated_at: checked.updated_at, diff --git a/app/flowix-web/features/collection/recovery.test.ts b/app/flowix-web/features/collection/recovery.test.ts new file mode 100644 index 000000000..8a3173ea3 --- /dev/null +++ b/app/flowix-web/features/collection/recovery.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import YAML from 'yaml'; +import { externalDocuments } from '@platform/tauri/client'; +import { createTableDocument, parseTableDocument, serializeTableDocument } from '@features/multidimensional-table/model'; +import { createMediaLibrary, parseMediaLibrary, serializeMediaLibrary } from '@features/media-library/model'; +import { createFolderCollectionContent } from './folder-collection'; +import { repairCollectionFile } from './recovery'; + +vi.mock('@platform/tauri/client', () => ({ + externalDocuments: { read: vi.fn(), write: vi.fn() }, + notebooks: { getAll: vi.fn().mockResolvedValue([{ id: 'book', path: '/book' }]) }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(externalDocuments.write).mockImplementation(async ({ filePath, content }) => ({ status: 'saved', path: filePath, content })); +}); + +it.each([ + ['notes', 'standalone'], ['notes', 'folder'], ['media', 'standalone'], ['media', 'folder'], +] as const)('recovers %s content with invalid placement at a %s location', async (type, placement) => { + const validSource = placement === 'folder' ? createFolderCollectionContent(type, '项目', '11测试') + : type === 'notes' ? serializeTableDocument(createTableDocument('calendar', '项目', 'standalone')) + : serializeMediaLibrary(createMediaLibrary('项目')); + const wire = YAML.parse(validSource); + const record = { + id: 'rec_01a12e0b2ab97d089eeff757ebd4e643', updated_at: '2026-10-12T02:57:27.436Z', + ...(type === 'notes' ? { note_path: 'Welcome111.md' } : { relative_path: 'photo.png', resource_id: 'image-1' }), + }; + wire.payload.dataset.records.include.push(record); + wire.collection.placement = 'standalone1'; + const damaged = YAML.stringify(wire); + vi.mocked(externalDocuments.read).mockResolvedValue(damaged); + const path = `/book/11测试/${placement === 'folder' ? '.index.yaml' : '新建多维日历.index.yaml'}`; + const result = await repairCollectionFile(path, '/book'); + const write = vi.mocked(externalDocuments.write).mock.calls[0][0]; + const repaired = type === 'notes' ? parseTableDocument(write.content) : parseMediaLibrary(write.content); + expect(write).toMatchObject({ filePath: path, scopePath: '/book', expectedContent: damaged }); + expect(result.placement).toBe(placement); + expect(repaired.collection.id).toBe(wire.collection.id); + expect(repaired.dataset.records.include).toEqual([type === 'notes' ? record : { ...record, resource_id: 'book::photo.png' }]); + expect(repaired.views).toEqual(wire.payload.views); + if (placement === 'folder') expect(repaired.dataset.source).toEqual({ scope: 'containing_folder', depth: 'descendants' }); +}); + +it('falls back to a valid table when the file has no recoverable YAML or collection type', async () => { + vi.mocked(externalDocuments.read).mockResolvedValue('collection: [\ninvalid: {'); + const result = await repairCollectionFile('/book/11测试/broken.index.yaml', '/book'); + expect(result).toMatchObject({ collectionType: 'table', placement: 'standalone' }); + const write = vi.mocked(externalDocuments.write).mock.calls[0][0]; + expect(() => parseTableDocument(write.content)).not.toThrow(); +}); diff --git a/app/flowix-web/features/collection/recovery.ts b/app/flowix-web/features/collection/recovery.ts new file mode 100644 index 000000000..6ddb6792e --- /dev/null +++ b/app/flowix-web/features/collection/recovery.ts @@ -0,0 +1,288 @@ +import YAML from 'yaml'; +import { externalDocuments, notebooks } from '@platform/tauri/client'; +import { canonicalDirectoryPath, canonicalPath, notebookRelativeDirectoryPath } from '@/lib/path'; +import { createFolderCollectionContent } from './folder-collection'; +import { + createCollectionId, + createUuidV7, + collectionDisplayName, + isRecord, + parseCollectionDocumentV2, + serializeCollectionDocumentV2, + validateCollectionProperty, + type CollectionDocumentV2, + type CollectionMetadata, + type CollectionPlacement, + type CollectionProperty, + type CollectionType, +} from './model'; +import { createTableDocument, createTableView, parseTableDocument, serializeTableDocument, type MultidimensionalTableDocument } from '@features/multidimensional-table/model'; +import { createMediaLibrary, parseMediaLibrary, serializeMediaLibrary } from '@features/media-library/model'; + +export interface CollectionRecoveryResult { + collectionId: string; + collectionType: CollectionType; + placement: CollectionPlacement; + name: string; +} + +function parseLooseDocument(source: string): Record | null { + try { + const value: unknown = YAML.parse(source); + return isRecord(value) ? value : null; + } catch { + // parseDocument can retain a useful partial YAML tree when a later block + // is malformed. Keep that partial tree for record-level salvage. + try { + const value: unknown = YAML.parseDocument(source, { prettyErrors: false }).toJS(); + return isRecord(value) ? value : null; + } catch { return null; } + } +} + +function collectionTypeFromWire(value: unknown): CollectionType | null { + if (value === 'notes' || value === 'table') return 'table'; + if (value === 'media' || value === 'media_library') return 'media_library'; + return null; +} + +function detectCollectionType(source: string, loose: Record | null): CollectionType { + const collection = loose && isRecord(loose.collection) ? loose.collection : null; + const parsed = collectionTypeFromWire(collection?.type); + if (parsed) return parsed; + // A syntax error elsewhere in the YAML should not hide a readable type. + const collectionBlock = source.match(/^collection:\s*\r?\n((?:^[ \t]+.*(?:\r?\n|$))*)/m)?.[1] ?? ''; + const wireType = collectionBlock.match(/^[ \t]{2,}type:\s*(notes|media|table|media_library)\s*$/m)?.[1]; + return collectionTypeFromWire(wireType) ?? 'table'; +} + +/** Identify the renderer without requiring the damaged document to validate. */ +export function inspectCollectionRecoveryType(source: string): CollectionType { + return detectCollectionType(source, parseLooseDocument(source)); +} + +function detectPlacement(filePath: string): CollectionPlacement { + // Placement is tied to the collection's location. A standalone collection + // cannot become a folder collection merely because its metadata is corrupt. + return canonicalPath(filePath).endsWith('/.index.yaml') ? 'folder' : 'standalone'; +} + +function validNotebookRelativePath(value: unknown): value is string { + return typeof value === 'string' && Boolean(value) && !value.startsWith('/') && !value.includes('\\') + && value.split('/').every((part) => part && part !== '.' && part !== '..'); +} + +function validTimestamp(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + +function validRecordId(value: unknown): value is string { + return typeof value === 'string' && /^rec_[0-9a-f]{32}$/.test(value); +} + +function sourceRecords( + loose: Record | null, +): { include: unknown[]; exclude: unknown[] } { + const payload = loose && isRecord(loose.payload) ? loose.payload : null; + const dataset = payload && isRecord(payload.dataset) ? payload.dataset : null; + const records = dataset && isRecord(dataset.records) ? dataset.records : null; + if (records) return { + include: Array.isArray(records.include) ? records.include : [], + exclude: Array.isArray(records.exclude) ? records.exclude : [], + }; + // Read the common v1 shape as well as v2. Legacy data rows become explicit + // includes; malformed fields are stripped by the type-specific sanitizer. + const legacyRecords = payload && isRecord(payload.records) ? payload.records : null; + return { include: Array.isArray(legacyRecords?.data) ? legacyRecords.data : [], exclude: [] }; +} + +function sanitizeRecords( + loose: Record | null, + type: CollectionType, + notebookId?: string, +): { include: Record[]; exclude: Record[] } { + const records = sourceRecords(loose); + const sanitize = (values: unknown[]) => { + const seen = new Set(); + const seenIds = new Set(); + const output: Record[] = []; + for (const raw of values) { + if (!isRecord(raw)) continue; + const path = type === 'table' ? raw.note_path : (raw.relative_path ?? raw.note_path); + if (!validNotebookRelativePath(path) || seen.has(path)) continue; + if (type === 'media_library' && !notebookId + && (typeof raw.resource_id !== 'string' || !raw.resource_id.trim())) continue; + seen.add(path); + const recordId = validRecordId(raw.id) && !seenIds.has(raw.id) ? raw.id : `rec_${createUuidV7()}`; + seenIds.add(recordId); + output.push({ + id: recordId, + updated_at: validTimestamp(raw.updated_at) ? raw.updated_at : new Date().toISOString(), + ...(type === 'table' ? { note_path: path } : { resource_id: notebookId ? `${notebookId}::${path}` : (raw.resource_id as string).trim(), relative_path: path }), + }); + } + return output; + }; + return { include: sanitize(records.include), exclude: sanitize(records.exclude) }; +} + +function recoveredMetadata( + loose: Record | null, + type: CollectionType, + placement: CollectionPlacement, + fallbackName: string, +): CollectionMetadata { + const raw = loose && isRecord(loose.collection) ? loose.collection : {}; + const now = new Date().toISOString(); + const properties: Record = {}; + if (isRecord(raw.properties)) { + for (const [key, value] of Object.entries(raw.properties)) { + if (validateCollectionProperty(value)) properties[key] = value; + } + } + const rawName = typeof raw.name === 'string' ? raw.name.trim() : ''; + return { + id: typeof raw.id === 'string' && /^col_[0-9a-f]{32}$/.test(raw.id) ? raw.id : createCollectionId(), + type, + placement, + name: rawName || fallbackName, + revision: Number.isSafeInteger(raw.revision) && Number(raw.revision) >= 0 ? Number(raw.revision) + 1 : 1, + created_at: validTimestamp(raw.created_at) ? raw.created_at : now, + updated_at: now, + properties, + }; +} + +function fallbackCollectionName(filePath: string, type: CollectionType): string { + const filename = filePath.split(/[\\/]/).filter(Boolean).pop() ?? ''; + const withoutExtension = filename.replace(/(?:\.index\.ya?ml|\.(?:table|lib)\.ya?ml)$/i, '').trim(); + return withoutExtension || (type === 'table' ? '多维表格' : '媒体库'); +} + +function createTemplate( + filePath: string, + notebookPath: string, + type: CollectionType, + placement: CollectionPlacement, + name: string, +): CollectionDocumentV2 { + if (placement === 'folder') { + const directory = canonicalPath(filePath).replace(/[\\/][^\\/]*$/, ''); + const relativeDirectory = notebookRelativeDirectoryPath(notebookPath, directory) ?? ''; + return parseCollectionDocumentV2(createFolderCollectionContent(type === 'table' ? 'notes' : 'media', name, relativeDirectory)); + } + if (type === 'table') return parseCollectionDocumentV2(serializeTableDocument(createTableDocument('table', name, 'standalone'))); + const media = createMediaLibrary(name); + media.collection.placement = 'standalone'; + // A damaged standalone media library with no readable filter should reopen + // with the notebook's discoverable media instead of an empty gallery. + media.dataset.filter = { all: [{ field: 'kind', op: 'in', value: ['image', 'video'] }], any: [] }; + return parseCollectionDocumentV2(serializeMediaLibrary(media)); +} + +function tryCollection(candidate: CollectionDocumentV2): CollectionDocumentV2 | null { + try { + const source = serializeCollectionDocumentV2(candidate); + const parsed = parseCollectionDocumentV2(source); + if (parsed.collection.type === 'table') parseTableDocument(source); + else parseMediaLibrary(source); + return parsed; + } + catch { return null; } +} + +function buildRecoveredDocument( + filePath: string, + notebookPath: string, + loose: Record | null, + type: CollectionType, + placement: CollectionPlacement, + notebookId?: string, +): CollectionDocumentV2 { + const name = fallbackCollectionName(filePath, type); + const template = createTemplate(filePath, notebookPath, type, placement, name); + const collection = recoveredMetadata(loose, type, placement, name); + const dataset = template.payload.dataset; + const incoming = loose && isRecord(loose.payload) && isRecord(loose.payload.dataset) ? loose.payload.dataset : null; + let candidate: CollectionDocumentV2 = { + ...template, + collection, + payload: { ...template.payload, dataset: { ...dataset, records: sanitizeRecords(loose, type, notebookId) } }, + }; + + // Keep each optional dataset section only when it passes the full collection + // validator in combination with the repaired metadata and records. + if (incoming && isRecord(incoming.source) && placement === 'standalone') { + const next = { ...candidate, payload: { ...candidate.payload, dataset: { ...candidate.payload.dataset, source: incoming.source } } }; + candidate = tryCollection(next) ?? candidate; + } + if (incoming && (incoming.filter === null || isRecord(incoming.filter))) { + const next = { ...candidate, payload: { ...candidate.payload, dataset: { ...candidate.payload.dataset, filter: incoming.filter as Record | null } } }; + candidate = tryCollection(next) ?? candidate; + } + if (incoming && isRecord(incoming.schema)) { + const datasetWithSchema = { ...candidate.payload.dataset, schema: incoming.schema }; + let next: CollectionDocumentV2 = { ...candidate, payload: { ...candidate.payload, dataset: datasetWithSchema } }; + if (type === 'table') { + try { + const tableDocument = { + collection, + dataset: datasetWithSchema, + views: { default_id: '', items: [] }, + } as unknown as MultidimensionalTableDocument; + const defaultView = createTableView(tableDocument, 'table'); + next = { ...next, payload: { ...next.payload, views: { default_id: defaultView.id, items: [defaultView] } } }; + } catch { /* Invalid custom fields fall back to the known template below. */ } + } + candidate = tryCollection(next) ?? candidate; + } + + // Retain custom views only as a valid set. A broken or stale view set falls + // back to the generated table/media view, which is always known to validate. + const incomingViews = loose && isRecord(loose.payload) && isRecord(loose.payload.views) ? loose.payload.views : null; + if (incomingViews && Array.isArray(incomingViews.items)) { + const requestedDefault = typeof incomingViews.default_id === 'string' ? incomingViews.default_id : ''; + const validViews = incomingViews.items.filter(isRecord); + const defaultExists = validViews.some((view) => view.id === requestedDefault); + const nextViews = { + default_id: defaultExists ? requestedDefault : (typeof validViews[0]?.id === 'string' ? validViews[0].id : candidate.payload.views.default_id), + items: validViews, + }; + const next = { ...candidate, payload: { ...candidate.payload, views: nextViews } }; + candidate = tryCollection(next) ?? candidate; + } + return tryCollection(candidate) ?? template; +} + +/** Repair a malformed collection in place while retaining every valid record/config section. */ +export async function repairCollectionFile( + filePath: string, + notebookPath: string, +): Promise { + const root = canonicalPath(notebookPath); + const path = canonicalPath(filePath); + const source = await externalDocuments.read(path, root); + const loose = parseLooseDocument(source); + const collectionType = detectCollectionType(source, loose); + const placement = detectPlacement(path); + const name = fallbackCollectionName(path, collectionType); + const notebookId = collectionType === 'media_library' + ? (await notebooks.getAll()).find((notebook) => canonicalDirectoryPath(notebook.path) === root)?.id + : undefined; + if (collectionType === 'media_library' && !notebookId) throw new Error('媒体库所属笔记本未注册'); + const document = buildRecoveredDocument(path, root, loose, collectionType, placement, notebookId); + const content = serializeCollectionDocumentV2(document); + // Validate the exact bytes before touching disk; serialization must produce + // a complete document accepted by the normal reader. + const verified = parseCollectionDocumentV2(content); + const result = await externalDocuments.write({ filePath: path, scopePath: root, expectedContent: source, content }); + if (result.status === 'conflict') throw new Error('集合文件已变化,请重新点击“重置当前视图”'); + if (result.status === 'missing') throw new Error('集合文件已不存在'); + if (result.status === 'error') throw new Error(result.message); + return { + collectionId: verified.collection.id, + collectionType: verified.collection.type, + placement: verified.collection.placement, + name: collectionDisplayName(path, placement) || name, + }; +} diff --git a/app/flowix-web/features/collection/rule-filter.ts b/app/flowix-web/features/collection/rule-filter.ts index 555d2100e..5282fb3ae 100644 --- a/app/flowix-web/features/collection/rule-filter.ts +++ b/app/flowix-web/features/collection/rule-filter.ts @@ -5,6 +5,15 @@ export interface CollectionRuleFilter { export type CollectionRuleEntry = CollectionRuleFilter | { field: string; op: string; value: unknown }; +export interface DatasetRuleFilter { + /** Mandatory notebook-relative directory prefix; null means no extra path restriction. */ + path: string | null; + match: 'all' | 'any'; + conditions: Array<{ field: string; op: string; value: unknown }>; +} + +export type DatasetFilter = CollectionRuleFilter | DatasetRuleFilter; + function isGroup(entry: CollectionRuleEntry): entry is CollectionRuleFilter { return 'all' in entry && 'any' in entry; } @@ -20,17 +29,40 @@ export function matchesCollectionFilter( return filter.all.every(matches) && (filter.any.length === 0 || filter.any.some(matches)); } -export function collectionFilterUsesField(filter: CollectionRuleFilter | null, field: string): boolean { +/** Dataset paths are mandatory; the remaining rules use one explicit match mode. */ +export function matchesDatasetFilter( + filter: DatasetFilter, + matchesRule: (rule: { field: string; op: string; value: unknown }) => boolean, +): boolean { + if ('conditions' in filter && 'match' in filter) { + const pathMatches = filter.path === null + || matchesRule({ field: 'path', op: 'contains', value: filter.path }); + if (!pathMatches) return false; + if (filter.conditions.length === 0) return true; + return filter.match === 'all' + ? filter.conditions.every(matchesRule) + : filter.conditions.some(matchesRule); + } + return matchesCollectionFilter(filter, matchesRule as (rule: Exclude) => boolean); +} + +export function collectionFilterUsesField(filter: DatasetFilter | null, field: string): boolean { if (!filter) return false; + if ('conditions' in filter && 'match' in filter) { + return filter.conditions.some((rule) => rule.field === field); + } const uses = (entry: CollectionRuleEntry): boolean => isGroup(entry) ? collectionFilterUsesField(entry, field) : entry.field === field; return [...filter.all, ...filter.any].some(uses); } -export function renameCollectionFilterField(filter: CollectionRuleFilter, previous: string, next: string): CollectionRuleFilter { +export function renameCollectionFilterField(filter: T, previous: string, next: string): T { + if ('conditions' in filter && 'match' in filter) { + return { ...filter, conditions: filter.conditions.map((rule) => rule.field === previous ? { ...rule, field: next } : rule) } as T; + } const rename = (entry: CollectionRuleEntry): CollectionRuleEntry => isGroup(entry) ? renameCollectionFilterField(entry, previous, next) : entry.field === previous ? { ...entry, field: next } : entry; - return { ...filter, all: filter.all.map(rename), any: filter.any.map(rename) }; + return { ...filter, all: filter.all.map(rename), any: filter.any.map(rename) } as T; } diff --git a/app/flowix-web/features/collection/use-collection-reference.ts b/app/flowix-web/features/collection/use-collection-reference.ts index c35f18212..8323e53ee 100644 --- a/app/flowix-web/features/collection/use-collection-reference.ts +++ b/app/flowix-web/features/collection/use-collection-reference.ts @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import { collections, externalDocuments, type CollectionIndexItem } from '@platform/tauri/client'; +import { collections, externalDocuments, type CollectionChangedEvent, type CollectionIndexItem } from '@platform/tauri/client'; import { subscribe } from '@platform/tauri/event-bus'; +import { subscribeNotebookChanges } from '@platform/tauri/notebook-change-events'; import { bindCollectionDisplayPath, ensureCollectionDisplay, pinCollectionDisplay } from '@/lib/collection-display-registry'; import type { FileDisplayIdentity } from '@/lib/file-display-registry'; import { canonicalDirectoryPath } from '@/lib/path'; import { parseCollectionDocumentV2, type CollectionType } from './model'; -import { subscribeLocalCollectionChanges } from './events'; interface ReferenceState { key: string; @@ -49,13 +49,17 @@ export function useCollectionReference(notebook: { id: string; path: string } | }; void refresh(); const matches = (event: { notebookId: string; collectionId?: string }) => event.notebookId === notebook.id && (!event.collectionId || event.collectionId === collectionId); - const stopCollection = subscribe<{ notebookId: string; collectionId: string }>('collection-changed', (event) => { if (matches(event)) void refresh(); }); - const stopPath = subscribe<{ notebookId: string; relativePath?: string; directory?: boolean }>('flowix:path-note-changed', (event) => { - if (matches(event) && (event.directory || !event.relativePath || /(?:\.(table|lib)\.ya?ml|\.index\.yaml)$/i.test(event.relativePath))) void refresh(); + const stopCollection = subscribe('collection-changed', (event) => { + if (matches(event) || (event.notebookId === notebook.id && Boolean(event.directoryMove))) void refresh(); }); - const stopCatalog = subscribe<{ notebookId: string }>('file-management-changed', (event) => { if (matches(event)) void refresh(); }); - const stopLocal = subscribeLocalCollectionChanges((event) => { if (matches({ notebookId: event.notebookId, collectionId: event.item.collectionId ?? undefined })) void refresh(); }); - return () => { active = false; stopCollection(); stopPath(); stopCatalog(); stopLocal(); release?.(); }; + const stopPath = subscribeNotebookChanges((event) => { + const collectionMutation = event.changeId?.startsWith(`collection:${notebook.id}:`); + if (!collectionMutation && matches(event) && ((event.kind === 'path-moved' && event.directory) || !event.relativePath || /(?:\.(table|lib)\.ya?ml|\.index\.yaml)$/i.test(event.relativePath))) void refresh(); + }); + const stopCatalog = subscribe<{ notebookId: string; operationId?: string }>('file-management-changed', (event) => { + if (!event.operationId && matches(event)) void refresh(); + }); + return () => { active = false; stopCollection(); stopPath(); stopCatalog(); release?.(); }; }, [enabled, notebook?.id, root, collectionId, type, viewId, key]); return state.key === key ? state : { key, item: null, content: null, fileIdentity: undefined, error: null }; } diff --git a/app/flowix-web/features/content/components/content-icon.tsx b/app/flowix-web/features/content/components/content-icon.tsx new file mode 100644 index 000000000..8047fc14e --- /dev/null +++ b/app/flowix-web/features/content/components/content-icon.tsx @@ -0,0 +1,186 @@ +import { forwardRef } from 'react'; +import type { Icon, IconProps } from '@phosphor-icons/react'; +import { Folder, Globe } from 'lucide-react'; +import { + FileCodeIcon, + FileDocIcon, + FileHtmlIcon, + FileImageIcon, + FileMdIcon, + FilePdfIcon, + FilePptIcon, + FileTextIcon, + FileVideoIcon, + FileXlsIcon, + FileZipIcon, +} from '@phosphor-icons/react'; +import { classifyFileContent, type FileContentKind } from '@features/content/model/content-kind'; +import { NoteContentIcon } from './note-content-icon'; + +export type ContentFileIconKind = FileContentKind | 'fallback'; + +const PHOSPHOR_ICON_BY_KIND: Record = { + note: FileMdIcon, + html: FileHtmlIcon, + document: FileDocIcon, + presentation: FilePptIcon, + spreadsheet: FileXlsIcon, + pdf: FilePdfIcon, + video: FileVideoIcon, + archive: FileZipIcon, + image: FileImageIcon, + code: FileCodeIcon, + fallback: FileTextIcon, + text: FileTextIcon, + unknown: FileTextIcon, +}; + +/** Flat icon categories; special resources sit beside file categories. */ +export type ContentKind = FileContentKind | 'folder' | 'web' | 'media-library' | 'multidimensional-table'; + +export type ContentDescription = + | { kind: 'file'; path: string; mimeType?: string | null } + | { kind: ContentKind }; + +/** One renderer for content icons; callers provide facts and layout only. */ +export function ContentIcon({ content, className }: { content: ContentDescription; className?: string }) { + const kind = content.kind === 'file' + ? getFileIconKind(content.path, content.mimeType) + : content.kind; + if (kind === 'folder') return
; @@ -117,7 +118,7 @@ export function PropertyPresetMenu({ }} placeholder={t('document.properties.picker.search')} aria-label={t('document.properties.picker.search')} - className="agent-thread-card__composer-note-search multidimensional-table__preset-search shrink-0" + className="agent-composer__note-search multidimensional-table__preset-search shrink-0" />