From 2f01dc3a2e67d370ba0c7cefa82cb3f347be89b5 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 25 May 2026 00:22:08 +0800 Subject: [PATCH] refactor: externalize filetree runtime helpers --- ...eb-browser-runtime-module-extraction-v1.md | 58 +++++- .../browser/filetree-context-menu-runtime.js | 187 ++++++++++++++++++ .../mnote-web/browser/filetree-runtime.js | 135 +++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 44 +++++ rust/crates/mnote-web/src/routes/web_shell.rs | 28 +++ rust/crates/mnote-web/src/ssr/pages/layout.rs | 105 ++++++++++ 6 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 rust/crates/mnote-web/browser/filetree-context-menu-runtime.js create mode 100644 rust/crates/mnote-web/browser/filetree-runtime.js diff --git a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md b/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md index 71fb54d6..5cac3293 100644 --- a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md +++ b/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md @@ -537,6 +537,60 @@ Reasonix read-only audit: - `type="module"` 自带作用域,`tree-shell-runtime.js` 已移除外层 IIFE,并只 hoist P3 关键函数;其他 DOM runtime 仍按原局部闭包组织,避免扩大改动。 - debug tree shell runtime 与主壳 `SIDEBAR_TREE_JS` 仍是两套 runtime,后续 P4/P5 继续拆主壳 filetree runtime 和 document pane host adapter。 +### 9.7 Batch 6 执行记录(2026-05-25) + +Reasonix read-only audit: + +- run id: `reasonix-2026-05-24T16-11-49-842Z-94562749` +- worktree: `/mnt/Data1T/mnote-worktrees/p4-filetree-audit` +- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T16-11-49-842Z-94562749/process-handoff.md` +- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、工作区 diff 与验证结果。 +- 采纳结论:主壳 filetree 第一批应优先迁纯 DOM/字符串读取 helper、path 解析和下载详情构造;DND、paste、菜单 DOM、上传执行链继续留在后续批次。 + +采纳内容: + +- 新增 `rust/crates/mnote-web/browser/filetree-runtime.js`。 +- 新增固定 asset route:`GET /api/mnote-browser-runtime/filetree-runtime.js`。 +- `PageLayout` 在 `SIDEBAR_TREE_JS` 前注入 `type="module"` 外置脚本。 +- `SIDEBAR_TREE_JS` 中以下 helper 先做 runtime 代理,保留原实现 fallback: + - `fileTreeRowDocumentId` + - `fileTreeRowAssetId` + - `decodeLocalEncodedPath` + - `fileTreeRowLocalRelativePath` + - `fileTreeRowLocalUploadTargetRelativePath` + - `fileTreeRowKind` + - `isFileTreeDownloadableAssetRow` + - `isFileTreeDownloadableRow` + - `fileTreeAssetDownloadDetail` + - `fileTreeRowsByRowIds` + - `fileTreeChildCount` +- 新增 `rust/crates/mnote-web/browser/filetree-context-menu-runtime.js`,先承接 `buildSidebarFileTreeContext` 与 `evaluateSidebarFileTreeWhen` 的 CommandContext / when 表达式求值;菜单 DOM 构建仍留在 `SIDEBAR_TREE_JS`。 + +采纳边界: + +- 本批次不迁移 `selectFileTreeRow`、`openFileTreeContextMenu`、`pasteSidebarFileTreeClipboard`、`downloadSelectedFileTreeAssetRows`、内部/外部 DND、上传执行链。 +- `fileTreeChildCount` 虽与 paste sortOrder 有关,但本批仅做同构 runtime 代理并保留 inline fallback,未改变 paste 命令语义。 +- filetree context menu 本批只迁 CommandContext/when evaluator,不迁 menu item DOM、事件绑定或右键定位逻辑。 + +已通过验证: + +- `node --check rust/crates/mnote-web/browser/filetree-runtime.js` +- `node --check rust/crates/mnote-web/browser/filetree-context-menu-runtime.js` +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` +- `cargo check --manifest-path rust/Cargo.toml -p mnote-web` +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime -- --test-threads=1` + - 结果:9 个相关测试通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_context_menu -- --test-threads=1` + - 结果:2 个相关测试通过。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1` + - 结果:runtime asset route 测试通过。 +- 真实浏览器轻量验收: + - dev server: `npm run dev:hot` + - 登录后访问 `http://127.0.0.1:3000/` + - `document.scripts` 包含 `/api/mnote-browser-runtime/filetree-runtime.js` 与 `/api/mnote-browser-runtime/filetree-context-menu-runtime.js` + - `window.__mnoteFileTreeRuntime.fileTreeRowLocalRelativePath` 和 `window.__mnoteFileTreeContextMenuRuntime.evaluateSidebarFileTreeWhen` 均为函数。 + - 两个 runtime asset 均返回 `200`,`content-type=application/javascript; charset=utf-8`。 + ## 10. 分阶段执行清单 ### P0:定位辅助与基线保护 @@ -600,7 +654,9 @@ Reasonix read-only audit: ### P4:抽出主壳 filetree runtime - [ ] 从 `SIDEBAR_TREE_JS` 中拆出文件树选择、keyboard、clipboard、DND、context menu。 -- [ ] 先拆纯函数和 reducer bridge,再拆 DOM 绑定。 +- [x] 先拆纯函数和 reducer bridge,再拆 DOM 绑定。 + - 2026-05-25 / Batch 6 已迁:filetree 行 accessor、local relative path、download detail、row lookup、child count wrapper,以及 context menu CommandContext / when evaluator。 + - 暂不迁:selection、keyboard、clipboard、DND、菜单 DOM、上传执行链。 - [ ] 主壳保留 `window.__mnoteSidebarTreeRuntimeStarted` 一类启动防重保护,但启动函数迁到外置模块。 - [ ] 与 Rust `FileTreeSelectionState` 合同对齐,不在前端新增第二套选择真相。 - [ ] 覆盖: diff --git a/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js b/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js new file mode 100644 index 00000000..4721cd03 --- /dev/null +++ b/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js @@ -0,0 +1,187 @@ +// MNote 文件树上下文菜单运行时外置模块。 +// 当前先承接 CommandContext 构造与 when 表达式求值,树语义仍由 Rust/kernel 主导。 + +function buildSidebarFileTreeContext(kind, deps) { + var selection = deps && deps.selection ? deps.selection : {}; + var rowIds = Array.isArray(selection.selectedRowIds) + ? selection.selectedRowIds + : Array.from(selection.selectedRowIds || []); + var rows = []; + var queryRowById = deps && typeof deps.queryRowById === 'function' + ? deps.queryRowById + : function() { return null; }; + for (var i = 0; i < rowIds.length; i += 1) { + var row = queryRowById(rowIds[i]); + if (row instanceof HTMLElement) rows.push(row); + } + var resourceKinds = {}; + for (var j = 0; j < rows.length; j += 1) { + var rk = rows[j].getAttribute('data-row-kind') || 'unknown'; + resourceKinds[rk] = true; + } + var rkKeys = Object.keys(resourceKinds); + var currentSourceKind = deps && typeof deps.currentSourceKind === 'function' + ? deps.currentSourceKind + : function() { return ''; }; + var readonly = deps && typeof deps.workspaceReadonly === 'function' + ? deps.workspaceReadonly() + : false; + return { + 'workspace.sourceKind': currentSourceKind() || '', + 'workspace.readonly': 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 = String(expr || '').trim(); + var tokens = tokenizeWhenExpression(expr); + 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 compareValue(cv, value, equal) { + if (value === 'true') { + var truth = cv === true || cv === 'true'; + return equal ? truth : !truth; + } + if (value === 'false') { + var falsy = cv === false || cv === '' || cv === 'false'; + return equal ? falsy : !falsy; + } + var num = Number(value); + if (!isNaN(num) && String(num) === value) { + return equal ? Number(cv) === num : Number(cv) !== num; + } + return equal ? String(cv) === value : String(cv) !== value; + } + 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 grouped = parseOr(); + expect(')'); + return grouped; + } + if (peek().t === '!') { + consume(); + var notTok = consume(); + if (!notTok || notTok.t !== 'id') throw new Error('Expected key after !'); + return !ctxVal(notTok.v); + } + if (peek().t === 'id') { + var keyTok = consume(); + if (peek() && peek().t === '==') { + consume(); + var eqTok = consume(); + if (!eqTok) throw new Error('Expected value after =='); + return compareValue(ctxVal(keyTok.v), eqTok.t === 'str' ? eqTok.v : (eqTok.v || ''), true); + } + if (peek() && peek().t === '!=') { + consume(); + var neTok = consume(); + if (!neTok) throw new Error('Expected value after !='); + return compareValue(ctxVal(keyTok.v), neTok.t === 'str' ? neTok.v : (neTok.v || ''), false); + } + 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 (_) { + return true; + } +} + +function tokenizeWhenExpression(expr) { + var tokens = []; + var i = 0; + while (i < expr.length) { + var ch = expr[i]; + if (ch === ' ' || ch === '\t') { i += 1; continue; } + if (ch === '(') { tokens.push({ t: '(' }); i += 1; continue; } + if (ch === ')') { tokens.push({ t: ')' }); i += 1; 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 += 1; continue; } + if (ch === '"' || ch === "'") { + i += 1; + var str = ''; + while (i < expr.length && expr[i] !== ch) { + str += expr[i]; + i += 1; + } + if (i < expr.length) i += 1; + tokens.push({ t: 'str', v: str }); + continue; + } + if ( + (ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch === '_' || + ch === '.' + ) { + 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 += 1; + } + tokens.push({ t: 'id', v: id }); + continue; + } + throw new Error('Unexpected char: ' + ch); + } + return tokens; +} + +window.__mnoteFileTreeContextMenuRuntime = { + buildSidebarFileTreeContext: buildSidebarFileTreeContext, + evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen, + tokenizeWhenExpression: tokenizeWhenExpression +}; diff --git a/rust/crates/mnote-web/browser/filetree-runtime.js b/rust/crates/mnote-web/browser/filetree-runtime.js new file mode 100644 index 00000000..a0de879b --- /dev/null +++ b/rust/crates/mnote-web/browser/filetree-runtime.js @@ -0,0 +1,135 @@ +// MNote 主壳文件树运行时外置模块。 +// 当前先承接 SIDEBAR_TREE_JS 中低风险的行数据解析 helper; +// inline script 保留 fallback,避免模块加载顺序影响首屏行为。 + +function fileTreeRowKind(row) { + if (!(row instanceof HTMLElement)) return ''; + return String(row.getAttribute('data-row-kind') || '').trim(); +} + +function fileTreeRowDocumentId(row) { + if (!(row instanceof HTMLElement)) return ''; + return String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim(); +} + +function fileTreeRowAssetId(row, deps) { + if (!(row instanceof HTMLElement)) return ''; + var assetId = String(row.getAttribute('data-asset-id') || '').trim(); + if (assetId) return assetId; + var currentSourceKind = deps && typeof deps.currentSourceKind === 'function' ? deps.currentSourceKind : function() { return ''; }; + if (currentSourceKind() === 'local_folder' && fileTreeRowKind(row) === 'asset') { + return String(row.getAttribute('data-row-id') || '').trim(); + } + return ''; +} + +function decodeLocalEncodedPath(value) { + var path = String(value || '').trim().replace(/~2F/g, '/'); + if (!path) return ''; + try { + return decodeURIComponent(path); + } catch (_) { + return path; + } +} + +function fileTreeRowLocalRelativePath(row, deps) { + if (!(row instanceof HTMLElement)) return ''; + var direct = String(row.getAttribute('data-local-relative-path') || '').trim(); + if (direct) return direct; + var localFilePathFromAssetId = deps && typeof deps.localFilePathFromAssetId === 'function' + ? deps.localFilePathFromAssetId + : function() { return ''; }; + var assetPath = localFilePathFromAssetId(fileTreeRowAssetId(row, deps)); + if (assetPath) return assetPath; + var rowId = String(row.getAttribute('data-row-id') || '').trim(); + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + var documentId = fileTreeRowDocumentId(row); + var kind = fileTreeRowKind(row); + if (rowId.indexOf('local:asset:') === 0) return rowId.slice('local:asset:'.length); + if (rowId.indexOf('local:markdown:') === 0) return rowId.slice('local:markdown:'.length); + if (rowId.indexOf('local:folder:') === 0) return rowId.slice('local:folder:'.length); + if (rowId.indexOf('local:node:') === 0) return rowId.slice('local:node:'.length); + if (nodeId.indexOf('local:node:') === 0) return nodeId.slice('local:node:'.length); + if ((kind === 'document' || kind === 'doc' || kind === 'markdown') && documentId.indexOf('local-md:') === 0) { + return decodeLocalEncodedPath(documentId.slice('local-md:'.length)); + } + if ((kind === 'folder' || kind === 'directory' || kind === 'index') && documentId.indexOf('local-dir:') === 0) { + return decodeLocalEncodedPath(documentId.slice('local-dir:'.length)); + } + return ''; +} + +function fileTreeRowLocalUploadTargetRelativePath(row, deps) { + if (!(row instanceof HTMLElement)) return ''; + var kind = fileTreeRowKind(row); + var relativePath = fileTreeRowLocalRelativePath(row, deps); + if (!relativePath) return ''; + if (kind === 'folder' || kind === 'directory') return relativePath; + var lastSlash = relativePath.lastIndexOf('/'); + return lastSlash >= 0 ? relativePath.slice(0, lastSlash) : ''; +} + +function isFileTreeDownloadableAssetRow(row, deps) { + if (!(row instanceof HTMLElement)) return false; + var kind = fileTreeRowKind(row); + if (kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || kind === 'folder' || kind === 'directory') return false; + return Boolean(fileTreeRowAssetId(row, deps)); +} + +function isFileTreeDownloadableRow(row, deps) { + if (!(row instanceof HTMLElement)) return false; + var currentSourceKind = deps && typeof deps.currentSourceKind === 'function' ? deps.currentSourceKind : function() { return ''; }; + if (currentSourceKind() !== 'local_folder') return isFileTreeDownloadableAssetRow(row, deps); + return Boolean(fileTreeRowLocalRelativePath(row, deps) || isFileTreeDownloadableAssetRow(row, deps)); +} + +function fileTreeAssetDownloadDetail(row, deps) { + if (!isFileTreeDownloadableRow(row, deps)) return null; + var rowTitle = deps && typeof deps.rowTitle === 'function' ? deps.rowTitle : function() { return ''; }; + var resolveWorkspaceId = deps && typeof deps.resolveWorkspaceId === 'function' ? deps.resolveWorkspaceId : function() { return ''; }; + var title = rowTitle(row); + var relativePath = fileTreeRowLocalRelativePath(row, deps); + var assetId = fileTreeRowAssetId(row, deps) || (relativePath ? 'local-file:' + relativePath : ''); + return { + contextKind: 'filetree', + documentId: fileTreeRowDocumentId(row), + rowId: row.getAttribute('data-row-id') || '', + rowKind: fileTreeRowKind(row), + assetId: assetId, + localRelativePath: relativePath, + title: title, + fileName: title, + workspaceId: resolveWorkspaceId(row) + }; +} + +function fileTreeRowsByRowIds(rowIds, deps) { + var cssEscape = deps && typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); }; + return (rowIds || []).map(function(rowId) { + return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]'); + }).filter(function(row) { return row instanceof HTMLElement; }); +} + +function fileTreeChildCount(documentId, deps) { + if (!documentId) return 0; + var cssEscape = deps && typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); }; + var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]'); + var node = row ? row.closest('.tree-node') : null; + var children = node ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="document"], :scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="doc"]') : []; + return children.length; +} + +window.__mnoteFileTreeRuntime = { + fileTreeRowKind: fileTreeRowKind, + fileTreeRowDocumentId: fileTreeRowDocumentId, + fileTreeRowAssetId: fileTreeRowAssetId, + decodeLocalEncodedPath: decodeLocalEncodedPath, + fileTreeRowLocalRelativePath: fileTreeRowLocalRelativePath, + fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath, + isFileTreeDownloadableAssetRow: isFileTreeDownloadableAssetRow, + isFileTreeDownloadableRow: isFileTreeDownloadableRow, + fileTreeAssetDownloadDetail: fileTreeAssetDownloadDetail, + fileTreeRowsByRowIds: fileTreeRowsByRowIds, + fileTreeChildCount: fileTreeChildCount +}; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index d43c2431..0703f8d4 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -102,6 +102,14 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/local-upload-runtime.js", get(web_shell::local_upload_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/filetree-runtime.js", + get(web_shell::filetree_runtime_asset), + ) + .route( + "/api/mnote-browser-runtime/filetree-context-menu-runtime.js", + get(web_shell::filetree_context_menu_runtime_asset), + ) .route( "/api/mnote-browser-runtime/tree-live-controller.js", get(web_shell::tree_live_controller_runtime_asset), @@ -508,6 +516,42 @@ mod tests { } } + #[tokio::test] + async fn mnote_browser_runtime_assets_are_explicitly_mounted() { + for path in [ + "/api/mnote-browser-runtime/resource-open-runtime.js", + "/api/mnote-browser-runtime/local-upload-runtime.js", + "/api/mnote-browser-runtime/filetree-runtime.js", + "/api/mnote-browser-runtime/filetree-context-menu-runtime.js", + "/api/mnote-browser-runtime/tree-live-controller.js", + "/api/mnote-browser-runtime/tree-shell-runtime.js", + ] { + let response = app(false) + .oneshot( + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!( + response.status(), + StatusCode::OK, + "{path} 应返回 runtime asset" + ); + let content_type = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.starts_with("application/javascript"), + "{path} 应声明 JavaScript content-type" + ); + } + } + #[tokio::test] async fn dev_hot_reload_endpoint_is_mounted() { let response = app(false) diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 76977b20..2ccaa7e3 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -4395,6 +4395,34 @@ pub async fn local_upload_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn filetree_context_menu_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/filetree-context-menu-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +pub async fn filetree_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/filetree-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn tree_live_controller_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-live-controller.js"); Response::builder() diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 751bc7e4..f85c2668 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3146,6 +3146,22 @@ const SIDEBAR_TREE_JS: &str = r##" return typeof fn === 'function' ? fn : null; } + function fileTreeRuntimeFunction(name) { + var runtime = window.__mnoteFileTreeRuntime; + var fn = runtime && runtime[name]; + return typeof fn === 'function' ? fn : null; + } + + function fileTreeRuntimeDeps() { + return { + currentSourceKind: currentSourceKind, + localFilePathFromAssetId: localFilePathFromAssetId, + rowTitle: rowTitle, + resolveWorkspaceId: resolveWorkspaceId, + cssEscape: cssEscape + }; + } + function uploadedAssetTitle(asset) { var runtimeFn = localUploadRuntimeFunction('uploadedAssetTitle'); if (runtimeFn) return runtimeFn(asset); @@ -4656,6 +4672,21 @@ const SIDEBAR_TREE_JS: &str = r##" // ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ── function buildSidebarFileTreeContext(kind) { + var runtime = window.__mnoteFileTreeContextMenuRuntime; + if (runtime && typeof runtime.buildSidebarFileTreeContext === 'function') { + try { + return runtime.buildSidebarFileTreeContext(kind, { + selection: sidebarFileTreeSelection, + currentSourceKind: currentSourceKind, + workspaceReadonly: function() { + return document.documentElement.getAttribute('data-mnote-workspace-readonly') === 'true'; + }, + queryRowById: function(rowId) { + return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]'); + } + }); + } catch (_) {} + } var s = sidebarFileTreeSelection; var rowIds = Array.from(s.selectedRowIds || []); var rows = []; @@ -4679,6 +4710,10 @@ const SIDEBAR_TREE_JS: &str = r##" } function evaluateSidebarFileTreeWhen(ctx, expr) { + var runtime = window.__mnoteFileTreeContextMenuRuntime; + if (runtime && typeof runtime.evaluateSidebarFileTreeWhen === 'function') { + return runtime.evaluateSidebarFileTreeWhen(ctx || {}, expr || ''); + } if (!expr) return true; try { expr = expr.trim(); @@ -5099,11 +5134,15 @@ const SIDEBAR_TREE_JS: &str = r##" } function fileTreeRowDocumentId(row) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowDocumentId'); + if (runtimeFn) return runtimeFn(row); if (!(row instanceof HTMLElement)) return ''; return String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim(); } function fileTreeRowAssetId(row) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowAssetId'); + if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); if (!(row instanceof HTMLElement)) return ''; var assetId = String(row.getAttribute('data-asset-id') || '').trim(); if (assetId) return assetId; @@ -5114,6 +5153,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function decodeLocalEncodedPath(value) { + var runtimeFn = fileTreeRuntimeFunction('decodeLocalEncodedPath'); + if (runtimeFn) return runtimeFn(value); var path = String(value || '').trim().replace(/~2F/g, '/'); if (!path) return ''; try { @@ -5124,6 +5165,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function fileTreeRowLocalRelativePath(row) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath'); + if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); if (!(row instanceof HTMLElement)) return ''; var direct = String(row.getAttribute('data-local-relative-path') || '').trim(); if (direct) return direct; @@ -5148,6 +5191,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function fileTreeRowLocalUploadTargetRelativePath(row) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalUploadTargetRelativePath'); + if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); if (!(row instanceof HTMLElement)) return ''; var kind = fileTreeRowKind(row); var relativePath = fileTreeRowLocalRelativePath(row); @@ -5158,11 +5203,15 @@ const SIDEBAR_TREE_JS: &str = r##" } function fileTreeRowKind(row) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowKind'); + if (runtimeFn) return runtimeFn(row); if (!(row instanceof HTMLElement)) return ''; return String(row.getAttribute('data-row-kind') || '').trim(); } function isFileTreeDownloadableAssetRow(row) { + var runtimeFn = fileTreeRuntimeFunction('isFileTreeDownloadableAssetRow'); + if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); if (!(row instanceof HTMLElement)) return false; var kind = fileTreeRowKind(row); if (kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || kind === 'folder' || kind === 'directory') return false; @@ -5170,12 +5219,16 @@ const SIDEBAR_TREE_JS: &str = r##" } function isFileTreeDownloadableRow(row) { + var runtimeFn = fileTreeRuntimeFunction('isFileTreeDownloadableRow'); + if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); if (!(row instanceof HTMLElement)) return false; if (currentSourceKind() !== 'local_folder') return isFileTreeDownloadableAssetRow(row); return Boolean(fileTreeRowLocalRelativePath(row) || isFileTreeDownloadableAssetRow(row)); } function fileTreeAssetDownloadDetail(row) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeAssetDownloadDetail'); + if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps()); if (!isFileTreeDownloadableRow(row)) return null; var title = rowTitle(row); var relativePath = fileTreeRowLocalRelativePath(row); @@ -5360,12 +5413,16 @@ const SIDEBAR_TREE_JS: &str = r##" } function fileTreeRowsByRowIds(rowIds) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowsByRowIds'); + if (runtimeFn) return runtimeFn(rowIds, fileTreeRuntimeDeps()); return (rowIds || []).map(function(rowId) { return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]'); }).filter(function(row) { return row instanceof HTMLElement; }); } function fileTreeChildCount(documentId) { + var runtimeFn = fileTreeRuntimeFunction('fileTreeChildCount'); + if (runtimeFn) return runtimeFn(documentId, fileTreeRuntimeDeps()); if (!documentId) return 0; var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]'); var node = row ? row.closest('.tree-node') : null; @@ -10480,6 +10537,8 @@ pub fn PageLayout( + + @@ -10520,6 +10579,9 @@ pub fn PageLayout( #[cfg(test)] mod tests { use super::SIDEBAR_TREE_JS; + const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str = + include_str!("../../../browser/filetree-context-menu-runtime.js"); + const FILETREE_RUNTIME_JS: &str = include_str!("../../../browser/filetree-runtime.js"); const TREE_LIVE_CONTROLLER_JS: &str = include_str!("../../../browser/tree-live-controller.js"); fn js_function_body(source: &str, name: &str) -> String { @@ -10844,6 +10906,37 @@ mod tests { assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot")); } + #[test] + fn sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback() { + assert!(SIDEBAR_TREE_JS.contains("function fileTreeRuntimeFunction")); + assert!(SIDEBAR_TREE_JS.contains("window.__mnoteFileTreeRuntime")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowDocumentId')")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowAssetId')")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowLocalRelativePath')")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeAssetDownloadDetail')")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeRowsByRowIds')")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeChildCount')")); + let document_id_body = js_function_body(SIDEBAR_TREE_JS, "fileTreeRowDocumentId"); + assert!( + document_id_body.contains("data-document-id") + && document_id_body.contains("data-doc-id"), + "inline fallback 必须保留旧 data attribute 解析" + ); + } + + #[test] + fn filetree_runtime_contains_row_accessor_helpers() { + assert!(FILETREE_RUNTIME_JS.contains("window.__mnoteFileTreeRuntime")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeRowDocumentId")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeRowAssetId")); + assert!(FILETREE_RUNTIME_JS.contains("function decodeLocalEncodedPath")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeRowLocalRelativePath")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeRowLocalUploadTargetRelativePath")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeAssetDownloadDetail")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeRowsByRowIds")); + assert!(FILETREE_RUNTIME_JS.contains("function fileTreeChildCount")); + } + #[test] fn sidebar_tree_runtime_focuses_restored_local_folder_row() { assert!(SIDEBAR_TREE_JS.contains("applyPendingLocalFolderRestoreFocusOnce")); @@ -11044,6 +11137,7 @@ mod tests { 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("__mnoteFileTreeContextMenuRuntime")); assert!(SIDEBAR_TREE_JS.contains("'workspace.sourceKind'")); assert!(SIDEBAR_TREE_JS.contains("'workspace.readonly'")); assert!(SIDEBAR_TREE_JS.contains("'tree.focusKind'")); @@ -11052,6 +11146,17 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("data-mnote-workspace-readonly")); } + #[test] + fn filetree_context_menu_runtime_contains_command_context_helpers() { + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("function buildSidebarFileTreeContext")); + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("function evaluateSidebarFileTreeWhen")); + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("function tokenizeWhenExpression")); + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("__mnoteFileTreeContextMenuRuntime")); + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("'workspace.sourceKind'")); + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("'workspace.readonly'")); + assert!(FILETREE_CONTEXT_MENU_RUNTIME_JS.contains("'tree.selectionCount'")); + } + #[test] fn sidebar_filetree_context_menu_items_have_when_for_readonly() { // FileTree 分支的写操作必须带 when: '!workspace.readonly'。