收口本地工作区清理与资源投影

清理历史 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:
lix-2026
2026-05-20 10:43:38 +08:00
parent 90818cdf75
commit b4c8bcb647
73 changed files with 8073 additions and 8240 deletions
+642
View File
@@ -188,3 +188,645 @@ pub struct EmbedBlock {
pub struct DeleteBlock {
pub block_id: String,
}
// ── CommandContext / when clause ────────────────────────────────────
/// CommandContext — 命令执行上下文的 key-value 集合。
///
/// 对应 VS Code 的 context key 概念。每个 key 表示当前 UI/workspace/editor 的状态片段。
/// 菜单项、快捷键、按钮的 enablement 通过 `when` 表达式对 context 求值得到。
///
/// 第一阶段支持 key 列表:
/// - `workspace.sourceKind` — 工作区类型 ("local_folder" | "convex" | ...)
/// - `workspace.readonly` — bool,工作区是否只读
/// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none")
/// - `tree.selectionCount` — 当前选中行数 (i64)
/// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...)
/// - `editor.dirty` — bool,当前编辑器是否有未保存修改
/// - `editor.hasSelection` — bool,编辑器是否有文本选区
/// - `ai.canWrite` — bool,当前会话是否允许 AI 写入
#[derive(Debug, Clone, PartialEq)]
pub struct CommandContext {
/// 工作区来源类型
pub workspace_source_kind: Option<String>,
/// 工作区是否只读
pub workspace_readonly: bool,
/// 树焦点类型
pub tree_focus_kind: Option<String>,
/// 树选择计数
pub tree_selection_count: i64,
/// 选择资源的 kind
pub tree_selection_resource_kind: Option<String>,
/// 编辑器是否 dirty
pub editor_dirty: bool,
/// 编辑器是否有选区
pub editor_has_selection: bool,
/// AI 是否可写
pub ai_can_write: bool,
}
impl CommandContext {
pub fn new() -> Self {
Self {
workspace_source_kind: None,
workspace_readonly: false,
tree_focus_kind: None,
tree_selection_count: 0,
tree_selection_resource_kind: None,
editor_dirty: false,
editor_has_selection: false,
ai_can_write: true,
}
}
/// 获取命名 context key 的值(用于 when 表达式求值)。
pub fn get(&self, key: &str) -> Option<CommandContextValue> {
match key {
"workspace.sourceKind" => self
.workspace_source_kind
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"workspace.readonly" => Some(CommandContextValue::Bool(self.workspace_readonly)),
"tree.focusKind" => self
.tree_focus_kind
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"tree.selectionCount" => Some(CommandContextValue::Number(self.tree_selection_count)),
"tree.selectionResourceKind" => self
.tree_selection_resource_kind
.as_deref()
.map(|s| CommandContextValue::String(s.to_string())),
"editor.dirty" => Some(CommandContextValue::Bool(self.editor_dirty)),
"editor.hasSelection" => Some(CommandContextValue::Bool(self.editor_has_selection)),
"ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)),
_ => None,
}
}
/// 用 builder 模式设置 workspace 只读。
pub fn with_workspace_readonly(mut self, readonly: bool) -> Self {
self.workspace_readonly = readonly;
self
}
/// 用 builder 模式设置 workspace source kind。
pub fn with_workspace_source_kind(mut self, kind: &str) -> Self {
self.workspace_source_kind = Some(kind.to_string());
self
}
/// 用 builder 模式设置 tree focus kind。
pub fn with_tree_focus_kind(mut self, kind: &str) -> Self {
self.tree_focus_kind = Some(kind.to_string());
self
}
/// 用 builder 模式设置 tree selection count。
pub fn with_tree_selection_count(mut self, count: i64) -> Self {
self.tree_selection_count = count;
self
}
/// 用 builder 模式设置 selection resource kind。
pub fn with_tree_selection_resource_kind(mut self, kind: &str) -> Self {
self.tree_selection_resource_kind = Some(kind.to_string());
self
}
/// 用 builder 模式设置 editor dirty。
pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
self.editor_dirty = dirty;
self
}
/// 用 builder 模式设置 editor has selection。
pub fn with_editor_has_selection(mut self, has_selection: bool) -> Self {
self.editor_has_selection = has_selection;
self
}
/// 用 builder 模式设置 ai can write。
pub fn with_ai_can_write(mut self, can_write: bool) -> Self {
self.ai_can_write = can_write;
self
}
}
impl Default for CommandContext {
fn default() -> Self {
Self::new()
}
}
/// 命令上下文中的值类型。
#[derive(Debug, Clone, PartialEq)]
pub enum CommandContextValue {
Bool(bool),
Number(i64),
String(String),
}
impl CommandContextValue {
fn as_bool(&self) -> Option<bool> {
match self {
CommandContextValue::Bool(b) => Some(*b),
_ => None,
}
}
fn as_string(&self) -> Option<&str> {
match self {
CommandContextValue::String(s) => Some(s.as_str()),
_ => None,
}
}
}
/// WhenExprToken — when 表达式的最小 token 类型。
#[derive(Debug, Clone, PartialEq)]
pub enum WhenExprToken {
/// `key`
Key(String),
/// `!key`
NotKey(String),
/// `key == value`
Equal(String, String),
/// `key != value`
NotEqual(String, String),
/// `&&`
And,
/// `||`
Or,
/// `(` ... `)`
LParen,
RParen,
}
/// WhenExpr — when 表达式的 AST 节点。
/// 支持:`key`、`!key`、`key == value`、`key != value`、`&&`、`||`、括号分组。
#[derive(Debug, Clone, PartialEq)]
pub enum WhenExprNode {
Key(String),
NotKey(String),
Equal(String, String),
NotEqual(String, String),
And(Box<WhenExprNode>, Box<WhenExprNode>),
Or(Box<WhenExprNode>, Box<WhenExprNode>),
}
/// 简单的递归下降 when 表达式解析器。
/// 语法:
/// expr = or_expr
/// or_expr = and_expr ("||" and_expr)*
/// and_expr = primary ("&&" primary)*
/// primary = "(" expr ")" | "!" key | "key == value" | "key != value" | key
pub fn parse_when_expr(input: &str) -> Result<WhenExprNode, String> {
let tokens = tokenize(input)?;
let (node, rest) = parse_or(&tokens)?;
if !rest.is_empty() {
return Err(format!("when 表达式末尾有多余 token: {:?}", rest));
}
Ok(node)
}
fn tokenize(input: &str) -> Result<Vec<WhenExprToken>, String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
match ch {
'(' => {
chars.next();
tokens.push(WhenExprToken::LParen);
}
')' => {
chars.next();
tokens.push(WhenExprToken::RParen);
}
'!' => {
chars.next();
// ! 后面跟 key(不能是 "("
let key = read_identifier(&mut chars)?;
tokens.push(WhenExprToken::NotKey(key));
}
'|' => {
chars.next();
if chars.next() == Some('|') {
tokens.push(WhenExprToken::Or);
} else {
return Err("期望 || 运算符,但只看到一个 |".to_string());
}
}
'&' => {
chars.next();
if chars.next() == Some('&') {
tokens.push(WhenExprToken::And);
} else {
return Err("期望 && 运算符,但只看到一个 &".to_string());
}
}
'=' => {
return Err("意外的 = 符号,应为 ==".to_string());
}
_ if is_ident_start(ch) => {
let key = read_identifier(&mut chars)?;
// 检查后面是否跟着 == 或 !=
let mut next_chars = chars.clone();
let after_ws = next_chars.find(|c| !c.is_whitespace());
let is_comparison = matches!(after_ws, Some('=') | Some('!'));
if is_comparison {
// 跳过空白
while chars.peek().map_or(false, |c| c.is_whitespace()) {
chars.next();
}
let op_start = chars.next(); // = or !
let op_next = chars.next(); // =
if op_start == Some('=') && op_next == Some('=') {
// ==
while chars.peek().map_or(false, |c| c.is_whitespace()) {
chars.next();
}
let value = read_value(&mut chars)?;
tokens.push(WhenExprToken::Equal(key, value));
} else if op_start == Some('!') && op_next == Some('=') {
// !=
while chars.peek().map_or(false, |c| c.is_whitespace()) {
chars.next();
}
let value = read_value(&mut chars)?;
tokens.push(WhenExprToken::NotEqual(key, value));
} else {
return Err(format!("key '{}' 后面的运算符需为 == 或 !=", key));
}
} else {
tokens.push(WhenExprToken::Key(key));
}
}
_ => {
return Err(format!("意外的字符: '{}'", ch));
}
}
}
Ok(tokens)
}
fn read_identifier(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, String> {
let mut ident = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_alphanumeric() || ch == '_' || ch == '.' {
ident.push(ch);
chars.next();
} else {
break;
}
}
if ident.is_empty() {
return Err("期望标识符".to_string());
}
Ok(ident)
}
fn read_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, String> {
let mut value = String::new();
// 跳过空白 (已经在调用前跳过了)
// 检查是否有引号
if chars.peek() == Some(&'"') || chars.peek() == Some(&'\'') {
let quote = chars.next().unwrap();
while let Some(&ch) = chars.peek() {
if ch == quote {
chars.next();
return Ok(value);
}
value.push(ch);
chars.next();
}
return Err("未闭合的引号".to_string());
}
// 无引号值:读取到下一个空白或运算符为止
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() || ch == ')' || ch == '|' || ch == '&' {
break;
}
value.push(ch);
chars.next();
}
if value.is_empty() {
return Err("期望值".to_string());
}
Ok(value)
}
fn is_ident_start(ch: char) -> bool {
ch.is_ascii_alphabetic() || ch == '_'
}
fn parse_or<'a>(
tokens: &'a [WhenExprToken],
) -> Result<(WhenExprNode, &'a [WhenExprToken]), String> {
let (mut left, mut rest) = parse_and(tokens)?;
while let Some(WhenExprToken::Or) = rest.first() {
rest = &rest[1..];
let (right, new_rest) = parse_and(rest)?;
left = WhenExprNode::Or(Box::new(left), Box::new(right));
rest = new_rest;
}
Ok((left, rest))
}
fn parse_and<'a>(
tokens: &'a [WhenExprToken],
) -> Result<(WhenExprNode, &'a [WhenExprToken]), String> {
let (mut left, mut rest) = parse_primary(tokens)?;
while let Some(WhenExprToken::And) = rest.first() {
rest = &rest[1..];
let (right, new_rest) = parse_primary(rest)?;
left = WhenExprNode::And(Box::new(left), Box::new(right));
rest = new_rest;
}
Ok((left, rest))
}
fn parse_primary<'a>(
tokens: &'a [WhenExprToken],
) -> Result<(WhenExprNode, &'a [WhenExprToken]), String> {
if let Some(token) = tokens.first() {
match token {
WhenExprToken::Key(key) => Ok((WhenExprNode::Key(key.clone()), &tokens[1..])),
WhenExprToken::NotKey(key) => Ok((WhenExprNode::NotKey(key.clone()), &tokens[1..])),
WhenExprToken::Equal(key, value) => Ok((
WhenExprNode::Equal(key.clone(), value.clone()),
&tokens[1..],
)),
WhenExprToken::NotEqual(key, value) => Ok((
WhenExprNode::NotEqual(key.clone(), value.clone()),
&tokens[1..],
)),
WhenExprToken::LParen => {
let (node, rest) = parse_or(&tokens[1..])?;
if let Some(WhenExprToken::RParen) = rest.first() {
Ok((node, &rest[1..]))
} else {
Err("缺少闭合的 ')'".to_string())
}
}
_ => Err(format!("期望表达式,得到 {:?}", token)),
}
} else {
Err("期望表达式,但没有更多 token".to_string())
}
}
/// 对 `CommandContext` 求值一个 when 表达式。
pub fn evaluate_when(ctx: &CommandContext, expr: &WhenExprNode) -> bool {
match expr {
WhenExprNode::Key(key) => ctx.get(key).map_or(false, |v| match v {
CommandContextValue::Bool(b) => b,
CommandContextValue::Number(n) => n != 0,
CommandContextValue::String(s) => !s.is_empty(),
}),
WhenExprNode::NotKey(key) => !ctx.get(key).map_or(false, |v| match v {
CommandContextValue::Bool(b) => b,
CommandContextValue::Number(n) => n != 0,
CommandContextValue::String(s) => !s.is_empty(),
}),
WhenExprNode::Equal(key, expected) => ctx.get(key).map_or(false, |v| {
v.as_string().map_or(false, |s| s == expected)
|| expected
.parse::<i64>()
.ok()
.is_some_and(|expected_number| {
matches!(v, CommandContextValue::Number(n) if n == expected_number)
})
|| v.as_bool().map_or(false, |b| {
expected == "true" && b || expected == "false" && !b
})
}),
WhenExprNode::NotEqual(key, expected) => ctx.get(key).map_or(true, |v| {
v.as_string().map_or(true, |s| s != expected)
&& expected
.parse::<i64>()
.ok()
.is_none_or(|expected_number| {
!matches!(v, CommandContextValue::Number(n) if n == expected_number)
})
&& v.as_bool().map_or(true, |b| {
!(expected == "true" && b || expected == "false" && !b)
})
}),
WhenExprNode::And(left, right) => evaluate_when(ctx, left) && evaluate_when(ctx, right),
WhenExprNode::Or(left, right) => evaluate_when(ctx, left) || evaluate_when(ctx, right),
}
}
/// 解析并求值一条 when 表达式字符串。
pub fn check_when(ctx: &CommandContext, when_expr: &str) -> Result<bool, String> {
let expr = parse_when_expr(when_expr)?;
Ok(evaluate_when(ctx, &expr))
}
/// 检查一个命令在指定上下文中是否应 enabled。
/// 如果 when 表达式为 None 或空,默认返回 true。
pub fn is_command_enabled(ctx: &CommandContext, when: Option<&str>) -> bool {
match when {
None | Some("") => true,
Some(expr) => check_when(ctx, expr).unwrap_or(true),
}
}
#[cfg(test)]
mod command_context_tests {
use super::*;
#[test]
fn command_context_when_clause() {
let ctx = CommandContext::new()
.with_workspace_readonly(false)
.with_workspace_source_kind("local_folder")
.with_tree_focus_kind("file_tree")
.with_tree_selection_count(1)
.with_tree_selection_resource_kind("page")
.with_editor_dirty(false)
.with_ai_can_write(true);
// 基础 key 表达式(bool
assert!(check_when(&ctx, "ai.canWrite").unwrap());
assert!(!check_when(&ctx, "editor.dirty").unwrap());
assert!(!check_when(&ctx, "workspace.readonly").unwrap());
// !key
assert!(check_when(&ctx, "!editor.dirty").unwrap());
assert!(!check_when(&ctx, "!ai.canWrite").unwrap());
// key == value
assert!(check_when(&ctx, "tree.focusKind == file_tree").unwrap());
assert!(!check_when(&ctx, "tree.focusKind == page_tree").unwrap());
// key != value
assert!(check_when(&ctx, "tree.focusKind != page_tree").unwrap());
assert!(!check_when(&ctx, "tree.focusKind != file_tree").unwrap());
// && 组合
assert!(check_when(&ctx, "ai.canWrite && !workspace.readonly").unwrap());
assert!(!check_when(&ctx, "ai.canWrite && workspace.readonly").unwrap());
assert!(!check_when(&ctx, "editor.dirty && ai.canWrite").unwrap());
// || 组合
assert!(check_when(&ctx, "editor.dirty || ai.canWrite").unwrap());
assert!(!check_when(&ctx, "editor.dirty || workspace.readonly").unwrap());
// 括号分组
assert!(check_when(&ctx, "(ai.canWrite && !workspace.readonly) || editor.dirty").unwrap());
assert!(!check_when(&ctx, "editor.dirty && (ai.canWrite || workspace.readonly)").unwrap());
}
#[test]
fn command_context_disables_write_commands_for_readonly_workspace() {
let write_commands = vec![
(
"tree.node.create",
Some("!workspace.readonly && ai.canWrite"),
),
("tree.node.rename", Some("!workspace.readonly")),
("tree.node.delete", Some("!workspace.readonly")),
("tree.node.move", Some("!workspace.readonly")),
];
let ctx_readonly = CommandContext::new()
.with_workspace_readonly(true)
.with_ai_can_write(true);
let ctx_writable = CommandContext::new()
.with_workspace_readonly(false)
.with_ai_can_write(true);
for (_name, when) in &write_commands {
// 只读上下文应禁用所有写命令
assert!(
!is_command_enabled(&ctx_readonly, *when),
"只读上下文不应启用命令 {:?}",
_name
);
// 可写上下文应启用
assert!(
is_command_enabled(&ctx_writable, *when),
"可写上下文应启用命令 {:?}",
_name
);
}
}
#[test]
fn command_context_filters_resource_commands_by_kind() {
let folder_commands = vec![
("tree.node.create", Some("tree.focusKind == file_tree")),
(
"tree.folder.rename",
Some("tree.selectionResourceKind == folder"),
),
];
let page_commands = vec![
("page.open", Some("tree.selectionResourceKind == page")),
("page.delete", Some("tree.selectionResourceKind == page")),
];
let mindmap_commands = vec![(
"mindmap.open",
Some("tree.selectionResourceKind == mindmap"),
)];
let ctx_folder = CommandContext::new()
.with_tree_focus_kind("file_tree")
.with_tree_selection_resource_kind("folder")
.with_tree_selection_count(1);
let ctx_page = CommandContext::new()
.with_tree_focus_kind("file_tree")
.with_tree_selection_resource_kind("page")
.with_tree_selection_count(1);
let ctx_mindmap = CommandContext::new()
.with_tree_focus_kind("file_tree")
.with_tree_selection_resource_kind("mindmap")
.with_tree_selection_count(1);
// folder 上下文
for (_name, when) in &folder_commands {
assert!(
is_command_enabled(&ctx_folder, *when),
"folder ctx 应启用命令 {:?}",
_name
);
}
for (_name, when) in &page_commands {
assert!(
!is_command_enabled(&ctx_folder, *when),
"folder ctx 不应启用 page 命令 {:?}",
_name
);
}
// page 上下文
for (_name, when) in &page_commands {
assert!(
is_command_enabled(&ctx_page, *when),
"page ctx 应启用 page 命令 {:?}",
_name
);
}
assert!(
!is_command_enabled(&ctx_page, Some("tree.selectionResourceKind == mindmap")),
"page ctx 不应启用 mindmap 命令"
);
// mindmap 上下文
for (_name, when) in &mindmap_commands {
assert!(
is_command_enabled(&ctx_mindmap, *when),
"mindmap ctx 应启用 mindmap 命令 {:?}",
_name
);
}
}
#[test]
fn command_context_default_is_allow() {
let ctx = CommandContext::new();
// 没有 when 表达式 → true
assert!(is_command_enabled(&ctx, None));
assert!(is_command_enabled(&ctx, Some("")));
}
#[test]
fn command_context_number_comparison() {
let ctx = CommandContext::new().with_tree_selection_count(3);
// 数字值在 bool 语境下:非零为 true
assert!(check_when(&ctx, "tree.selectionCount").unwrap());
assert!(check_when(&ctx, "tree.selectionCount == 3").unwrap());
assert!(check_when(&ctx, "tree.selectionCount != 1").unwrap());
assert!(!check_when(&ctx, "tree.selectionCount == 1").unwrap());
let ctx_zero = CommandContext::new().with_tree_selection_count(0);
assert!(!check_when(&ctx_zero, "tree.selectionCount").unwrap());
assert!(check_when(&ctx_zero, "tree.selectionCount == 0").unwrap());
}
#[test]
fn command_context_parse_error_returns_default() {
let ctx = CommandContext::new();
// 解析错误 → is_command_enabled 返回 true(默认允许)
assert!(is_command_enabled(&ctx, Some("invalid syntax &&&")));
}
#[test]
fn command_context_ai_can_write_gates_ai_actions() {
let ctx_can_write = CommandContext::new().with_ai_can_write(true);
let ctx_readonly = CommandContext::new().with_ai_can_write(false);
assert!(is_command_enabled(&ctx_can_write, Some("ai.canWrite")));
assert!(!is_command_enabled(&ctx_readonly, Some("ai.canWrite")));
}
}
+6 -5
View File
@@ -15,11 +15,12 @@ pub use ai::{
AiShareContext, AiStructuredWriteKind, AiStructuredWriteResult, AiToolCall,
};
pub use command::{
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
MoveDocumentPage, PatchBlock, PatchPageBlock, PutMindmap, ReplaceMediaAssetStorage,
RestoreDocumentPage, SavePageContent, UpdateBlock, UpdatePageOptions, UpdatePageStats,
UpdatePageTitle,
check_when, evaluate_when, is_command_enabled, parse_when_expr, CommandContext,
CommandContextValue, CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage,
CreateWorkspace, DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock,
InsertBlock, MoveBlock, MoveDocumentPage, PatchBlock, PatchPageBlock, PutMindmap,
ReplaceMediaAssetStorage, RestoreDocumentPage, SavePageContent, UpdateBlock, UpdatePageOptions,
UpdatePageStats, UpdatePageTitle, WhenExprNode, WhenExprToken,
};
pub use common::{
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,