batch E: close tail checks and refresh smoke evidence
This commit is contained in:
@@ -3990,6 +3990,142 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
menu.appendChild(button);
|
||||
}
|
||||
|
||||
// ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ──
|
||||
|
||||
function buildSidebarFileTreeContext(kind) {
|
||||
var s = sidebarFileTreeSelection;
|
||||
var rowIds = Array.from(s.selectedRowIds || []);
|
||||
var rows = [];
|
||||
for (var i = 0; i < rowIds.length; i += 1) {
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowIds[i]) + '"]');
|
||||
if (row instanceof HTMLElement) rows.push(row);
|
||||
}
|
||||
var resourceKinds = {};
|
||||
for (var i = 0; i < rows.length; i += 1) {
|
||||
var rk = rows[i].getAttribute('data-row-kind') || 'unknown';
|
||||
resourceKinds[rk] = true;
|
||||
}
|
||||
var rkKeys = Object.keys(resourceKinds);
|
||||
return {
|
||||
'workspace.sourceKind': currentSourceKind() || '',
|
||||
'workspace.readonly': document.documentElement.getAttribute('data-mnote-workspace-readonly') === 'true',
|
||||
'tree.focusKind': kind === 'filetree' ? 'file_tree' : kind === 'page' ? 'page_tree' : kind,
|
||||
'tree.selectionCount': rows.length,
|
||||
'tree.selectionResourceKind': rkKeys.length === 1 ? rkKeys[0] : 'mixed'
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateSidebarFileTreeWhen(ctx, expr) {
|
||||
if (!expr) return true;
|
||||
try {
|
||||
expr = expr.trim();
|
||||
// 词法切分:支持 key、!key、==、!=、&&、|| 和括号分组
|
||||
var tokens = [];
|
||||
var i = 0;
|
||||
while (i < expr.length) {
|
||||
var ch = expr[i];
|
||||
if (ch === ' ' || ch === '\t') { i++; continue; }
|
||||
if (ch === '(') { tokens.push({ t: '(' }); i++; continue; }
|
||||
if (ch === ')') { tokens.push({ t: ')' }); i++; continue; }
|
||||
if (ch === '&' && expr[i+1] === '&') { tokens.push({ t: '&&' }); i += 2; continue; }
|
||||
if (ch === '|' && expr[i+1] === '|') { tokens.push({ t: '||' }); i += 2; continue; }
|
||||
if (ch === '=' && expr[i+1] === '=') { tokens.push({ t: '==' }); i += 2; continue; }
|
||||
if (ch === '!' && expr[i+1] === '=') { tokens.push({ t: '!=' }); i += 2; continue; }
|
||||
if (ch === '!') { tokens.push({ t: '!' }); i++; continue; }
|
||||
if (ch === '"' || ch === "'") {
|
||||
i++;
|
||||
var str = '';
|
||||
while (i < expr.length && expr[i] !== ch) { str += expr[i]; i++; }
|
||||
if (i < expr.length) i++;
|
||||
tokens.push({ t: 'str', v: str });
|
||||
continue;
|
||||
}
|
||||
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '.' || (ch >= '0' && ch <= '9')) {
|
||||
var id = '';
|
||||
while (i < expr.length && ((expr[i] >= 'a' && expr[i] <= 'z') || (expr[i] >= 'A' && expr[i] <= 'Z') || (expr[i] >= '0' && expr[i] <= '9') || expr[i] === '_' || expr[i] === '.')) { id += expr[i]; i++; }
|
||||
tokens.push({ t: 'id', v: id });
|
||||
continue;
|
||||
}
|
||||
throw new Error('Unexpected char: ' + ch);
|
||||
}
|
||||
var pos = 0;
|
||||
function peek() { return tokens[pos]; }
|
||||
function consume() { return tokens[pos++]; }
|
||||
function expect(t) {
|
||||
var tok = consume();
|
||||
if (!tok || tok.t !== t) throw new Error('Expected ' + t + ' got ' + (tok ? tok.t : 'EOF'));
|
||||
return tok;
|
||||
}
|
||||
function ctxVal(key) {
|
||||
var v = ctx[key];
|
||||
if (v === undefined || v === null) return '';
|
||||
return v;
|
||||
}
|
||||
function parseOr() {
|
||||
var left = parseAnd();
|
||||
while (peek() && peek().t === '||') { consume(); var right = parseAnd(); left = left || right; }
|
||||
return left;
|
||||
}
|
||||
function parseAnd() {
|
||||
var left = parsePrimary();
|
||||
while (peek() && peek().t === '&&') { consume(); var right = parsePrimary(); left = left && right; }
|
||||
return left;
|
||||
}
|
||||
function parsePrimary() {
|
||||
if (!peek()) throw new Error('Unexpected EOF');
|
||||
if (peek().t === '(') {
|
||||
consume();
|
||||
var val = parseOr();
|
||||
expect(')');
|
||||
return val;
|
||||
}
|
||||
if (peek().t === '!') {
|
||||
consume();
|
||||
var keyTok = consume();
|
||||
if (!keyTok || keyTok.t !== 'id') throw new Error('Expected key after !');
|
||||
return !ctxVal(keyTok.v);
|
||||
}
|
||||
if (peek().t === 'id') {
|
||||
var keyTok = consume();
|
||||
if (peek() && peek().t === '==') {
|
||||
consume();
|
||||
var valTok = consume();
|
||||
if (!valTok) throw new Error('Expected value after ==');
|
||||
var val = valTok.t === 'str' ? valTok.v : (valTok.v || '');
|
||||
// 与 Rust evaluate_when 保持一致:按 string / bool / number 比较。
|
||||
var cv = ctxVal(keyTok.v);
|
||||
if (val === 'true') return cv === true || cv === 'true';
|
||||
if (val === 'false') return cv === false || cv === '' || cv === 'false';
|
||||
var num = Number(val);
|
||||
if (!isNaN(num) && String(num) === val) return Number(cv) === num;
|
||||
return String(cv) === val;
|
||||
}
|
||||
if (peek() && peek().t === '!=') {
|
||||
consume();
|
||||
var valTok = consume();
|
||||
if (!valTok) throw new Error('Expected value after !=');
|
||||
var val = valTok.t === 'str' ? valTok.v : (valTok.v || '');
|
||||
var cv = ctxVal(keyTok.v);
|
||||
if (val === 'true') return !(cv === true || cv === 'true');
|
||||
if (val === 'false') return !(cv === false || cv === '' || cv === 'false');
|
||||
var num = Number(val);
|
||||
if (!isNaN(num) && String(num) === val) return Number(cv) !== num;
|
||||
return String(cv) !== val;
|
||||
}
|
||||
// 单独 key:按 truthy 规则判断。
|
||||
var cv = ctxVal(keyTok.v);
|
||||
if (typeof cv === 'boolean') return cv;
|
||||
if (typeof cv === 'number') return cv !== 0;
|
||||
return cv !== '' && cv !== undefined && cv !== null;
|
||||
}
|
||||
throw new Error('Unexpected token: ' + peek().t);
|
||||
}
|
||||
return parseOr();
|
||||
} catch(e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function openTreeContextMenu(kind, detail, x, y, trigger) {
|
||||
closeTreeContextMenu();
|
||||
detail = Object.assign({}, detail || {}, { contextKind: kind });
|
||||
@@ -4001,9 +4137,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var isAttachment = kind === 'attachment';
|
||||
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
var isFileTreeDownload = kind === 'filetree' && detail.downloadable;
|
||||
var ctx = buildSidebarFileTreeContext(kind);
|
||||
var items = isAttachment ? [
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-link', icon: 'link', label: '复制链接' },
|
||||
{ action: 'move-embed', icon: 'subdirectory_arrow_right', label: '移动/嵌入到...', shortcut: 'Alt+Shift+M/G' },
|
||||
@@ -4025,9 +4162,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) > 1 ? '下载 ' + Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) + ' 个项目' : '下载' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
@@ -4041,16 +4178,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ separator: true },
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空' },
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' }
|
||||
] : [
|
||||
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
|
||||
{ separator: true },
|
||||
@@ -4058,11 +4195,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' }
|
||||
];
|
||||
items.forEach(function(item) { appendTreeContextMenuButton(menu, item, detail, trigger); });
|
||||
items.forEach(function(item) {
|
||||
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
|
||||
item = Object.assign({}, item, { disabled: true, title: item.title || '当前上下文不支持此操作' });
|
||||
}
|
||||
appendTreeContextMenuButton(menu, item, detail, trigger);
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
var rect = menu.getBoundingClientRect();
|
||||
var left = Math.min(Math.max(8, x || 8), Math.max(8, window.innerWidth - rect.width - 8));
|
||||
@@ -8924,6 +9066,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
if (fileTreeRowForKey) {
|
||||
event.preventDefault();
|
||||
var delCtx = buildSidebarFileTreeContext('filetree');
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) {
|
||||
return;
|
||||
}
|
||||
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
|
||||
return;
|
||||
}
|
||||
@@ -10135,7 +10281,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function deleteSelectedSidebarFileTreeRows"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("event.key === 'Delete' || event.key === 'Backspace'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("selectedFileTreeRows.length > 1"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("deleteSelectedSidebarFileTreeRows(trigger || document.body)"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("deleteSelectedSidebarFileTreeRows(trigger || document.body)")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"currentSourceKind() === 'local_folder' && fileTreeRowKind(row) === 'asset'"
|
||||
));
|
||||
@@ -10158,6 +10306,38 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("ensureFileTreeWritableTarget('drop'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_command_context_helper_functions_exist() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function buildSidebarFileTreeContext"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function evaluateSidebarFileTreeWhen"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'workspace.sourceKind'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'workspace.readonly'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'tree.focusKind'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'tree.selectionCount'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'tree.selectionResourceKind'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-workspace-readonly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_context_menu_items_have_when_for_readonly() {
|
||||
// FileTree 分支的写操作必须带 when: '!workspace.readonly'。
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("when: '!workspace.readonly'"),
|
||||
"上下文菜单写操作应携带 when: '!workspace.readonly'"
|
||||
);
|
||||
// Verify at least one delete-trash item has the when expression
|
||||
let delete_trash_items = SIDEBAR_TREE_JS.match_indices("'delete-trash'").count();
|
||||
assert!(delete_trash_items > 0, "应至少有一个 delete-trash 菜单项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_context_evaluator_uses_readonly_and_selection_count_for_delete_key() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')"),
|
||||
"Delete/Backspace 快捷键应至少评估 workspace.readonly 上下文"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() {
|
||||
let table_loop_start = SIDEBAR_TREE_JS
|
||||
|
||||
Reference in New Issue
Block a user