refactor: externalize filetree runtime helpers

This commit is contained in:
lix-2026
2026-05-25 00:22:08 +08:00
parent d4bef58773
commit 2f01dc3a2e
6 changed files with 556 additions and 1 deletions
+44
View File
@@ -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)
@@ -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()
@@ -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(
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/resource-open-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/local-upload-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-context-menu-runtime.js"></script>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/tree-live-controller.js"></script>
</aside>
@@ -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'。