feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -6,6 +6,5 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
core-domain = { path = "../core-domain" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user