feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
+88
View File
@@ -202,7 +202,12 @@ pub struct DeleteBlock {
/// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none")
/// - `tree.selectionCount` — 当前选中行数 (i64)
/// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...)
/// - `tree.targetResourceKind` — 当前命令目标资源类型
/// - `tree.targetRowKind` — 当前命令目标行类型
/// - `tree.targetIsFolder` — 当前命令目标是否文件夹
/// - `tree.targetIsAsset` — 当前命令目标是否资源文件
/// - `editor.dirty` — bool,当前编辑器是否有未保存修改
/// - `editor.dirtyState` — 当前编辑器 dirty 状态 ("clean" | "dirty" | "stale" | ...)
/// - `editor.hasSelection` — bool,编辑器是否有文本选区
/// - `ai.canWrite` — bool,当前会话是否允许 AI 写入
#[derive(Debug, Clone, PartialEq)]
@@ -217,8 +222,18 @@ pub struct CommandContext {
pub tree_selection_count: i64,
/// 选择资源的 kind
pub tree_selection_resource_kind: Option<String>,
/// 当前命令目标资源 kind
pub tree_target_resource_kind: Option<String>,
/// 当前命令目标行 kind
pub tree_target_row_kind: Option<String>,
/// 当前命令目标是否文件夹
pub tree_target_is_folder: bool,
/// 当前命令目标是否资源文件
pub tree_target_is_asset: bool,
/// 编辑器是否 dirty
pub editor_dirty: bool,
/// 编辑器 dirty 状态
pub editor_dirty_state: Option<String>,
/// 编辑器是否有选区
pub editor_has_selection: bool,
/// AI 是否可写
@@ -233,7 +248,12 @@ impl CommandContext {
tree_focus_kind: None,
tree_selection_count: 0,
tree_selection_resource_kind: None,
tree_target_resource_kind: None,
tree_target_row_kind: None,
tree_target_is_folder: false,
tree_target_is_asset: false,
editor_dirty: false,
editor_dirty_state: Some("clean".to_string()),
editor_has_selection: false,
ai_can_write: true,
}
@@ -256,7 +276,21 @@ impl CommandContext {
.tree_selection_resource_kind
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"tree.targetResourceKind" => self
.tree_target_resource_kind
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"tree.targetRowKind" => self
.tree_target_row_kind
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"tree.targetIsFolder" => Some(CommandContextValue::Bool(self.tree_target_is_folder)),
"tree.targetIsAsset" => Some(CommandContextValue::Bool(self.tree_target_is_asset)),
"editor.dirty" => Some(CommandContextValue::Bool(self.editor_dirty)),
"editor.dirtyState" => self
.editor_dirty_state
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"editor.hasSelection" => Some(CommandContextValue::Bool(self.editor_has_selection)),
"ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)),
_ => None,
@@ -293,9 +327,44 @@ impl CommandContext {
self
}
/// 用 builder 模式设置命令目标 resource kind。
pub fn with_tree_target_resource_kind(mut self, kind: &str) -> Self {
self.tree_target_resource_kind = Some(kind.to_string());
self
}
/// 用 builder 模式设置命令目标 row kind。
pub fn with_tree_target_row_kind(mut self, kind: &str) -> Self {
self.tree_target_row_kind = Some(kind.to_string());
self
}
/// 用 builder 模式设置命令目标是否文件夹。
pub fn with_tree_target_is_folder(mut self, is_folder: bool) -> Self {
self.tree_target_is_folder = is_folder;
self
}
/// 用 builder 模式设置命令目标是否资源文件。
pub fn with_tree_target_is_asset(mut self, is_asset: bool) -> Self {
self.tree_target_is_asset = is_asset;
self
}
/// 用 builder 模式设置 editor dirty。
pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
self.editor_dirty = dirty;
if dirty && self.editor_dirty_state.as_deref() == Some("clean") {
self.editor_dirty_state = Some("dirty".to_string());
}
self
}
/// 用 builder 模式设置 editor dirty 状态。
pub fn with_editor_dirty_state(mut self, state: &str) -> Self {
let normalized = state.to_lowercase();
self.editor_dirty_state = Some(normalized.clone());
self.editor_dirty = normalized != "clean";
self
}
@@ -800,6 +869,25 @@ mod command_context_tests {
assert!(is_command_enabled(&ctx, Some("")));
}
#[test]
fn command_context_exposes_target_and_dirty_state_keys() {
let ctx = CommandContext::new()
.with_tree_target_resource_kind("folder")
.with_tree_target_row_kind("directory")
.with_tree_target_is_folder(true)
.with_tree_target_is_asset(false)
.with_editor_dirty_state("Dirty")
.with_ai_can_write(false);
assert!(check_when(&ctx, "tree.targetResourceKind == folder").unwrap());
assert!(check_when(&ctx, "tree.targetRowKind == directory").unwrap());
assert!(check_when(&ctx, "tree.targetIsFolder").unwrap());
assert!(!check_when(&ctx, "tree.targetIsAsset").unwrap());
assert!(check_when(&ctx, "editor.dirty").unwrap());
assert!(check_when(&ctx, "editor.dirtyState == dirty").unwrap());
assert!(!check_when(&ctx, "ai.canWrite").unwrap());
}
#[test]
fn command_context_number_comparison() {
let ctx = CommandContext::new().with_tree_selection_count(3);
+38
View File
@@ -94,6 +94,10 @@ pub struct PageBodyWriteRequest {
alias = "conflict_detection_key"
)]
pub expected_file_version: Option<String>,
#[serde(default, alias = "write_intent_id")]
pub write_intent_id: Option<String>,
#[serde(default, alias = "save_operation_id")]
pub save_operation_id: Option<String>,
#[serde(default)]
pub base_content_hash: Option<String>,
#[serde(default = "default_page_body_content_format")]
@@ -226,6 +230,12 @@ pub struct DocumentBuffer {
pub last_saved_at: Option<i64>,
/// 外部修改者
pub external_actor: Option<String>,
/// 最近一次由 MNote 发起写入的意图 ID。
#[serde(default)]
pub last_write_intent_id: Option<String>,
/// 最近一次保存请求实例 ID。
#[serde(default)]
pub last_save_operation_id: Option<String>,
}
impl DocumentBuffer {
@@ -240,10 +250,26 @@ impl DocumentBuffer {
}
pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
self.mark_saved_with_operation(file_version, content_hash, None, None);
}
pub fn mark_saved_with_operation(
&mut self,
file_version: String,
content_hash: String,
write_intent_id: Option<String>,
save_operation_id: Option<String>,
) {
self.file_version = Some(file_version);
self.base_content_hash = Some(content_hash);
self.current_content_hash = None;
self.dirty_state = DocBufferDirtyState::Clean;
if write_intent_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
self.last_write_intent_id = write_intent_id;
}
if save_operation_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
self.last_save_operation_id = save_operation_id;
}
self.last_saved_at = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -881,6 +907,8 @@ mod tests {
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space",
"expectedFileVersion": "local-md:local-md:README.md:1:2:hash",
"writeIntentId": "intent:editor:abc",
"saveOperationId": "save:op:abc",
"baseContentHash": "sha256:base",
"contentFormat": "editorBlocks",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}],
@@ -894,6 +922,8 @@ mod tests {
request.expected_file_version.as_deref(),
Some("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(request.write_intent_id.as_deref(), Some("intent:editor:abc"));
assert_eq!(request.save_operation_id.as_deref(), Some("save:op:abc"));
assert_eq!(request.content_format, "editorBlocks");
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
@@ -902,6 +932,8 @@ mod tests {
value["expectedFileVersion"],
json!("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(value["writeIntentId"], json!("intent:editor:abc"));
assert_eq!(value["saveOperationId"], json!("save:op:abc"));
assert_eq!(value["baseContentHash"], json!("sha256:base"));
assert_eq!(value["contentFormat"], json!("editorBlocks"));
assert_eq!(value["editorSource"], json!("tiptap"));
@@ -1009,11 +1041,15 @@ mod tests {
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: Some("intent:editor:abc".into()),
last_save_operation_id: Some("save:op:abc".into()),
};
let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化");
assert_eq!(value["dirtyState"], json!("clean"));
assert_eq!(value["fileVersion"], json!("v1"));
assert_eq!(value["lastWriteIntentId"], json!("intent:editor:abc"));
assert_eq!(value["lastSaveOperationId"], json!("save:op:abc"));
assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md"));
let decoded: DocumentBuffer =
@@ -1046,6 +1082,8 @@ mod tests {
last_loaded_at: None,
last_saved_at: None,
external_actor: None,
last_write_intent_id: None,
last_save_operation_id: None,
};
assert!(!buf.is_dirty());