batch E: close tail checks and refresh smoke evidence

This commit is contained in:
lix-2026
2026-05-21 17:15:28 +08:00
parent 1e8f5c5816
commit cc91a56603
16 changed files with 1093 additions and 45 deletions
@@ -1,5 +1,6 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::document_buffer_store::{self, BufferKey, BufferStore};
use crate::error::WebError;
use crate::routes::command_support::{
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
@@ -38,6 +39,64 @@ pub struct DocumentMetaQuery {
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BufferStateQuery {
pub document_id: Option<String>,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub relative_path: Option<String>,
}
fn find_document_buffer_state(
buffer_store: &BufferStore,
query: &BufferStateQuery,
) -> Option<core_protocol::DocumentBuffer> {
if let (Some(root_uri), Some(_source_kind), Some(document_id)) = (
query.root_uri.as_deref().filter(|v| !v.is_empty()),
query.source_kind.as_deref().filter(|v| !v.is_empty()),
query.document_id.as_deref().filter(|v| !v.is_empty()),
) {
// 本地文件夹查询优先用明确 relativePath;缺省时退回扫描,避免前端只知道 documentId 时查不到已初始化 buffer。
if let Some(relative_path) = query.relative_path.as_deref().filter(|v| !v.is_empty()) {
let ws_path = document_buffer_store::build_local_folder_workspace_path(
query.workspace_id.as_deref().unwrap_or(""),
root_uri,
relative_path,
document_id,
);
return buffer_store.get_by_path(&ws_path);
}
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.root_uri == root_uri
&& buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
});
}
if let (Some(document_id), Some(workspace_id), Some(relative_path)) = (
query.document_id.as_deref().filter(|v| !v.is_empty()),
query.workspace_id.as_deref().filter(|v| !v.is_empty()),
query.relative_path.as_deref().filter(|v| !v.is_empty()),
) {
// 通过完整 workspace path 精确查询。
let key = BufferKey::from_parts(
workspace_id,
"LocalFolder",
"",
relative_path,
Some(document_id.to_string()),
);
return buffer_store.get(&key);
}
if let Some(document_id) = query.document_id.as_deref().filter(|v| !v.is_empty()) {
// 最后按 documentId 扫描,作为调试和兼容查询兜底。
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
});
}
None
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest {
@@ -520,6 +579,42 @@ pub async fn content(
Ok(ok_response(&context, result))
}
/// 查询指定文档的当前 BufferStore 状态。
///
/// 支持两种查询方式:
/// 1. 通过 documentId + sourceKind + rootUri 自动构造 workspace path
/// 2. 通过 documentId + workspaceId + relativePath 精确查询
pub async fn buffer_state(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<BufferStateQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let result = find_document_buffer_state(&state.buffer_store, &query);
match result {
Some(buf) => Ok(ok_response(
&context,
json!({
"documentId": buf.workspace_path.object_identity.document_id,
"workspaceId": buf.workspace_path.workspace_id,
"dirtyState": format!("{:?}", buf.dirty_state),
"fileVersion": buf.file_version,
"baseContentHash": buf.base_content_hash,
"currentContentHash": buf.current_content_hash,
"externalActor": buf.external_actor,
"lastLoadedAt": buf.last_loaded_at,
"lastSavedAt": buf.last_saved_at,
}),
)),
None => Err(WebError::new(
StatusCode::NOT_FOUND,
"buffer_state_not_found",
"未找到该文档的 buffer 状态",
)
.with_context(&context)),
}
}
pub async fn page_body_write(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1001,6 +1096,7 @@ pub async fn options(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
use axum::body::{to_bytes, Body};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value;
@@ -1103,6 +1199,30 @@ mod tests {
}))
}
#[test]
fn documents_buffer_state_falls_back_to_document_id_without_relative_path() {
let store = BufferStore::new();
let ws_path = build_local_folder_workspace_path(
"ws_local",
"file:///tmp/mnote-root",
"docs/page.md",
"local-md:docs~2Fpage.md",
);
store.init_buffer(&ws_path, Some("version-1".into()), Some("hash-1".into()));
let query = super::BufferStateQuery {
document_id: Some("local-md:docs~2Fpage.md".into()),
workspace_id: Some("ws_local".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote-root".into()),
relative_path: None,
};
let result = super::find_document_buffer_state(&store, &query).expect("buffer state");
assert_eq!(result.file_version.as_deref(), Some("version-1"));
assert_eq!(result.workspace_path.relative_path, "docs/page.md");
}
#[test]
fn convex_auth_cookie_does_not_force_next_proxy() {
let mut headers = HeaderMap::new();
@@ -2127,6 +2127,22 @@ pub fn save_local_markdown_page(
document_id: &str,
expected_conflict_detection_key: Option<&str>,
content: &Value,
) -> Result<Value, WebError> {
save_local_markdown_page_inner(
root_uri,
document_id,
expected_conflict_detection_key,
content,
None,
)
}
fn save_local_markdown_page_inner(
root_uri: &str,
document_id: &str,
expected_conflict_detection_key: Option<&str>,
content: &Value,
buffer_store: Option<&crate::document_buffer_store::BufferStore>,
) -> Result<Value, WebError> {
let root_path = parse_file_root_uri(root_uri)?;
let canonical_root = root_path.canonicalize().map_err(|error| {
@@ -2143,6 +2159,13 @@ pub fn save_local_markdown_page(
"找不到要保存的本地 Markdown 页面",
)
})?;
let workspace_id = local_workspace_id(&canonical_root);
let relative_path_for_buffer = markdown_file
.path
.strip_prefix(&canonical_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let current = fs::read_to_string(&markdown_file.path).map_err(|error| {
WebError::bad_request_code(
"local_markdown_read_failed",
@@ -2159,11 +2182,21 @@ pub fn save_local_markdown_page(
.filter(|value| !value.is_empty())
{
if expected_key != current_conflict_key {
let buffer_state = buffer_store.and_then(|store| {
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
&workspace_id,
root_uri,
&relative_path_for_buffer,
document_id,
);
store.get_by_path(&ws_path)
});
return Err(local_markdown_conflict_error(
root_uri,
document_id,
expected_key,
&current_conflict_key,
buffer_state.as_ref(),
));
}
}
@@ -2183,13 +2216,6 @@ pub fn save_local_markdown_page(
),
)
})?;
let workspace_id = local_workspace_id(&canonical_root);
let relative_path_for_buffer = markdown_file
.path
.strip_prefix(&canonical_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
let next_conflict_key =
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
@@ -2216,19 +2242,18 @@ fn local_markdown_conflict_error(
document_id: &str,
editor_base_version: &str,
current_disk_version: &str,
buffer_state: Option<&core_protocol::DocumentBuffer>,
) -> WebError {
WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
)
.with_details(json!({
let mut details = json!({
"conflict": {
"code": "local_markdown_external_change",
"documentId": document_id,
"rootUri": root_uri,
"currentDiskVersion": current_disk_version,
"editorBaseVersion": editor_base_version,
"externalActor": Value::Null,
"dirtyState": Value::Null,
"bufferFileVersion": Value::Null,
"suggestedActions": [
"accept_disk",
"keep_editor",
@@ -2236,7 +2261,20 @@ fn local_markdown_conflict_error(
"merge"
]
}
}))
});
if let Some(buf) = buffer_state {
if let Some(conflict) = details.get_mut("conflict").and_then(Value::as_object_mut) {
conflict.insert("externalActor".into(), json!(&buf.external_actor));
conflict.insert("dirtyState".into(), json!(format!("{:?}", buf.dirty_state)));
conflict.insert("bufferFileVersion".into(), json!(&buf.file_version));
}
}
WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
)
.with_details(details)
}
pub fn write_local_markdown_page_body(
@@ -2256,12 +2294,22 @@ pub fn write_local_markdown_page_body(
));
}
let mut result = save_local_markdown_page(
&request.root_uri,
&request.document_id,
request.expected_file_version.as_deref(),
&request.content,
)?;
let mut result = if buffer_store.is_some() {
save_local_markdown_page_inner(
&request.root_uri,
&request.document_id,
request.expected_file_version.as_deref(),
&request.content,
buffer_store,
)?
} else {
save_local_markdown_page(
&request.root_uri,
&request.document_id,
request.expected_file_version.as_deref(),
&request.content,
)?
};
// 保存成功后更新 BufferStore
if let Some(store) = buffer_store {
@@ -7843,6 +7891,55 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_fails_with_error_code_on_readonly_file() {
let root = temp_root("mnote-local-markdown-save-readonly-error");
std::fs::write(
root.join("README.md"),
"---\ntitle: Error Test\n---\n# Original\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
// 把文件设为只读,使 fs::write 失败。
let mut perms = std::fs::metadata(root.join("README.md"))
.expect("metadata")
.permissions();
perms.set_readonly(true);
std::fs::set_permissions(root.join("README.md"), perms).expect("set readonly");
let error = save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{"type":"paragraph","content":[{"type":"text","text":"新内容","styles":{}}]}
]),
)
.expect_err("save 应在只读文件上失败");
assert_eq!(
error.code(),
"local_markdown_write_failed",
"错误码应为 local_markdown_write_failed,实际: {}",
error.code()
);
assert!(
error.message().contains("无法保存本地 Markdown 文件"),
"错误信息应包含中文解释和文件路径,实际: {}",
error.message()
);
// 验证磁盘上的原始内容未被清除。
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(
saved.contains("# Original"),
"原文应保留,实际内容: {}",
saved
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_writes_table_inline_marks() {
let root = temp_root("mnote-local-markdown-save-table-inline-marks");
@@ -7930,6 +8027,50 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_table_handles_colspan_degradation() {
// Tiptap table cell 可能包含 colspan/rowspan 等 GFM pipe table
// 无法表达的结构。降级策略:提取文本内容,忽略合并单元格属性,
// 输出标准 pipe table,不丢文字。
let root = temp_root("mnote-local-markdown-save-table-colspan");
std::fs::write(root.join("README.md"), "---\ntitle: Colspan\n---\n# Old\n")
.expect("write md");
let root_uri = format!("file://{}", root.display());
// 第二行第二个单元格有 colspan=2GFM pipe table 不支持,应降级为普通单元格。
save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{"type":"table","props":{"tiptapTable":{"type":"table","content":[
{"type":"tableRow","content":[
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]}
]},
{"type":"tableRow","content":[
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"x","styles":{}}]}]},
{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"y+z","styles":{}}]}]}
]}
]}}}
]),
)
.expect("save");
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
// 降级后 colspan=2 的单元格变成普通单列,GFM 表头行 3 列但数据行 2 列 → 右列留空。
// 重点:文字不丢。
assert!(
saved.contains("y+z"),
"colspan 单元格文字不应丢失: {}",
saved
);
// 不会 panic,表格行数对齐由 editor_block_table_to_markdown 的 padding 逻辑保证。
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_writes_image_blocks_as_markdown_images() {
let root = temp_root("mnote-local-markdown-save-image-block");
+1
View File
@@ -193,6 +193,7 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/buffer-state", get(documents::buffer_state))
.route("/api/documents/purge", post(documents::purge))
.route("/api/documents/empty-trash", post(documents::empty_trash))
.route("/api/documents/title", post(documents::title))
@@ -1,5 +1,6 @@
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::document_buffer_store::{self};
use crate::error::WebError;
use crate::page_aggregate::PageAggregate;
use crate::routes::documents::{
@@ -69,6 +70,25 @@ pub async fn document_page_shell(
primary_root_uri,
)
.await?;
// 初始化 BufferStorelocal_folder 文档打开时记录 file_version
if primary_source_kind == Some("local_folder") {
if let Some(root_uri) = primary_root_uri {
let relative_path = document_id
.strip_prefix("local-md:")
.unwrap_or("")
.replace("~2F", "/");
let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string());
let ws_path = document_buffer_store::build_local_folder_workspace_path(
&aggregate.identity.workspace_id,
root_uri,
&relative_path,
&document_id,
);
state.buffer_store.init_buffer(&ws_path, file_version, None);
}
}
let title = aggregate.head.title.as_str();
let workspace_id = aggregate.identity.workspace_id.clone();
let requested_secondary_document_id =
@@ -1552,6 +1572,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
workspaceId: session.workspaceId,
viewCount: session.views.size,
status: session.status,
lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
})),
localFolderChannelCount: localFolderEventRegistry.size,
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
@@ -1782,6 +1804,25 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return sourceLabel ? `${baseMessage}(来源:${sourceLabel}` : baseMessage;
};
const ensureSessionConflictEnvelope = (session, message) => {
if (!session) return null;
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
const envelope = {
code: 'local_markdown_external_change',
documentId: session.documentId,
rootUri: session.rootUri || null,
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
externalActor: session.lastExternalWriteSource || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
message: message || externalConflictMessage,
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
};
session.lastExternalConflictEnvelope = envelope;
return envelope;
};
const clearSessionConflictSurface = (session) => {
sessionViews(session).forEach((view) => {
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
@@ -2047,6 +2088,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const markSessionExternalConflict = (session, message) => {
session.externalChangePending = false;
ensureSessionConflictEnvelope(session, message);
session.hasExternalConflict = true;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
+192 -12
View File
@@ -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