Files
mnote/rust/crates/mnote-web/src/document_buffer_store.rs
T

453 lines
16 KiB
Rust
Raw Normal View History

2026-05-20 10:43:38 +08:00
//! 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<String>,
}
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<String>,
) -> 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<RwLock<BufferStoreInner>>,
}
#[derive(Debug)]
struct BufferStoreInner {
buffers: HashMap<BufferKey, DocumentBuffer>,
}
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,
};
inner.buffers.insert(key, buf.clone());
buf
}
/// 按 key 获取 buffer(不创建)。
pub fn get(&self, key: &BufferKey) -> Option<DocumentBuffer> {
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<DocumentBuffer> {
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<DocumentBuffer> {
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(file_version, content_hash);
Some(buf.clone())
} else {
None
}
}
/// 标记外部修改:如果 buffer 是 Clean → ExternalModified;是 Dirty → Stale。
pub fn mark_external_modified(
&self,
path: &ObjectWorkspacePath,
actor: Option<String>,
) -> Option<DocumentBuffer> {
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
}
}
/// 标记 buffer 为 dirty(编辑器内容已修改)。
pub fn mark_dirty(
&self,
path: &ObjectWorkspacePath,
content_hash: String,
) -> Option<DocumentBuffer> {
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
}
}
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
pub fn init_buffer(
&self,
path: &ObjectWorkspacePath,
file_version: Option<String>,
base_content_hash: Option<String>,
) -> 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<DocumentBuffer> {
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,
}
}
#[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_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"));
}
}