收口本地工作区清理与资源投影
清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。 新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。 扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。 验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user