Improve local filetree view state and sidebar performance

This commit is contained in:
lix-2026
2026-05-27 11:31:12 +08:00
parent 58e2fdb5d8
commit 3ae33cc21d
56 changed files with 8614 additions and 461 deletions
+247 -7
View File
@@ -88,6 +88,7 @@ pub fn PageLayout(
Some(sidebar_tree_html.as_str()),
None,
None,
None,
)
});
let tree_live_bootstrap = serde_json::json!({
@@ -175,8 +176,8 @@ pub fn PageLayout(
</nav>
</div>
<div class="wolai-topbar-actions" aria-label="页面操作">
<span class="wolai-public-pill" data-testid="wolai-public-state">"全网公开"</span>
<button type="button" class="wolai-icon-button" title="收藏" aria-label="收藏"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
@@ -199,6 +200,8 @@ pub fn PageLayout(
#[cfg(test)]
mod tests {
use leptos::prelude::ElementChild;
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-runtime.js");
const SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-tree-live-apply-runtime.js");
@@ -260,6 +263,8 @@ mod tests {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_LAST_CLOUD_WORKSPACE_KEY"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command"));
@@ -341,6 +346,51 @@ mod tests {
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'"));
}
#[test]
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
let html = crate::ssr::render_view(leptos::view! {
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
<main>"正文"</main>
</super::PageLayout>
});
assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#));
assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#));
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
}
#[test]
fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("inferLocalRootUriFromWorkspaceId"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-filetree-scope"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("toggle-sidebar-folder-shortcut"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
let scope_start = SIDEBAR_TREE_RUNTIME_JS
.find("persistStarredFolderScope(workspaceId, rootUri, relativePath);")
.expect("starred folder should persist scope before loading");
let sidebar_fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..]
.find("fetch(sidebarUrl.toString()")
.expect("starred folder should fetch local page projection")
+ scope_start;
let fetch_start = SIDEBAR_TREE_RUNTIME_JS[scope_start..]
.find("fetch(url.toString()")
.expect("starred folder should fetch scoped projection")
+ scope_start;
assert!(
sidebar_fetch_start < fetch_start,
"星标文件夹请求 scoped filetree 前必须先拉本地页面树投影,避免我的空间旧页面残留"
);
assert!(
scope_start < fetch_start,
"星标文件夹应先写入 fileTreeScope,再请求大目录,避免 live refresh 把视图折回根目录"
);
}
#[test]
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
assert!(
@@ -503,9 +553,10 @@ mod tests {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("renderSidebarSnapshot(sidebarPayload.result || sidebarPayload)"));
.contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("renderFileProjection(filePayload.result || filePayload)"));
.contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)"));
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
);
@@ -565,6 +616,29 @@ mod tests {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("editorRoot: uploadContext.root"));
}
#[test]
fn sidebar_attachment_open_runtime_receives_closest_action_dependency() {
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function closestAction"));
let injection_start = SIDEBAR_TREE_RUNTIME_JS
.find("const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({")
.expect("attachment runtime injection");
let injection_end = SIDEBAR_TREE_RUNTIME_JS[injection_start..]
.find(" });")
.map(|offset| injection_start + offset)
.expect("attachment runtime injection end");
let injection = &SIDEBAR_TREE_RUNTIME_JS[injection_start..injection_end];
assert!(
injection.contains("closestAction"),
"attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError"
);
assert!(
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("closestAction = typeof injectedClosestAction === 'function'")
|| SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("dependencies.closestAction"),
"attachment open runtime 应从 dependencies 读取 closestAction,而不是依赖外层闭包"
);
}
#[test]
fn local_upload_runtime_contains_editor_upload_context_helpers() {
const LOCAL_UPLOAD_RUNTIME_JS: &str =
@@ -803,8 +877,9 @@ mod tests {
"selectSidebarFileTreeDocument",
);
assert!(
select_document_body
.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"),
select_document_body.contains("activateSidebarFileTreeRow(row, options)")
&& SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function activateSidebarFileTreeRow")
&& SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false })"),
"document selection 应复用 filetree row selection runtime/fallback"
);
}
@@ -1006,8 +1081,173 @@ mod tests {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file/children"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("parentRelativePath"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-filetree-children-loaded"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeLazyChildrenCache"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowsByParent: new Map()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadedParents: new Set()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("loadingParents: new Map()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("dirtyParents: new Set()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeExpandedRelativePaths"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("if (fileTreeState.loadingParents.has(key))"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("return fileTreeState.loadingParents.get(key);"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("function markExistingFileTreeChildrenLoaded"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("if (markExistingFileTreeChildrenLoaded(row, button))"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("scheduleRestorePersistedFileTreeExpansionState();"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("return patchFileTreeParentChildren(parentRelativePath, rows);"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)"));
let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.find("function renderFileProjection(projection)")
.expect("renderFileProjection");
let render_end = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..]
.find("function renderSidebarSnapshot(payload)")
.map(|offset| render_start + offset)
.expect("renderFileProjection end");
let render_body = &SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[render_start..render_end];
assert!(!render_body.contains("tree.innerHTML ="));
assert!(render_body.contains("tree.replaceChildren"));
let patch_branch = render_body
.find("patchFileTreeParentChildren(parentRelativePath, rows)")
.expect("non-root parent patch");
let root_replace = render_body
.find("tree.replaceChildren")
.expect("root replacement");
assert!(
patch_branch < root_replace,
"非 scope parent projection 必须先局部 patch,不能替换整棵 filetree"
);
let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.find("async function loadFileTreeChildren(row, button)")
.expect("lazy children loader");
let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
.find("setTreeRowExpanded(row, button, true);")
.expect("lazy loading should mark the requested folder expanded before fetch")
+ load_start;
let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
.find("getFileTreeChildren(relativePath)")
.expect("lazy children datasource call")
+ load_start;
assert!(
optimistic_expand < fetch_start,
"慢目录加载期间必须先保存 expanded 状态,否则 refresh/create-page 会把刚点开的文件夹折叠"
);
}
#[test]
fn sidebar_filetree_runtime_discards_stale_generation_results() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("staleParents: new Set()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("requestGeneration: 0"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function beginFileTreeRequest"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function isLatestFileTreeRequest"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("if (!isLatestFileTreeRequest(key, generation))"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.staleParents.add(key)"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.requestGeneration += 1"));
}
#[test]
fn sidebar_filetree_runtime_prefers_command_affected_parents() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("function addAffectedParentsFromCommandResult"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("Array.isArray(result && result.affectedParents)"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("result && result.execution && result.execution.affectedParents"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("addAffectedParentsFromCommandResult(parents, result)"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("data-mnote-filetree-command-refresh-fallback"));
}
#[test]
fn sidebar_filetree_runtime_batches_local_command_refresh() {
assert!(
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function nextFileTreeOperationBatchId")
);
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("removeFileTreeAssetRow"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("removeFileTreeAssetRow,"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("batchId: batchId"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("tree:local-command-batch-complete"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function queueFileTreeBatchRefresh"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function flushFileTreeBatchRefresh"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("data-mnote-filetree-batch-refresh-pending"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("data-mnote-filetree-batch-refresh-applied"));
}
#[test]
fn sidebar_filetree_runtime_handles_watch_batch_and_structured_error() {
assert!(TREE_LIVE_CONTROLLER_JS.contains("watch_batch"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:local-folder-watch-batch"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("data-mnote-local-folder-watch-batch-applied"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
}
#[test]
fn sidebar_filetree_runtime_has_view_state_namespace() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("expandedParents: new Set()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("selectedRowIds: new Set()"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("focusedRowId: ''"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("activeRowId: ''"));
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeState = fileTreeViewState;")
);
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
"if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;"
));
}
#[test]
fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() {
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function rememberFileTreeSelectionState")
);
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function reprojectFileTreeSelectionState")
);
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("fileTreeViewState.selectedRowIds.add(rowId)"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
"row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))"
));
}
#[test]
fn sidebar_filetree_runtime_can_reveal_unloaded_resource_path() {
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("async function revealFileTreeResource")
);
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("function fileTreeParentChainForRelativePath"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("await getFileTreeChildren(parentRelativePath)"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("fileTreeViewState.selectedRowIds = new Set([targetRowId])"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("revealFileTreeResource,"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args);"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("revealFileTreeResource({"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("decodeLocalEncodedPath(id.slice('local-md:'.length))"));
assert!(
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("function localMarkdownBundleParentPath")
);
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("visibleFileTreeRowByRelativePath(bundleParentPath)"));
}
#[test]
+45
View File
@@ -1522,6 +1522,18 @@ body {
color: var(--atelier-text);
}
.wolai-section-add + .wolai-section-add {
margin-left: 2px;
}
.wolai-section-add .material-symbols-outlined {
width: 15px;
height: 15px;
font-size: 15px;
display: block;
color: currentColor;
}
.wolai-page-row {
min-height: 30px;
gap: 7px;
@@ -1560,6 +1572,39 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.wolai-row-more {
width: 26px;
height: 26px;
flex: 0 0 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #8C8983;
opacity: 0;
cursor: pointer;
}
.wolai-page-row:hover .wolai-row-more,
.wolai-row-more:focus-visible,
.wolai-row-more[aria-expanded="true"] {
opacity: 1;
}
.wolai-row-more:hover {
background: #E7E4E0;
color: #4B4945;
}
.wolai-row-more .material-symbols-outlined {
font-size: 18px;
line-height: 1;
}
.sidebar-tree-section {