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

737 lines
26 KiB
Rust

//! 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>,
}
#[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<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> {
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<String>,
save_operation_id: 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_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<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
}
}
/// 带磁盘版本的外部修改标记。
///
/// 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<String>,
actor: Option<String>,
) -> Option<DocumentBuffer> {
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<String>,
actor: Option<String>,
) -> Option<BufferExternalModificationOutcome> {
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<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
}
}
/// 本地文件 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<DocumentBuffer> {
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<DocumentBuffer> {
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<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,
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);
}
}