//! BufferStore — 运行时 DocumentBuffer 管理器 //! //! 职责: //! - 持有所有已打开文档的 `DocumentBuffer` 实例 //! - 以 `ObjectWorkspacePath` 的稳定复合身份作为 buffer key //! - tiptap 保存链、AI 写入、外部 watcher 均通过此模块仲裁状态 //! //! 不是持久化结构,纯内存运行时。 use core_protocol::{ DocBufferDirtyState, DocumentBuffer, KernelObjectIdentity, KernelObjectKind, ObjectWorkspacePath, WorkspaceSourceKind, }; use std::collections::HashMap; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; /// BufferKey — 从 ObjectWorkspacePath 导出的稳定复合 key。 /// /// 由 workspace_id + source_kind + root_uri + relative_path + object_identity.document_id 组成。 /// 同一 .md 文件由 tiptap、AI、外部 watcher 访问时落到同一 key。 #[derive(Debug, Clone, Hash, Eq, PartialEq)] pub struct BufferKey { pub workspace_id: String, pub source_kind: String, pub root_uri: String, pub relative_path: String, pub document_id: Option, } impl BufferKey { /// 从 ObjectWorkspacePath 构建 BufferKey。 pub fn from_workspace_path(path: &ObjectWorkspacePath) -> Self { Self { workspace_id: path.workspace_id.clone(), source_kind: format!("{:?}", path.source_kind), root_uri: path.root_uri.clone(), relative_path: path.relative_path.clone(), document_id: path.object_identity.document_id.clone(), } } /// 从文档基本属性构建 BufferKey。 pub fn from_parts( workspace_id: &str, source_kind: &str, root_uri: &str, relative_path: &str, document_id: Option, ) -> Self { Self { workspace_id: workspace_id.to_string(), source_kind: source_kind.to_string(), root_uri: root_uri.to_string(), relative_path: relative_path.to_string(), document_id, } } } /// BufferStore — 运行时文档缓冲区管理器。 /// /// 每个 `AppState` 有一个实例,所有文档打开/保存/冲突检测共享同一状态模型。 #[derive(Debug, Clone)] pub struct BufferStore { inner: Arc>, } #[derive(Debug)] struct BufferStoreInner { buffers: HashMap, } #[derive(Debug, Clone)] pub struct BufferExternalModificationOutcome { pub buffer: DocumentBuffer, pub self_write_echo: bool, } impl BufferStore { pub fn new() -> Self { Self { inner: Arc::new(RwLock::new(BufferStoreInner { buffers: HashMap::new(), })), } } /// 获取或创建 buffer。 /// /// 如果该 key 已有 buffer,直接返回。否则用 `ObjectWorkspacePath` 创建一个 Clean buffer。 pub fn get_or_create(&self, path: &ObjectWorkspacePath) -> DocumentBuffer { let key = BufferKey::from_workspace_path(path); let mut inner = self.inner.write().expect("BufferStore lock"); if let Some(buf) = inner.buffers.get(&key) { return buf.clone(); } let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; let buf = DocumentBuffer { workspace_path: path.clone(), file_version: None, base_content_hash: None, current_content_hash: None, dirty_state: DocBufferDirtyState::Clean, last_loaded_at: Some(now_ms), last_saved_at: Some(now_ms), external_actor: None, last_write_intent_id: None, last_save_operation_id: None, }; inner.buffers.insert(key, buf.clone()); buf } /// 按 key 获取 buffer(不创建)。 pub fn get(&self, key: &BufferKey) -> Option { let inner = self.inner.read().expect("BufferStore lock"); inner.buffers.get(key).cloned() } /// 更新 buffer。 pub fn update(&self, key: &BufferKey, buffer: DocumentBuffer) { let mut inner = self.inner.write().expect("BufferStore lock"); inner.buffers.insert(key.clone(), buffer); } /// 通过 ObjectWorkspacePath 获取 buffer。不存在则返回 None。 pub fn get_by_path(&self, path: &ObjectWorkspacePath) -> Option { let key = BufferKey::from_workspace_path(path); self.get(&key) } /// 标记保存成功:更新 file_version、base_content_hash、清除 dirty 状态。 pub fn mark_saved( &self, path: &ObjectWorkspacePath, file_version: String, content_hash: String, ) -> Option { self.mark_saved_with_operation(path, file_version, content_hash, None, None) } pub fn mark_saved_with_operation( &self, path: &ObjectWorkspacePath, file_version: String, content_hash: String, write_intent_id: Option, save_operation_id: Option, ) -> Option { let key = BufferKey::from_workspace_path(path); let mut inner = self.inner.write().expect("BufferStore lock"); if let Some(buf) = inner.buffers.get_mut(&key) { buf.mark_saved_with_operation( file_version, content_hash, write_intent_id, save_operation_id, ); Some(buf.clone()) } else { None } } /// 标记外部修改:如果 buffer 是 Clean → ExternalModified;是 Dirty → Stale。 pub fn mark_external_modified( &self, path: &ObjectWorkspacePath, actor: Option, ) -> Option { let key = BufferKey::from_workspace_path(path); let mut inner = self.inner.write().expect("BufferStore lock"); if let Some(buf) = inner.buffers.get_mut(&key) { buf.mark_external_modified(actor); Some(buf.clone()) } else { None } } /// 带磁盘版本的外部修改标记。 /// /// watcher 会看到 MNote 自己保存产生的文件系统回声;如果 watcher 观测到的 /// `observed_file_version` 等于最近保存/加载的 `file_version`,说明这不是新的外部修改, /// 不应把 Clean 变成 ExternalModified,也不应把当前浏览器正在输入的 Dirty 变成 Stale。 pub fn mark_external_modified_if_version_changed( &self, path: &ObjectWorkspacePath, observed_file_version: Option, actor: Option, ) -> Option { self.mark_external_modified_if_version_changed_with_outcome( path, observed_file_version, actor, ) .map(|outcome| outcome.buffer) } pub fn mark_external_modified_if_version_changed_with_outcome( &self, path: &ObjectWorkspacePath, observed_file_version: Option, actor: Option, ) -> Option { let key = BufferKey::from_workspace_path(path); let mut inner = self.inner.write().expect("BufferStore lock"); let buf = inner.buffers.get_mut(&key)?; if let Some(observed) = observed_file_version .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { if buf.file_version.as_deref() == Some(observed) { return Some(BufferExternalModificationOutcome { buffer: buf.clone(), self_write_echo: true, }); } buf.file_version = Some(observed.to_string()); } buf.mark_external_modified(actor); Some(BufferExternalModificationOutcome { buffer: buf.clone(), self_write_echo: false, }) } /// 标记 buffer 为 dirty(编辑器内容已修改)。 pub fn mark_dirty( &self, path: &ObjectWorkspacePath, content_hash: String, ) -> Option { let key = BufferKey::from_workspace_path(path); let mut inner = self.inner.write().expect("BufferStore lock"); if let Some(buf) = inner.buffers.get_mut(&key) { buf.mark_dirty(content_hash); Some(buf.clone()) } else { None } } /// 本地文件 rename/move 后重绑打开的 Markdown buffer。 pub fn rekey_local_folder_markdown( &self, workspace_id: &str, root_uri: &str, previous_relative_path: &str, previous_document_id: &str, next_relative_path: &str, next_document_id: &str, ) -> Option { let previous_path = build_local_folder_workspace_path( workspace_id, root_uri, previous_relative_path, previous_document_id, ); let next_path = build_local_folder_workspace_path( workspace_id, root_uri, next_relative_path, next_document_id, ); let previous_key = BufferKey::from_workspace_path(&previous_path); let next_key = BufferKey::from_workspace_path(&next_path); let mut inner = self.inner.write().expect("BufferStore lock"); let mut buffer = inner.buffers.remove(&previous_key)?; buffer.workspace_path = next_path; inner.buffers.insert(next_key, buffer.clone()); Some(buffer) } /// 本地文件 delete/archive/purge 后标记打开的 Markdown buffer 已删除。 pub fn mark_local_folder_markdown_deleted( &self, workspace_id: &str, root_uri: &str, relative_path: &str, document_id: &str, ) -> Option { let path = build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id); let key = BufferKey::from_workspace_path(&path); let mut inner = self.inner.write().expect("BufferStore lock"); if let Some(buf) = inner.buffers.get_mut(&key) { buf.mark_deleted(); Some(buf.clone()) } else { None } } /// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。 pub fn init_buffer( &self, path: &ObjectWorkspacePath, file_version: Option, base_content_hash: Option, ) -> DocumentBuffer { let mut buf = self.get_or_create(path); if file_version.is_some() || base_content_hash.is_some() { if let Some(fv) = file_version { buf.file_version = Some(fv); } if let Some(ch) = base_content_hash { buf.base_content_hash = Some(ch); } let key = BufferKey::from_workspace_path(path); self.update(&key, buf.clone()); } buf } /// 返回当前 buffer 数量(仅用于测试和监控)。 pub fn buffer_count(&self) -> usize { let inner = self.inner.read().expect("BufferStore lock"); inner.buffers.len() } /// 清除所有 buffers(测试用途)。 pub fn clear(&self) { let mut inner = self.inner.write().expect("BufferStore lock"); inner.buffers.clear(); } /// 返回当前所有 buffers 的拷贝(测试/监控用途)。 pub fn all_buffers(&self) -> Vec { let inner = self.inner.read().expect("BufferStore lock"); inner.buffers.values().cloned().collect() } } impl Default for BufferStore { fn default() -> Self { Self::new() } } // ── helpers ────────────────────────────────────────────────────────── /// 从 ObjectWorkspacePath 构建 buffer key 字符串(用于日志/调试)。 pub fn buffer_key_string(path: &ObjectWorkspacePath) -> String { format!( "{}:{}:{}:{}", path.workspace_id, path.root_uri, path.relative_path, path.object_identity.document_id.as_deref().unwrap_or("_") ) } /// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。 /// /// 在 save_local_markdown_page、watcher event 和 Hermes 写入链中统一使用此函数构造路径。 pub fn build_local_folder_workspace_path( workspace_id: &str, root_uri: &str, relative_path: &str, document_id: &str, ) -> ObjectWorkspacePath { ObjectWorkspacePath { workspace_id: workspace_id.to_string(), source_kind: WorkspaceSourceKind::LocalFolder, root_uri: root_uri.to_string(), relative_path: relative_path.to_string(), object_identity: KernelObjectIdentity { object_kind: KernelObjectKind::Page, document_id: Some(document_id.to_string()), block_id: None, asset_id: None, }, resource_kind: Some("document".to_string()), } } // ── tests ──────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use core_protocol::{ DocBufferDirtyState, DocumentBuffer, KernelObjectIdentity, KernelObjectKind, ObjectWorkspacePath, WorkspaceSourceKind, }; fn make_test_workspace_path( workspace_id: &str, relative_path: &str, document_id: &str, ) -> ObjectWorkspacePath { ObjectWorkspacePath { workspace_id: workspace_id.to_string(), source_kind: WorkspaceSourceKind::LocalFolder, root_uri: "file:///tmp/test-root".to_string(), relative_path: relative_path.to_string(), object_identity: KernelObjectIdentity { object_kind: KernelObjectKind::Page, document_id: Some(document_id.to_string()), block_id: None, asset_id: None, }, resource_kind: Some("document".to_string()), } } fn make_fresh_buffer(path: &ObjectWorkspacePath) -> DocumentBuffer { DocumentBuffer { workspace_path: path.clone(), file_version: Some("v1".to_string()), base_content_hash: Some("sha256:base".to_string()), current_content_hash: None, dirty_state: DocBufferDirtyState::Clean, last_loaded_at: Some(1_700_000_000_000i64), last_saved_at: Some(1_700_000_000_001i64), external_actor: None, last_write_intent_id: None, last_save_operation_id: None, } } #[test] fn document_buffer_reuses_workspace_path_key() { let store = BufferStore::new(); let path_a = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let path_b = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); // 同一个路径应得同一个 buffer let buf1 = store.get_or_create(&path_a); let buf2 = store.get_or_create(&path_b); assert_eq!( buf1.workspace_path.relative_path, buf2.workspace_path.relative_path ); assert_eq!( BufferKey::from_workspace_path(&path_a), BufferKey::from_workspace_path(&path_b) ); } #[test] fn document_buffer_marks_external_modified_for_clean_buffer() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let mut buf = make_fresh_buffer(&path); let key = BufferKey::from_workspace_path(&path); store.update(&key, buf.clone()); buf.mark_external_modified(Some("external-editor".into())); store.update(&key, buf); let result = store.get(&key).expect("buffer should exist"); assert_eq!(result.dirty_state, DocBufferDirtyState::ExternalModified); assert_eq!(result.external_actor.as_deref(), Some("external-editor")); } #[test] fn document_buffer_marks_stale_for_dirty_buffer() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let mut buf = make_fresh_buffer(&path); let key = BufferKey::from_workspace_path(&path); // 先 mark_dirty buf.mark_dirty("sha256:dirty".into()); store.update(&key, buf); // 外部修改 → 应变为 Stale let result = store.mark_external_modified(&path, Some("external-editor".into())); assert!(result.is_some()); assert_eq!( result.as_ref().unwrap().dirty_state, DocBufferDirtyState::Stale ); assert_eq!( result.as_ref().unwrap().external_actor.as_deref(), Some("external-editor") ); } #[test] fn document_buffer_ignores_watcher_echo_for_last_saved_version() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let key = BufferKey::from_workspace_path(&path); store.update(&key, make_fresh_buffer(&path)); let clean_echo = store .mark_external_modified_if_version_changed( &path, Some("v1".into()), Some("external-editor".into()), ) .expect("buffer"); assert_eq!(clean_echo.dirty_state, DocBufferDirtyState::Clean); assert_eq!(clean_echo.external_actor, None); let dirty = store .mark_dirty(&path, "sha256:dirty".into()) .expect("dirty"); assert_eq!(dirty.dirty_state, DocBufferDirtyState::Dirty); let dirty_echo = store .mark_external_modified_if_version_changed( &path, Some("v1".into()), Some("external-editor".into()), ) .expect("buffer"); assert_eq!(dirty_echo.dirty_state, DocBufferDirtyState::Dirty); assert_eq!(dirty_echo.external_actor, None); } #[test] fn document_buffer_marks_stale_when_dirty_buffer_sees_new_version() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let key = BufferKey::from_workspace_path(&path); let mut buf = make_fresh_buffer(&path); buf.mark_dirty("sha256:dirty".into()); store.update(&key, buf); let result = store .mark_external_modified_if_version_changed( &path, Some("v2".into()), Some("external-editor".into()), ) .expect("buffer"); assert_eq!(result.dirty_state, DocBufferDirtyState::Stale); assert_eq!(result.file_version.as_deref(), Some("v2")); assert_eq!(result.external_actor.as_deref(), Some("external-editor")); } #[test] fn document_buffer_records_save_operation_and_reports_self_write_echo() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let key = BufferKey::from_workspace_path(&path); let mut buf = make_fresh_buffer(&path); buf.mark_dirty("sha256:dirty".into()); store.update(&key, buf); let saved = store .mark_saved_with_operation( &path, "v2".into(), "sha256:v2".into(), Some("intent:editor:abc".into()), Some("save:op:abc".into()), ) .expect("保存后应更新 buffer"); assert_eq!(saved.last_write_intent_id.as_deref(), Some("intent:editor:abc")); assert_eq!(saved.last_save_operation_id.as_deref(), Some("save:op:abc")); let outcome = store .mark_external_modified_if_version_changed_with_outcome( &path, Some("v2".into()), Some("external-editor".into()), ) .expect("watcher 回声应返回 outcome"); assert!(outcome.self_write_echo, "同版本 watcher 回声应标记为自写回声"); assert_eq!( outcome.buffer.last_write_intent_id.as_deref(), Some("intent:editor:abc") ); assert_eq!( outcome.buffer.last_save_operation_id.as_deref(), Some("save:op:abc") ); assert_eq!(outcome.buffer.dirty_state, DocBufferDirtyState::Clean); } #[test] fn document_buffer_mark_saved_after_page_body_write() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let mut buf = make_fresh_buffer(&path); let key = BufferKey::from_workspace_path(&path); buf.mark_dirty("sha256:dirty".into()); store.update(&key, buf); // 保存后应变为 Clean,更新 file_version let result = store.mark_saved(&path, "v2".into(), "sha256:saved".into()); assert!(result.is_some()); let buf = result.unwrap(); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); assert_eq!(buf.file_version.as_deref(), Some("v2")); assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved")); assert!(!buf.is_dirty()); } #[test] fn document_buffer_different_paths_different_keys() { let store = BufferStore::new(); let path_a = make_test_workspace_path("ws_1", "doc-a.md", "local-md:doc-a.md"); let path_b = make_test_workspace_path("ws_2", "doc-b.md", "local-md:doc-b.md"); let key_a = BufferKey::from_workspace_path(&path_a); let key_b = BufferKey::from_workspace_path(&path_b); assert_ne!(key_a, key_b); let buf1 = store.get_or_create(&path_a); let buf2 = store.get_or_create(&path_b); assert_ne!( buf1.workspace_path.workspace_id, buf2.workspace_path.workspace_id ); } #[test] fn document_buffer_reuses_across_multiple_get_or_create_calls() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let buf1 = store.get_or_create(&path); let buf2 = store.get_or_create(&path); // 同一个路径的 buffer 应该共享同一 key assert_eq!( BufferKey::from_workspace_path(&buf1.workspace_path), BufferKey::from_workspace_path(&buf2.workspace_path) ); assert_eq!(store.buffer_count(), 1); } #[test] fn document_buffer_init_buffer_sets_file_version() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md"); let buf = store.init_buffer(&path, Some("v3".into()), Some("sha256:v3base".into())); assert_eq!(buf.file_version.as_deref(), Some("v3")); assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:v3base")); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); // 再次获取应保留状态 let buf2 = store.get_or_create(&path); assert_eq!(buf2.file_version.as_deref(), Some("v3")); assert_eq!(store.buffer_count(), 1); } #[test] fn document_buffer_mark_dirty_and_saved_round_trip() { let store = BufferStore::new(); let path = make_test_workspace_path("ws_1", "doc-cycle.md", "local-md:doc-cycle.md"); // init → mark_dirty → mark_saved → 应回到 Clean let _ = store.init_buffer(&path, Some("v1".into()), Some("sha256:base".into())); let dirty = store.mark_dirty(&path, "sha256:dirty".into()); assert!(dirty.is_some()); assert_eq!(dirty.unwrap().dirty_state, DocBufferDirtyState::Dirty); let saved = store.mark_saved(&path, "v2".into(), "sha256:saved".into()); assert!(saved.is_some()); let buf = saved.unwrap(); assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean); assert_eq!(buf.file_version.as_deref(), Some("v2")); assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved")); } #[test] fn document_buffer_rekeys_after_local_file_operation_rename() { let store = BufferStore::new(); let root_uri = "file:///tmp/mnote-buffer-rekey"; let old_path = build_local_folder_workspace_path( "local:test", root_uri, "docs/Old.md", "local-md:docs~2FOld.md", ); store.init_buffer(&old_path, Some("v1".into()), Some("sha256:old".into())); let rekeyed = store .rekey_local_folder_markdown( "local:test", root_uri, "docs/Old.md", "local-md:docs~2FOld.md", "docs/New.md", "local-md:docs~2FNew.md", ) .expect("buffer should be rekeyed"); assert_eq!(rekeyed.workspace_path.relative_path, "docs/New.md"); assert_eq!( rekeyed .workspace_path .object_identity .document_id .as_deref(), Some("local-md:docs~2FNew.md") ); assert!(store.get_by_path(&old_path).is_none()); let next_path = build_local_folder_workspace_path( "local:test", root_uri, "docs/New.md", "local-md:docs~2FNew.md", ); assert!(store.get_by_path(&next_path).is_some()); } #[test] fn document_buffer_marks_deleted_after_local_file_operation_archive() { let store = BufferStore::new(); let root_uri = "file:///tmp/mnote-buffer-delete"; let path = build_local_folder_workspace_path( "local:test", root_uri, "docs/Delete.md", "local-md:docs~2FDelete.md", ); store.get_or_create(&path); let deleted = store .mark_local_folder_markdown_deleted( "local:test", root_uri, "docs/Delete.md", "local-md:docs~2FDelete.md", ) .expect("buffer should be marked deleted"); assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted); } }