737 lines
26 KiB
Rust
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);
|
|
}
|
|
}
|