Optimize local folder file tree lazy loading
This commit is contained in:
@@ -18,6 +18,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
shortMindmapFileName,
|
shortMindmapFileName,
|
||||||
syncSidebarFileTreeSelection,
|
syncSidebarFileTreeSelection,
|
||||||
} = dependencies;
|
} = dependencies;
|
||||||
|
var fileTreeLazyChildrenCache = new Map();
|
||||||
|
var fileTreeExpandedRelativePaths = new Set();
|
||||||
|
var fileTreeLazyCacheRootUri = '';
|
||||||
|
|
||||||
function updateTitleEverywhere(documentId, title) {
|
function updateTitleEverywhere(documentId, title) {
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
@@ -580,12 +583,13 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
var objectIdentity = fileObjectIdentity(item);
|
var objectIdentity = fileObjectIdentity(item);
|
||||||
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
||||||
var iconKind = iconKindOf(item);
|
var iconKind = iconKindOf(item);
|
||||||
|
var cachedChildren = relativePath ? (fileTreeLazyChildrenCache.get(relativePath) || []) : [];
|
||||||
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
||||||
? fileTreePageTitle(rawTitle)
|
? fileTreePageTitle(rawTitle)
|
||||||
: normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity);
|
: normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity);
|
||||||
var children = grouped.get(nodeId) || [];
|
var children = grouped.get(nodeId) || [];
|
||||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length || cachedChildren.length);
|
||||||
var expanded = expandable && item.expandedByDefault !== false;
|
var expanded = expandable && (fileTreeExpandedRelativePaths.has(relativePath) || item.expandedByDefault !== false);
|
||||||
var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId;
|
var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId;
|
||||||
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
|
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
|
||||||
var toggle = expandable
|
var toggle = expandable
|
||||||
@@ -594,16 +598,44 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
var createAction = rowKind === 'document'
|
var createAction = rowKind === 'document'
|
||||||
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
|
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
|
||||||
: '';
|
: '';
|
||||||
var childHtml = expandable
|
var childRowsHtml = children.length
|
||||||
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '</ul>'
|
? renderFileRows(nodeId, grouped, activeId, activeRowId)
|
||||||
|
: cachedChildren.length
|
||||||
|
? renderFileRows('', groupRowsByParent(cachedChildren), activeId, activeRowId)
|
||||||
|
: '';
|
||||||
|
var childHtml = expandable && expanded && childRowsHtml
|
||||||
|
? '<ul class="tree-children">' + childRowsHtml + '</ul>'
|
||||||
: '';
|
: '';
|
||||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureFileTreeLazyCacheScope() {
|
||||||
|
var rootUri = currentRootUri();
|
||||||
|
if (rootUri === fileTreeLazyCacheRootUri) return;
|
||||||
|
fileTreeLazyCacheRootUri = rootUri;
|
||||||
|
fileTreeLazyChildrenCache.clear();
|
||||||
|
fileTreeExpandedRelativePaths.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberFileTreeExpansionState() {
|
||||||
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
||||||
|
if (!(row instanceof HTMLElement)) return;
|
||||||
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
||||||
|
if (!relativePath) return;
|
||||||
|
if (row.getAttribute('aria-expanded') === 'true') {
|
||||||
|
fileTreeExpandedRelativePaths.add(relativePath);
|
||||||
|
} else {
|
||||||
|
fileTreeExpandedRelativePaths.delete(relativePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderFileProjection(projection) {
|
function renderFileProjection(projection) {
|
||||||
var tree = document.getElementById('sidebar-file-tree-root');
|
var tree = document.getElementById('sidebar-file-tree-root');
|
||||||
if (!tree) return false;
|
if (!tree) return false;
|
||||||
|
ensureFileTreeLazyCacheScope();
|
||||||
|
rememberFileTreeExpansionState();
|
||||||
var rows = projectionItems(projection);
|
var rows = projectionItems(projection);
|
||||||
var activeId = currentDocumentId();
|
var activeId = currentDocumentId();
|
||||||
var activeRowId = currentFileTreeActiveRowId();
|
var activeRowId = currentFileTreeActiveRowId();
|
||||||
@@ -763,18 +795,105 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localFileTreeRelativePathFromRow(row) {
|
||||||
|
if (!(row instanceof HTMLElement)) return '';
|
||||||
|
var direct = String(row.getAttribute('data-local-relative-path') || '').trim();
|
||||||
|
if (direct) return direct;
|
||||||
|
var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath');
|
||||||
|
if (runtimeFn) {
|
||||||
|
try {
|
||||||
|
return String(runtimeFn(row, { localFilePathFromAssetId: localFilePathFromAssetId }) || '').trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||||
|
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
|
||||||
|
if (rowId.indexOf('local:folder:') === 0) return rowId.slice('local:folder:'.length);
|
||||||
|
if (rowId.indexOf('local:markdown:') === 0) return rowId.slice('local:markdown:'.length);
|
||||||
|
if (rowId.indexOf('local:asset:') === 0) return rowId.slice('local:asset:'.length);
|
||||||
|
if (nodeId.indexOf('local:node:') === 0) return nodeId.slice('local:node:'.length);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTreeRowExpanded(row, button, expanded) {
|
||||||
|
row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
if (button) {
|
||||||
|
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
button.textContent = expanded ? '▾' : '▸';
|
||||||
|
}
|
||||||
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
||||||
|
if (!relativePath) return;
|
||||||
|
if (expanded) fileTreeExpandedRelativePaths.add(relativePath);
|
||||||
|
else fileTreeExpandedRelativePaths.delete(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCachedFileTreeChildren(row, button, relativePath) {
|
||||||
|
var cachedRows = fileTreeLazyChildrenCache.get(relativePath) || [];
|
||||||
|
if (!cachedRows.length) return false;
|
||||||
|
var node = row.closest('.tree-node');
|
||||||
|
if (!node) return false;
|
||||||
|
var children = node.querySelector(':scope > .tree-children');
|
||||||
|
if (!children) {
|
||||||
|
children = document.createElement('ul');
|
||||||
|
children.className = 'tree-children';
|
||||||
|
node.appendChild(children);
|
||||||
|
}
|
||||||
|
children.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId());
|
||||||
|
children.classList.remove('tree-children--collapsed');
|
||||||
|
row.setAttribute('data-filetree-children-loaded', 'true');
|
||||||
|
setTreeRowExpanded(row, button, true);
|
||||||
|
syncSidebarFileTreeSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFileTreeChildren(row, button) {
|
||||||
|
if (!(row instanceof HTMLElement)) return false;
|
||||||
|
if (row.getAttribute('data-shell-mode') !== 'filetree') return false;
|
||||||
|
if (currentSourceKind() !== 'local_folder') return false;
|
||||||
|
ensureFileTreeLazyCacheScope();
|
||||||
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
||||||
|
if (relativePath && renderCachedFileTreeChildren(row, button, relativePath)) return true;
|
||||||
|
if (row.getAttribute('data-filetree-children-loaded') === 'true') return false;
|
||||||
|
if (row.getAttribute('data-filetree-children-loading') === 'true') return true;
|
||||||
|
var rootUri = currentRootUri();
|
||||||
|
if (!rootUri || !relativePath) return false;
|
||||||
|
row.setAttribute('data-filetree-children-loading', 'true');
|
||||||
|
try {
|
||||||
|
var url = new URL('/api/tree/projections/file/children', window.location.origin);
|
||||||
|
var workspaceId = currentWorkspaceId();
|
||||||
|
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||||
|
url.searchParams.set('sourceKind', 'local_folder');
|
||||||
|
url.searchParams.set('rootUri', rootUri);
|
||||||
|
url.searchParams.set('parentRelativePath', relativePath);
|
||||||
|
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
|
||||||
|
var payload = await response.json().catch(function() { return null; });
|
||||||
|
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_children_failed_' + response.status);
|
||||||
|
var projection = readProjection(payload && (payload.result || payload));
|
||||||
|
var rows = projectionItems(projection);
|
||||||
|
fileTreeLazyChildrenCache.set(relativePath, rows);
|
||||||
|
if (!rows.length) {
|
||||||
|
row.setAttribute('data-filetree-children-loaded', 'true');
|
||||||
|
setTreeRowExpanded(row, button, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return renderCachedFileTreeChildren(row, button, relativePath);
|
||||||
|
} catch (error) {
|
||||||
|
setTreeLiveApplyError(error && error.message ? error.message : '文件树子目录加载失败');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
row.removeAttribute('data-filetree-children-loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleChildren(row, button) {
|
function toggleChildren(row, button) {
|
||||||
var li = row && row.parentElement;
|
var li = row && row.parentElement;
|
||||||
var children = li ? li.querySelector(':scope > .tree-children') : null;
|
var children = li ? li.querySelector(':scope > .tree-children') : null;
|
||||||
if (!children) return;
|
if (!children) {
|
||||||
|
void loadFileTreeChildren(row, button);
|
||||||
|
return;
|
||||||
|
}
|
||||||
children.classList.toggle('tree-children--collapsed');
|
children.classList.toggle('tree-children--collapsed');
|
||||||
var collapsed = children.classList.contains('tree-children--collapsed');
|
var collapsed = children.classList.contains('tree-children--collapsed');
|
||||||
row.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
setTreeRowExpanded(row, button, !collapsed);
|
||||||
if (button && button.getAttribute('data-testid') === 'tree-node-toggle') {
|
|
||||||
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
||||||
} else if (button) {
|
|
||||||
button.textContent = collapsed ? '▸' : '▾';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ pub(crate) struct RootEntryQuery {
|
|||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
source_kind: Option<String>,
|
source_kind: Option<String>,
|
||||||
root_uri: Option<String>,
|
root_uri: Option<String>,
|
||||||
|
tree_view: Option<String>,
|
||||||
restore_focus_row_id: Option<String>,
|
restore_focus_row_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +295,11 @@ pub async fn root_entry(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.is_none()
|
.is_none()
|
||||||
&& requested_page_id.is_none();
|
&& requested_page_id.is_none();
|
||||||
|
let requests_filetree_first = query
|
||||||
|
.tree_view
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| value == "filetree");
|
||||||
let (
|
let (
|
||||||
workspace_id,
|
workspace_id,
|
||||||
workspace_projection,
|
workspace_projection,
|
||||||
@@ -312,7 +318,14 @@ pub async fn root_entry(
|
|||||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||||
})?;
|
})?;
|
||||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
|
||||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
let snapshot = if requests_filetree_first {
|
||||||
|
crate::routes::local_folder_source::load_local_folder_file_tree_snapshot_with_reveal(
|
||||||
|
root_uri,
|
||||||
|
requested_page_id.as_deref(),
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
load_local_folder_page_tree_snapshot(root_uri)?
|
||||||
|
};
|
||||||
let workspace_id = snapshot
|
let workspace_id = snapshot
|
||||||
.dataset
|
.dataset
|
||||||
.get("workspace")
|
.get("workspace")
|
||||||
@@ -339,8 +352,11 @@ pub async fn root_entry(
|
|||||||
.first()
|
.first()
|
||||||
.map(|item| item.id.as_str()),
|
.map(|item| item.id.as_str()),
|
||||||
);
|
);
|
||||||
let sidebar_tree_html =
|
let sidebar_tree_html = if requests_filetree_first {
|
||||||
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
|
String::new()
|
||||||
|
} else {
|
||||||
|
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?
|
||||||
|
};
|
||||||
let restore_focus_row_id = query
|
let restore_focus_row_id = query
|
||||||
.restore_focus_row_id
|
.restore_focus_row_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -2755,8 +2771,9 @@ mod tests {
|
|||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||||
assert!(html.contains("local_folder"));
|
assert!(html.contains("local_folder"));
|
||||||
assert!(html.contains("Local Root"));
|
assert!(html.contains("README.md"));
|
||||||
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
|
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
|
||||||
|
assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#));
|
||||||
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
|
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
|
||||||
assert!(!html.contains("legacy_next_compat_disabled"));
|
assert!(!html.contains("legacy_next_compat_disabled"));
|
||||||
assert!(!html.contains(r#"href="/tree"#));
|
assert!(!html.contains(r#"href="/tree"#));
|
||||||
@@ -2798,7 +2815,8 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||||
assert!(html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
|
assert!(html.contains(r#"data-row-id="local:folder:attachments""#));
|
||||||
|
assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
|
||||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::local_folder_source::{
|
use crate::routes::local_folder_source::{
|
||||||
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot,
|
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot,
|
||||||
|
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
|
||||||
load_local_folder_page_tree_snapshot,
|
load_local_folder_page_tree_snapshot,
|
||||||
};
|
};
|
||||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||||
@@ -28,6 +29,7 @@ pub struct KernelProjectionQuery {
|
|||||||
pub max_results: Option<usize>,
|
pub max_results: Option<usize>,
|
||||||
pub source_kind: Option<String>,
|
pub source_kind: Option<String>,
|
||||||
pub root_uri: Option<String>,
|
pub root_uri: Option<String>,
|
||||||
|
pub parent_relative_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -88,7 +90,27 @@ async fn project_projection(
|
|||||||
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
|
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
|
||||||
.map_err(|error| error.with_context(&context))?;
|
.map_err(|error| error.with_context(&context))?;
|
||||||
let snapshot = if projection == KernelProjectionKind::FileTree {
|
let snapshot = if projection == KernelProjectionKind::FileTree {
|
||||||
load_local_folder_file_tree_snapshot(root_uri)?
|
if let Some(parent_relative_path) = query
|
||||||
|
.parent_relative_path
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
load_local_folder_file_tree_children_snapshot(root_uri, parent_relative_path)?
|
||||||
|
} else if query
|
||||||
|
.root_node_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
load_local_folder_file_tree_snapshot_with_reveal(
|
||||||
|
root_uri,
|
||||||
|
query.root_node_id.as_deref(),
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
load_local_folder_file_tree_snapshot(root_uri)?
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
load_local_folder_page_tree_snapshot(root_uri)?
|
load_local_folder_page_tree_snapshot(root_uri)?
|
||||||
};
|
};
|
||||||
@@ -371,6 +393,56 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_folder_file_projection_can_load_direct_children_by_parent_path() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-local-kernel-children-projection-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(root.join("docs").join("nested")).expect("create nested dirs");
|
||||||
|
std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme");
|
||||||
|
std::fs::write(root.join("docs").join("nested").join("deep.md"), "# Deep\n")
|
||||||
|
.expect("write deep");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
|
||||||
|
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/api/tree/projections/file?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs"
|
||||||
|
))
|
||||||
|
.header("x-mnote-actor-id", "dev-user")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
let items = payload["result"]["items"].as_array().expect("items");
|
||||||
|
let titles = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item["title"].as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(payload["result"]["parentRelativePath"], "docs");
|
||||||
|
assert!(titles.contains(&"README.md"));
|
||||||
|
assert!(titles.contains(&"nested"));
|
||||||
|
assert!(!titles.contains(&"deep.md"));
|
||||||
|
assert!(items
|
||||||
|
.iter()
|
||||||
|
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs")));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tree_projection_routes_keep_ok_response_shape() {
|
async fn tree_projection_routes_keep_ok_response_shape() {
|
||||||
let response = app()
|
let response = app()
|
||||||
|
|||||||
@@ -2554,6 +2554,31 @@ fn local_workspace_manifest_json(manifest: &LocalWorkspaceManifest) -> Value {
|
|||||||
|
|
||||||
pub fn load_local_folder_file_tree_snapshot(
|
pub fn load_local_folder_file_tree_snapshot(
|
||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
|
) -> Result<ProjectionSnapshot, WebError> {
|
||||||
|
load_local_folder_file_tree_scope_snapshot(root_uri, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_local_folder_file_tree_snapshot_with_reveal(
|
||||||
|
root_uri: &str,
|
||||||
|
reveal_document_id: Option<&str>,
|
||||||
|
) -> Result<ProjectionSnapshot, WebError> {
|
||||||
|
let reveal_relative_path = reveal_document_id
|
||||||
|
.and_then(local_markdown_relative_path_from_document_id)
|
||||||
|
.map(|path| path.replace('\\', "/"));
|
||||||
|
load_local_folder_file_tree_scope_snapshot(root_uri, None, reveal_relative_path.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_local_folder_file_tree_children_snapshot(
|
||||||
|
root_uri: &str,
|
||||||
|
parent_relative_path: &str,
|
||||||
|
) -> Result<ProjectionSnapshot, WebError> {
|
||||||
|
load_local_folder_file_tree_scope_snapshot(root_uri, Some(parent_relative_path), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_local_folder_file_tree_scope_snapshot(
|
||||||
|
root_uri: &str,
|
||||||
|
parent_relative_path: Option<&str>,
|
||||||
|
reveal_relative_path: Option<&str>,
|
||||||
) -> Result<ProjectionSnapshot, WebError> {
|
) -> Result<ProjectionSnapshot, WebError> {
|
||||||
let root_path = parse_file_root_uri(root_uri)?;
|
let root_path = parse_file_root_uri(root_uri)?;
|
||||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||||
@@ -2572,23 +2597,56 @@ pub fn load_local_folder_file_tree_snapshot(
|
|||||||
let workspace_id = local_workspace_id(&canonical_root);
|
let workspace_id = local_workspace_id(&canonical_root);
|
||||||
let root_source_uri = file_uri_for_path(&canonical_root);
|
let root_source_uri = file_uri_for_path(&canonical_root);
|
||||||
let metadata = load_local_folder_metadata(&canonical_root)?;
|
let metadata = load_local_folder_metadata(&canonical_root)?;
|
||||||
|
let parent_relative_path = parent_relative_path
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && *value != ".")
|
||||||
|
.unwrap_or("");
|
||||||
|
let scan_root = if parent_relative_path.is_empty() {
|
||||||
|
canonical_root.clone()
|
||||||
|
} else {
|
||||||
|
resolve_metadata_relative_path(&canonical_root, parent_relative_path)?
|
||||||
|
};
|
||||||
|
if !scan_root.is_dir() {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_folder_parent_not_directory",
|
||||||
|
"FileTree 懒加载 parentRelativePath 必须指向目录",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let parent_node_id = if parent_relative_path.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(local_node_id(parent_relative_path))
|
||||||
|
};
|
||||||
|
let depth = local_folder_relative_depth(parent_relative_path);
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
scan_directory(
|
scan_directory(
|
||||||
&canonical_root,
|
&canonical_root,
|
||||||
&canonical_root,
|
&scan_root,
|
||||||
None,
|
parent_node_id,
|
||||||
0,
|
depth,
|
||||||
|
Some(depth),
|
||||||
&root_source_uri,
|
&root_source_uri,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
&mut rows,
|
&mut rows,
|
||||||
)?;
|
)?;
|
||||||
|
if parent_relative_path.is_empty() {
|
||||||
|
append_file_tree_reveal_rows(
|
||||||
|
&canonical_root,
|
||||||
|
reveal_relative_path,
|
||||||
|
&root_source_uri,
|
||||||
|
&workspace_id,
|
||||||
|
&metadata,
|
||||||
|
&mut rows,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
let items = rows
|
let items = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(local_folder_row_to_projection_item)
|
.map(local_folder_row_to_projection_item)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
|
let watch_revision =
|
||||||
|
local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)?;
|
||||||
let dataset = json!({
|
let dataset = json!({
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"id": &workspace_id,
|
"id": &workspace_id,
|
||||||
@@ -2607,6 +2665,8 @@ pub fn load_local_folder_file_tree_snapshot(
|
|||||||
"projection": "file_tree",
|
"projection": "file_tree",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
"rootUri": root_source_uri,
|
"rootUri": root_source_uri,
|
||||||
|
"parentRelativePath": parent_relative_path,
|
||||||
|
"lazy": true,
|
||||||
"watchRevision": watch_revision,
|
"watchRevision": watch_revision,
|
||||||
"items": items,
|
"items": items,
|
||||||
});
|
});
|
||||||
@@ -6044,6 +6104,7 @@ fn scan_directory(
|
|||||||
directory: &Path,
|
directory: &Path,
|
||||||
parent_node_id: Option<String>,
|
parent_node_id: Option<String>,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
|
max_depth: Option<u32>,
|
||||||
root_source_uri: &str,
|
root_source_uri: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
metadata: &LocalFolderMetadata,
|
metadata: &LocalFolderMetadata,
|
||||||
@@ -6052,7 +6113,6 @@ fn scan_directory(
|
|||||||
let mut entries = read_sorted_entries(directory, root)?;
|
let mut entries = read_sorted_entries(directory, root)?;
|
||||||
let parent_key = file_order_parent_key_for_directory(root, directory)?;
|
let parent_key = file_order_parent_key_for_directory(root, directory)?;
|
||||||
sort_entries_with_file_order(&mut entries, metadata, &parent_key);
|
sort_entries_with_file_order(&mut entries, metadata, &parent_key);
|
||||||
let entry_count = entries.len();
|
|
||||||
for (position, entry) in entries.into_iter().enumerate() {
|
for (position, entry) in entries.into_iter().enumerate() {
|
||||||
let node_id = local_node_id(if entry.relative_path.is_empty() {
|
let node_id = local_node_id(if entry.relative_path.is_empty() {
|
||||||
"."
|
"."
|
||||||
@@ -6060,7 +6120,11 @@ fn scan_directory(
|
|||||||
&entry.relative_path
|
&entry.relative_path
|
||||||
});
|
});
|
||||||
let child_count = if entry.is_dir && !entry.is_symlink {
|
let child_count = if entry.is_dir && !entry.is_symlink {
|
||||||
count_visible_children(&entry.path, root)?
|
if has_visible_child(&entry.path, root)? {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -6088,7 +6152,7 @@ fn scan_directory(
|
|||||||
source_uri: file_uri_for_path(&entry.path),
|
source_uri: file_uri_for_path(&entry.path),
|
||||||
child_count,
|
child_count,
|
||||||
expandable: entry.is_dir && child_count > 0,
|
expandable: entry.is_dir && child_count > 0,
|
||||||
expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80,
|
expanded_by_default: false,
|
||||||
document_id: if let Some(asset) = uploaded_asset {
|
document_id: if let Some(asset) = uploaded_asset {
|
||||||
Some(asset.document_id.clone())
|
Some(asset.document_id.clone())
|
||||||
} else if is_markdown_file(&entry.file_name) {
|
} else if is_markdown_file(&entry.file_name) {
|
||||||
@@ -6100,12 +6164,13 @@ fn scan_directory(
|
|||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
});
|
});
|
||||||
if entry.is_dir && !entry.is_symlink {
|
if entry.is_dir && !entry.is_symlink && max_depth.map_or(true, |limit| depth < limit) {
|
||||||
scan_directory(
|
scan_directory(
|
||||||
root,
|
root,
|
||||||
&entry.path,
|
&entry.path,
|
||||||
Some(node_id),
|
Some(node_id),
|
||||||
depth + 1,
|
depth + 1,
|
||||||
|
max_depth,
|
||||||
root_source_uri,
|
root_source_uri,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -6117,6 +6182,91 @@ fn scan_directory(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
||||||
|
let encoded = document_id.trim().strip_prefix("local-md:")?;
|
||||||
|
decode_local_id_segment(encoded).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ancestor_directories_for_relative_path(relative_path: &str) -> Vec<String> {
|
||||||
|
let normalized = relative_path.trim().trim_matches('/');
|
||||||
|
if normalized.is_empty() || normalized == "." {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut ancestors = Vec::new();
|
||||||
|
let mut current = Path::new(normalized).parent();
|
||||||
|
while let Some(parent) = current {
|
||||||
|
let value = parent
|
||||||
|
.components()
|
||||||
|
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
if value.is_empty() || value == "." {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ancestors.push(value);
|
||||||
|
current = parent.parent();
|
||||||
|
}
|
||||||
|
ancestors.reverse();
|
||||||
|
ancestors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_file_tree_reveal_rows(
|
||||||
|
root: &Path,
|
||||||
|
reveal_relative_path: Option<&str>,
|
||||||
|
root_source_uri: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
metadata: &LocalFolderMetadata,
|
||||||
|
rows: &mut Vec<LocalFolderRow>,
|
||||||
|
) -> Result<(), WebError> {
|
||||||
|
let Some(reveal_relative_path) = reveal_relative_path
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let ancestors = ancestor_directories_for_relative_path(reveal_relative_path);
|
||||||
|
if ancestors.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ancestor_set = ancestors.iter().cloned().collect::<BTreeSet<_>>();
|
||||||
|
let mut seen = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.row_id.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
|
||||||
|
for ancestor in &ancestors {
|
||||||
|
let directory = resolve_metadata_relative_path(root, ancestor)?;
|
||||||
|
if !directory.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let depth = local_folder_relative_depth(ancestor);
|
||||||
|
let mut scoped_rows = Vec::new();
|
||||||
|
scan_directory(
|
||||||
|
root,
|
||||||
|
&directory,
|
||||||
|
Some(local_node_id(ancestor)),
|
||||||
|
depth,
|
||||||
|
Some(depth),
|
||||||
|
root_source_uri,
|
||||||
|
workspace_id,
|
||||||
|
metadata,
|
||||||
|
&mut scoped_rows,
|
||||||
|
)?;
|
||||||
|
for row in scoped_rows {
|
||||||
|
if seen.insert(row.row_id.clone()) {
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows.iter_mut() {
|
||||||
|
if ancestor_set.contains(&row.relative_path) {
|
||||||
|
row.expanded_by_default = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_sorted_entries(directory: &Path, root: &Path) -> Result<Vec<LocalFolderEntry>, WebError> {
|
fn read_sorted_entries(directory: &Path, root: &Path) -> Result<Vec<LocalFolderEntry>, WebError> {
|
||||||
let read_dir = fs::read_dir(directory).map_err(|error| {
|
let read_dir = fs::read_dir(directory).map_err(|error| {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
@@ -6160,8 +6310,28 @@ fn read_sorted_entries(directory: &Path, root: &Path) -> Result<Vec<LocalFolderE
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_visible_children(directory: &Path, root: &Path) -> Result<u32, WebError> {
|
fn has_visible_child(directory: &Path, root: &Path) -> Result<bool, WebError> {
|
||||||
Ok(read_sorted_entries(directory, root)?.len() as u32)
|
let read_dir = fs::read_dir(directory).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_folder_scan_failed",
|
||||||
|
format!("无法读取本地目录 {}: {error}", directory.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
for entry in read_dir {
|
||||||
|
let entry = entry.map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_folder_scan_failed",
|
||||||
|
format!("读取目录项失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let relative_path = normalize_relative_path(root, &path)?;
|
||||||
|
if !should_ignore_entry(&relative_path, &file_name) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_entry_capabilities(entry: &LocalFolderEntry) -> Vec<String> {
|
fn local_entry_capabilities(entry: &LocalFolderEntry) -> Vec<String> {
|
||||||
@@ -6192,6 +6362,17 @@ fn normalize_relative_path(root: &Path, path: &Path) -> Result<String, WebError>
|
|||||||
.join("/"))
|
.join("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_folder_relative_depth(relative_path: &str) -> u32 {
|
||||||
|
let trimmed = relative_path.trim().trim_matches('/');
|
||||||
|
if trimmed.is_empty() || trimmed == "." {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
trimmed
|
||||||
|
.split('/')
|
||||||
|
.filter(|segment| !segment.trim().is_empty())
|
||||||
|
.count() as u32
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
|
fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||||
let relative = Path::new(relative_path);
|
let relative = Path::new(relative_path);
|
||||||
if relative.is_absolute()
|
if relative.is_absolute()
|
||||||
@@ -6884,6 +7065,44 @@ fn local_folder_watch_revision_for_root(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_folder_watch_revision_for_directory(
|
||||||
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
|
root_source_uri: &str,
|
||||||
|
) -> Result<LocalFolderWatchRevision, WebError> {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
let mut entry_count = 0usize;
|
||||||
|
let mut latest_modified_ms = 0u128;
|
||||||
|
|
||||||
|
for entry in read_sorted_entries(directory, root)? {
|
||||||
|
entry.relative_path.hash(&mut hasher);
|
||||||
|
entry.file_name.hash(&mut hasher);
|
||||||
|
entry.is_dir.hash(&mut hasher);
|
||||||
|
entry.is_symlink.hash(&mut hasher);
|
||||||
|
entry.is_readonly.hash(&mut hasher);
|
||||||
|
if let Ok(meta) = fs::symlink_metadata(&entry.path) {
|
||||||
|
meta.len().hash(&mut hasher);
|
||||||
|
if let Ok(modified) = meta.modified() {
|
||||||
|
if let Ok(delta) = modified.duration_since(UNIX_EPOCH) {
|
||||||
|
let modified_ms = delta.as_millis();
|
||||||
|
modified_ms.hash(&mut hasher);
|
||||||
|
if modified_ms > latest_modified_ms {
|
||||||
|
latest_modified_ms = modified_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LocalFolderWatchRevision {
|
||||||
|
root_uri: root_source_uri.to_string(),
|
||||||
|
revision: format!("{:016x}", hasher.finish()),
|
||||||
|
entry_count,
|
||||||
|
latest_modified_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn file_uri_for_path(path: &Path) -> String {
|
fn file_uri_for_path(path: &Path) -> String {
|
||||||
format!("file://{}", path.display())
|
format!("file://{}", path.display())
|
||||||
}
|
}
|
||||||
@@ -8244,7 +8463,8 @@ mod tests {
|
|||||||
ensure_local_workspace_write_access_with_state, execute_local_tree_command,
|
ensure_local_workspace_write_access_with_state, execute_local_tree_command,
|
||||||
execute_local_tree_command_with_sort, get_local_access_policy, get_share_grants,
|
execute_local_tree_command_with_sort, get_local_access_policy, get_share_grants,
|
||||||
get_share_links, get_user_access_policy, initialize_local_page_id,
|
get_share_links, get_user_access_policy, initialize_local_page_id,
|
||||||
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
|
initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot,
|
||||||
|
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
|
||||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||||
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
|
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
|
||||||
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
|
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
|
||||||
@@ -10285,6 +10505,38 @@ fn main() {}
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_file_tree_snapshot_loads_root_level_only() {
|
||||||
|
let root = temp_root("mnote-local-file-tree-root-only");
|
||||||
|
init_workspace(&root);
|
||||||
|
std::fs::create_dir_all(root.join("docs").join("nested")).expect("create nested dirs");
|
||||||
|
std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme");
|
||||||
|
std::fs::write(root.join("docs").join("nested").join("deep.md"), "# Deep\n")
|
||||||
|
.expect("write deep");
|
||||||
|
std::fs::write(root.join("root.md"), "# Root\n").expect("write root");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree");
|
||||||
|
let items = snapshot.projection["items"].as_array().expect("items");
|
||||||
|
let titles = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item["title"].as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(titles.contains(&"docs"));
|
||||||
|
assert!(titles.contains(&"root.md"));
|
||||||
|
assert!(!titles.contains(&"README.md"));
|
||||||
|
assert!(!titles.contains(&"deep.md"));
|
||||||
|
let docs = items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["title"].as_str() == Some("docs"))
|
||||||
|
.expect("docs row");
|
||||||
|
assert_eq!(docs["parentNodeId"].as_str(), None);
|
||||||
|
assert_eq!(docs["expandable"].as_bool(), Some(true));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_tree_command_refreshes_search_index_after_rename() {
|
fn local_tree_command_refreshes_search_index_after_rename() {
|
||||||
let root = temp_root("mnote-local-tree-refresh-search-index");
|
let root = temp_root("mnote-local-tree-refresh-search-index");
|
||||||
@@ -11333,7 +11585,8 @@ fn main() {}
|
|||||||
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree");
|
let snapshot =
|
||||||
|
load_local_folder_file_tree_children_snapshot(&root_uri, "Page").expect("file tree");
|
||||||
let items = snapshot.projection["items"].as_array().expect("items");
|
let items = snapshot.projection["items"].as_array().expect("items");
|
||||||
let mindmap = items
|
let mindmap = items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -11600,7 +11853,11 @@ fn main() {}
|
|||||||
.expect("write Child.md");
|
.expect("write Child.md");
|
||||||
|
|
||||||
// 读取文件树。
|
// 读取文件树。
|
||||||
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree");
|
let snapshot = load_local_folder_file_tree_snapshot_with_reveal(
|
||||||
|
&root_uri,
|
||||||
|
Some("local-md:Root~2FChild~2FChild.md"),
|
||||||
|
)
|
||||||
|
.expect("load file tree");
|
||||||
let items = snapshot.projection["items"].as_array().expect("items");
|
let items = snapshot.projection["items"].as_array().expect("items");
|
||||||
|
|
||||||
// 文件树应保留 Root 文件夹和其中的 Root.md 真实条目。
|
// 文件树应保留 Root 文件夹和其中的 Root.md 真实条目。
|
||||||
@@ -11703,7 +11960,8 @@ fn main() {}
|
|||||||
.expect("uploaded asset index");
|
.expect("uploaded asset index");
|
||||||
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
|
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
|
||||||
|
|
||||||
let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree");
|
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
|
||||||
|
.expect("file tree");
|
||||||
let items = snapshot.projection["items"].as_array().expect("items");
|
let items = snapshot.projection["items"].as_array().expect("items");
|
||||||
let notes_row = items
|
let notes_row = items
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -391,6 +391,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/api/tree/projections/page", get(kernel::project_tree_page))
|
.route("/api/tree/projections/page", get(kernel::project_tree_page))
|
||||||
.route("/api/tree/projections/file", get(kernel::project_tree_file))
|
.route("/api/tree/projections/file", get(kernel::project_tree_file))
|
||||||
|
.route(
|
||||||
|
"/api/tree/projections/file/children",
|
||||||
|
get(kernel::project_tree_file),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/kernel/projections/sidebar",
|
"/api/kernel/projections/sidebar",
|
||||||
get(kernel::project_sidebar),
|
get(kernel::project_sidebar),
|
||||||
|
|||||||
@@ -2741,7 +2741,8 @@ mod tests {
|
|||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains("local_folder"));
|
assert!(html.contains("local_folder"));
|
||||||
assert!(html.contains("README.md"));
|
assert!(html.contains("README.md"));
|
||||||
assert!(html.contains("child.md"));
|
assert!(html.contains("docs"));
|
||||||
|
assert!(!html.contains("child.md"));
|
||||||
assert!(html.contains("image.png"));
|
assert!(html.contains("image.png"));
|
||||||
assert!(html.contains("data-row-kind=\"folder\""));
|
assert!(html.contains("data-row-kind=\"folder\""));
|
||||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::routes::documents::{
|
|||||||
use crate::routes::gateway::default_workspace_name_for_context;
|
use crate::routes::gateway::default_workspace_name_for_context;
|
||||||
use crate::routes::local_folder_source::{
|
use crate::routes::local_folder_source::{
|
||||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
|
||||||
resolve_local_markdown_page_aggregate,
|
resolve_local_markdown_page_aggregate,
|
||||||
};
|
};
|
||||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||||
@@ -1594,7 +1594,7 @@ pub(crate) fn render_local_file_tree_html(
|
|||||||
active_document_id: Option<&str>,
|
active_document_id: Option<&str>,
|
||||||
active_row_id: Option<&str>,
|
active_row_id: Option<&str>,
|
||||||
) -> Result<String, WebError> {
|
) -> Result<String, WebError> {
|
||||||
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
|
let snapshot = load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?;
|
||||||
let rows =
|
let rows =
|
||||||
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
|
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
|
||||||
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||||
|
|||||||
@@ -997,6 +997,15 @@ mod tests {
|
|||||||
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowId === 'index:' + activeId"));
|
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowId === 'index:' + activeId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_filetree_runtime_supports_lazy_children_projection() {
|
||||||
|
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("fileTreeExpandedRelativePaths"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_tree_delete_to_trash_dispatches_archive_not_purge() {
|
fn sidebar_tree_delete_to_trash_dispatches_archive_not_purge() {
|
||||||
let delete_trash_start = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
let delete_trash_start = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||||
|
|||||||
@@ -108,13 +108,9 @@ fn render_filetree_row(
|
|||||||
create_action_html = create_action_html,
|
create_action_html = create_action_html,
|
||||||
title = escape_html(&row.title),
|
title = escape_html(&row.title),
|
||||||
));
|
));
|
||||||
if row.expandable {
|
if row.expandable && row.expanded {
|
||||||
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||||
html.push_str(if row.expanded {
|
html.push_str(r#"<ul class="tree-children">"#);
|
||||||
r#"<ul class="tree-children">"#
|
|
||||||
} else {
|
|
||||||
r#"<ul class="tree-children tree-children--collapsed">"#
|
|
||||||
});
|
|
||||||
for child in children {
|
for child in children {
|
||||||
render_filetree_row(html, child, children_by_parent);
|
render_filetree_row(html, child, children_by_parent);
|
||||||
}
|
}
|
||||||
@@ -221,4 +217,47 @@ mod tests {
|
|||||||
assert!(html.contains("title=\"思维导图.json\""));
|
assert!(html.contains("title=\"思维导图.json\""));
|
||||||
assert!(html.contains("data-selected=\"true\""));
|
assert!(html.contains("data-selected=\"true\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_filetree_renderer_does_not_ssr_collapsed_descendants() {
|
||||||
|
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||||
|
rows: vec![
|
||||||
|
FileTreeRenderRow {
|
||||||
|
row_id: "local:folder:docs".into(),
|
||||||
|
row_kind: "folder".into(),
|
||||||
|
node_id: "local:node:docs".into(),
|
||||||
|
parent_node_id: None,
|
||||||
|
title: "docs".into(),
|
||||||
|
depth: 0,
|
||||||
|
expandable: true,
|
||||||
|
expanded: false,
|
||||||
|
icon_kind: "folder".into(),
|
||||||
|
document_id: None,
|
||||||
|
asset_id: None,
|
||||||
|
object_identity: None,
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
FileTreeRenderRow {
|
||||||
|
row_id: "local:markdown:docs/README.md".into(),
|
||||||
|
row_kind: "markdown".into(),
|
||||||
|
node_id: "local:node:docs/README.md".into(),
|
||||||
|
parent_node_id: Some("local:node:docs".into()),
|
||||||
|
title: "README.md".into(),
|
||||||
|
depth: 1,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
icon_kind: "file".into(),
|
||||||
|
document_id: Some("local-md:docs~2FREADME.md".into()),
|
||||||
|
asset_id: None,
|
||||||
|
object_identity: None,
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(html.contains("data-row-id=\"local:folder:docs\""));
|
||||||
|
assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\""));
|
||||||
|
assert!(!html.contains("README.md"));
|
||||||
|
assert!(!html.contains("tree-children--collapsed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const runtimePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"rust",
|
||||||
|
"crates",
|
||||||
|
"mnote-web",
|
||||||
|
"browser",
|
||||||
|
"sidebar-tree-live-apply-runtime.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const source = fs
|
||||||
|
.readFileSync(runtimePath, "utf8")
|
||||||
|
.replace(
|
||||||
|
"export const createSidebarTreeLiveApplyRuntime =",
|
||||||
|
"window.createSidebarTreeLiveApplyRuntime =",
|
||||||
|
);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto("http://127.0.0.1:3000/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.setContent(
|
||||||
|
'<!doctype html><html><body><div id="sidebar-file-tree-root"></div><div id="sidebar-tree-root"></div></body></html>',
|
||||||
|
);
|
||||||
|
await page.addScriptTag({ content: source });
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
String(value || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
const rootProjection = {
|
||||||
|
dataset: {
|
||||||
|
kernel_file_tree_projection: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rowId: "local:folder:design",
|
||||||
|
rowKind: "folder",
|
||||||
|
nodeId: "local:node:design",
|
||||||
|
parentNodeId: null,
|
||||||
|
title: "design",
|
||||||
|
depth: 0,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
resourceMeta: {
|
||||||
|
extra: {
|
||||||
|
source: {
|
||||||
|
relativePath: "design",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const childrenProjection = {
|
||||||
|
result: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rowId: "local:folder:design/03-rust-web",
|
||||||
|
rowKind: "folder",
|
||||||
|
nodeId: "local:node:design/03-rust-web",
|
||||||
|
parentNodeId: "local:node:design",
|
||||||
|
title: "03-rust-web",
|
||||||
|
depth: 1,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
resourceMeta: {
|
||||||
|
extra: {
|
||||||
|
source: {
|
||||||
|
relativePath: "design/03-rust-web",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => childrenProjection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = window.createSidebarTreeLiveApplyRuntime({
|
||||||
|
activeSidebarTreeMode: () => "filetree",
|
||||||
|
cssEscape: (value) => CSS.escape(String(value || "")),
|
||||||
|
currentDocumentId: () => "local-md:AGENTS.md",
|
||||||
|
currentFileTreeActiveRowId: () => "",
|
||||||
|
currentRootUri: () => "file:///tmp/mnote-smoke",
|
||||||
|
currentSourceKind: () => "local_folder",
|
||||||
|
currentWorkspaceId: () => "local:test",
|
||||||
|
escapeHtml,
|
||||||
|
fileTreeRuntimeFunction: () => null,
|
||||||
|
localFilePathFromAssetId: () => "",
|
||||||
|
navigateToDocument: () => {},
|
||||||
|
refreshEditorLocalAttachmentExistence: () => {},
|
||||||
|
restoreSidebarTreeTab: () => {},
|
||||||
|
rowTitle: (row) => (row ? row.textContent || "" : ""),
|
||||||
|
schedulePendingLocalFolderRestoreFocus: () => {},
|
||||||
|
shortMindmapFileName: () => "mindmap.json",
|
||||||
|
syncSidebarFileTreeSelection: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = runtime.renderSidebarSnapshot(rootProjection);
|
||||||
|
const designRow = document.querySelector('[data-row-id="local:folder:design"]');
|
||||||
|
if (!rendered || !designRow) {
|
||||||
|
return {
|
||||||
|
rendered,
|
||||||
|
loadedBeforeRefresh: false,
|
||||||
|
expandedBeforeRefresh: false,
|
||||||
|
loadedAfterRefresh: false,
|
||||||
|
expandedAfterRefresh: false,
|
||||||
|
html: document.getElementById("sidebar-file-tree-root").innerHTML,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const toggle = designRow && designRow.querySelector('[data-rust-action="toggle"]');
|
||||||
|
runtime.toggleChildren(designRow, toggle);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const loadedBeforeRefresh = Boolean(
|
||||||
|
document.querySelector('[data-row-id="local:folder:design/03-rust-web"]'),
|
||||||
|
);
|
||||||
|
const expandedBeforeRefresh = designRow.getAttribute("aria-expanded") === "true";
|
||||||
|
|
||||||
|
runtime.renderSidebarSnapshot(rootProjection);
|
||||||
|
const loadedAfterRefresh = Boolean(
|
||||||
|
document.querySelector('[data-row-id="local:folder:design/03-rust-web"]'),
|
||||||
|
);
|
||||||
|
const refreshedDesignRow = document.querySelector('[data-row-id="local:folder:design"]');
|
||||||
|
const expandedAfterRefresh =
|
||||||
|
refreshedDesignRow && refreshedDesignRow.getAttribute("aria-expanded") === "true";
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadedBeforeRefresh,
|
||||||
|
expandedBeforeRefresh,
|
||||||
|
loadedAfterRefresh,
|
||||||
|
expandedAfterRefresh,
|
||||||
|
error: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||||
|
html: document.getElementById("sidebar-file-tree-root").innerHTML,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (!result.loadedBeforeRefresh || !result.expandedBeforeRefresh) {
|
||||||
|
throw new Error(`懒加载前置步骤失败: ${JSON.stringify(result, null, 2)}`);
|
||||||
|
}
|
||||||
|
if (!result.loadedAfterRefresh || !result.expandedAfterRefresh) {
|
||||||
|
throw new Error(`FileTree root projection 刷新后丢失已展开子树: ${JSON.stringify(result, null, 2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user