收口本地工作区清理与资源投影
清理历史 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:
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user