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
@@ -66,7 +66,7 @@ import {
return window.__mnoteLeptosTiptapRuntimePromise;
}
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
const manifest = await manifestResponse.json();
if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath');
@@ -368,16 +368,19 @@ import {
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
mindmapHost.unmountMindmapPane(paneRole);
const runtime = await loadRuntime();
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
const runtimePromise = loadRuntime();
const aggregatePromise = options.aggregate
? Promise.resolve(options.aggregate)
: fetchPageAggregateForPane(descriptor);
const previousView = paneViewRegistry.get(paneRole);
const [runtime, aggregate] = await Promise.all([runtimePromise, aggregatePromise]);
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete(paneRole);
}
const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor);
const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole);
syncPageAggregateScript({ pageAggregateScriptId: bootstrap.pageAggregateScriptId }, aggregate);
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap };
@@ -24,7 +24,7 @@ function fileTreeRowAssetId(row, deps) {
}
function decodeLocalEncodedPath(value) {
var path = String(value || '').trim().replace(/~2F/g, '/');
var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1');
if (!path) return '';
try {
return decodeURIComponent(path);
@@ -5,6 +5,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
buildLocalOnlyOfficeOpenUrl,
buildOnlyOfficeOpenPath,
buildOnlyOfficeOpenUrl,
closestAction: injectedClosestAction,
currentDocumentId,
currentRootUri,
currentWorkspaceSourcePayload,
@@ -23,6 +24,11 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
uploadedFileSize,
} = dependencies;
const closestAction = typeof injectedClosestAction === 'function' ? injectedClosestAction : function(target, selector) {
var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
return node && typeof node.closest === 'function' ? node.closest(selector) : null;
};
var activeEditorAttachmentLink = null;
var attachmentActionsHideTimer = 0;
@@ -5,6 +5,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
createPage,
cssEscape,
currentDocumentId,
currentRootUri,
currentSourceKind,
deleteSingleFileTreeAsset,
dispatchSidebarEvent,
@@ -23,6 +24,8 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
openEditorAttachmentEditTab,
openEditorAttachmentNewWindow,
refreshLocalFolderSidebarSnapshot,
removeFileTreeAssetRow,
revealFileTreeResource,
resolveWorkspaceId,
runtimeState,
selectedSidebarFileTreeSelection,
@@ -30,6 +33,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
validateFileTreeRename,
} = dependencies;
const sidebarFileTreeSelection = selectedSidebarFileTreeSelection;
var fileTreeOperationBatchCounter = 0;
function nextFileTreeOperationBatchId(action) {
fileTreeOperationBatchCounter += 1;
return 'filetree-' + String(action || 'operation') + '-' + Date.now() + '-' + fileTreeOperationBatchCounter;
}
function rowTitle(row) {
var title = row ? row.querySelector(':scope > .tree-link > .tree-link-title') : null;
@@ -59,8 +68,12 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var rowKind = row.getAttribute('data-row-kind') || '';
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
var assetId = row.getAttribute('data-asset-id') || '';
if (!documentId && !assetId) return false;
if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId) return false;
var rowId = row.getAttribute('data-row-id') || '';
var localFolderSource = currentSourceKind() === 'local_folder';
var localFolderDirectory = localFolderSource && (rowKind === 'folder' || rowKind === 'directory');
var commandTargetId = documentId || (localFolderSource ? (assetId || rowId) : '');
if (!commandTargetId && !assetId) return false;
if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId && !localFolderDirectory) return false;
var link = row.querySelector(':scope > .tree-link');
var title = rowTitle(row);
if (!(link instanceof HTMLElement)) return false;
@@ -68,7 +81,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var input = document.createElement('input');
input.type = 'text';
input.className = 'tree-rename-input';
input.setAttribute('data-rename-id', row.getAttribute('data-row-id') || '');
input.setAttribute('data-rename-id', rowId);
input.value = title;
input.style.minWidth = '0';
input.style.flex = '1 1 auto';
@@ -117,7 +130,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var commandTitle = isFileTreePageRow(row) ? normalizeFileTreePageRenameTitle(nextTitle) : nextTitle;
committing = true;
input.disabled = true;
var work = assetId
var work = assetId && !localFolderSource
? Promise.resolve().then(function(){
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-asset-id="' + cssEscape(assetId) + '"] .tree-link-title').forEach(function(titleNode) {
titleNode.textContent = commandTitle;
@@ -128,9 +141,11 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
: dispatchTreeCommand(row, {
action: 'rename',
workspaceId: resolveWorkspaceId(row),
documentId: documentId,
documentId: commandTargetId,
title: commandTitle
}).then(function(){ updateTitleEverywhere(documentId, commandTitle); });
}).then(function(){
if (!localFolderSource && documentId) updateTitleEverywhere(documentId, commandTitle);
});
void work.then(close).catch(function(error) {
committing = false;
input.disabled = false;
@@ -293,6 +308,51 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return detail.title || '';
}
function localRootPathFromRootUri(rootUri) {
var value = String(rootUri || '').trim();
if (!value) return '';
if (!/^file:\/\//i.test(value)) return value.charAt(0) === '/' ? value : '';
var pathPart = value.replace(/^file:\/\//i, '');
if (pathPart.indexOf('localhost/') === 0) pathPart = pathPart.slice('localhost'.length);
if (pathPart.charAt(0) !== '/') pathPart = '/' + pathPart;
try {
return decodeURIComponent(pathPart);
} catch (_) {
return pathPart;
}
}
function joinLocalAbsolutePath(rootUri, relativePath) {
var rootPath = localRootPathFromRootUri(rootUri);
var normalized = decodeLocalEncodedPath(relativePath).replace(/^\/+/, '');
if (!rootPath || !normalized) return rootPath || normalized;
return rootPath.replace(/\/+$/g, '') + '/' + normalized;
}
function fileTreeCopyLocalAbsolutePath(detail, trigger) {
var rootUri = typeof currentRootUri === 'function' ? currentRootUri() : '';
if (!rootUri && trigger && typeof trigger.closest === 'function') {
var row = trigger.closest('.tree-row[data-shell-mode="filetree"]');
if (row instanceof HTMLElement) rootUri = row.getAttribute('data-root-uri') || '';
}
if (!rootUri && document.body) rootUri = document.body.getAttribute('data-mnote-root-uri') || '';
var relativePath = String(detail && detail.localRelativePath || '').trim();
if (!relativePath && detail && detail.assetId) relativePath = localFilePathFromAssetId(detail.assetId);
if (!relativePath && detail && detail.documentId) {
relativePath = String(detail.documentId || '')
.replace(/^local-md:/, '')
.replace(/^local-dir:/, '');
}
if (!relativePath && detail && detail.rowId) {
relativePath = String(detail.rowId || '')
.replace(/^local:asset:/, '')
.replace(/^local:markdown:/, '')
.replace(/^local:folder:/, '')
.replace(/^local:node:/, '');
}
return joinLocalAbsolutePath(rootUri, relativePath);
}
function fileTreeMenuTargetParentId(detail, trigger) {
var rowKind = String(detail && detail.rowKind || '').trim();
var rowId = String(detail && detail.rowId || '').trim();
@@ -361,6 +421,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
var title = detail.title || '无标题';
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
if (detail.contextKind === 'filetree' && action === 'toggle-sidebar-folder-shortcut') {
dispatchSidebarEvent('tree.sidebarShortcut.toggleFolder', detail);
return;
}
if (detail.contextKind === 'filetree' && action === 'download') {
downloadSelectedFileTreeAssetRows(detail, trigger);
return;
@@ -408,7 +472,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return;
}
if (action === 'copy-id') {
void copyTreeContextValue(documentId || detail.assetId || detail.rowId || '', 'copy-id');
var copyIdValue = currentSourceKind() === 'local_folder'
? fileTreeCopyLocalAbsolutePath(detail, trigger)
: '';
void copyTreeContextValue(copyIdValue || documentId || detail.assetId || detail.rowId || '', 'copy-id');
return;
}
if (action === 'delete-trash' && detail.contextKind === 'filetree') {
@@ -776,6 +843,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ separator: true },
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
{ action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' },
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
@@ -915,22 +983,31 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]')
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]')
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]');
if (!(row instanceof HTMLElement)) return false;
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return false;
selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false });
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
});
row.setAttribute('data-active', 'true');
if (options && options.scrollIntoView !== false) {
try {
row.scrollIntoView({ block: 'nearest' });
} catch (_) {
row.scrollIntoView();
if (!(row instanceof HTMLElement)) {
var relativePath = options && options.relativePath
? String(options.relativePath || '').trim()
: id.indexOf('local-md:') === 0
? decodeLocalEncodedPath(id.slice('local-md:'.length))
: '';
var bundleParentPath = localMarkdownBundleParentPath(relativePath);
var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null;
if (bundleRow instanceof HTMLElement) {
return activateSidebarFileTreeRow(bundleRow, options);
}
if (relativePath && typeof revealFileTreeResource === 'function') {
void revealFileTreeResource({
rootUri: options && options.rootUri,
relativePath: relativePath,
rowId: options && options.rowId,
select: true,
focus: true,
scroll: options ? options.scrollIntoView !== false : true
});
return true;
}
return false;
}
return true;
return activateSidebarFileTreeRow(row, options);
}
function selectSidebarFileTreeRowById(rowId, options) {
@@ -1063,7 +1140,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
function decodeLocalEncodedPath(value) {
var runtimeFn = fileTreeRuntimeFunction('decodeLocalEncodedPath');
if (runtimeFn) return runtimeFn(value);
var path = String(value || '').trim().replace(/~2F/g, '/');
var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1');
if (!path) return '';
try {
return decodeURIComponent(path);
@@ -1072,6 +1149,62 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}
}
function normalizeFileTreeRelativePath(value) {
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
return normalized === '.' ? '' : normalized;
}
function parentRelativePathForFileTreePath(relativePath) {
var normalized = normalizeFileTreeRelativePath(relativePath);
if (!normalized) return '';
var index = normalized.lastIndexOf('/');
return index > 0 ? normalized.slice(0, index) : '';
}
function fileNameStem(value) {
var name = String(value || '').trim();
var dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(0, dot) : name;
}
function localMarkdownBundleParentPath(relativePath) {
var normalized = normalizeFileTreeRelativePath(relativePath);
if (!/\.md$/i.test(normalized)) return '';
var parent = parentRelativePathForFileTreePath(normalized);
if (!parent) return '';
var fileName = normalized.slice(normalized.lastIndexOf('/') + 1);
var parentName = parent.slice(parent.lastIndexOf('/') + 1);
return fileNameStem(fileName) === parentName ? parent : '';
}
function visibleFileTreeRowByRelativePath(relativePath) {
var normalized = normalizeFileTreeRelativePath(relativePath);
if (!normalized) return null;
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]');
if (!(row instanceof HTMLElement)) return null;
if (row.closest('.tree-children--collapsed')) return null;
return row;
}
function activateSidebarFileTreeRow(row, options) {
if (!(row instanceof HTMLElement)) return false;
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return false;
selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false });
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
});
row.setAttribute('data-active', 'true');
if (options && options.scrollIntoView !== false) {
try {
row.scrollIntoView({ block: 'nearest' });
} catch (_) {
row.scrollIntoView();
}
}
return true;
}
function fileTreeRowLocalRelativePath(row) {
var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath');
if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps());
@@ -1239,7 +1372,8 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var assetRows = [];
rows.forEach(function(row) {
var kind = fileTreeRowKind(row);
if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown') && fileTreeRowDocumentId(row)) {
var documentId = fileTreeRowDocumentId(row);
if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || documentId.indexOf('local-md:') === 0) && documentId) {
docRows.push(row);
return;
}
@@ -1338,69 +1472,101 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return children.length;
}
function fileTreeMoveTargetParentId(targetRow) {
if (!(targetRow instanceof HTMLElement)) return null;
return fileTreeMenuTargetParentId({
rowKind: fileTreeRowKind(targetRow),
rowId: targetRow.getAttribute('data-row-id') || '',
documentId: fileTreeRowDocumentId(targetRow)
}, targetRow) || null;
}
function fileTreeMoveSourceId(row) {
if (!(row instanceof HTMLElement)) return '';
return fileTreeRowDocumentId(row)
|| (currentSourceKind() === 'local_folder'
? (fileTreeRowAssetId(row) || row.getAttribute('data-row-id') || '')
: fileTreeRowAssetId(row));
}
async function moveSidebarFileTreeRows(rowIds, targetRow, options) {
var rows = fileTreeRowsByRowIds(rowIds || []);
if (rows.length === 0) return false;
var copy = Boolean(options && options.copy);
var targetParentId = fileTreeMoveTargetParentId(targetRow);
var workspaceId = resolveWorkspaceId(targetRow || document.body);
var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy);
if (!writable) return false;
var batchId = nextFileTreeOperationBatchId(copy ? 'copy' : 'move');
var plan = buildSidebarFileTreeDeletePlan(rows);
var failures = [];
if (copy) {
dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: rowIds || [], targetDocumentId: targetParentId });
recordFileTreeActionStatus('copy-requested', { documentId: targetParentId, batchId: batchId });
return true;
}
var movableRows = plan.docRows.concat(plan.folderRows);
if (currentSourceKind() === 'local_folder') {
movableRows = movableRows.concat(plan.fileAssetRows, plan.mindmapRows, plan.tableRows);
}
for (var i = 0; i < movableRows.length; i += 1) {
var sourceRow = movableRows[i];
var sourceId = fileTreeMoveSourceId(sourceRow);
if (!sourceId || sourceId === targetParentId) continue;
try {
await dispatchTreeCommand(targetRow || sourceRow, {
action: 'move',
batchId: batchId,
workspaceId: workspaceId,
documentId: sourceId,
parentId: targetParentId,
sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i
});
} catch (error) {
failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败'));
}
}
if (currentSourceKind() !== 'local_folder') {
var assetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean);
if (assetIds.length > 0) {
try {
await postSidebarFileTreeJson('/api/media/batch', {
action: 'move',
assetIds: assetIds,
targetDocumentId: targetParentId
});
} catch (error) {
failures.push(assetIds.join(',') + ': ' + (error && error.message ? error.message : '移动附件失败'));
}
}
}
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'move', failed: failures.length, count: movableRows.length } }));
if (failures.length > 0) {
recordFileTreeActionStatus('failed', { documentId: targetParentId, fallback: 'alert', batchId: batchId });
window.alert('部分对象移动失败:' + failures.join(''));
return false;
}
recordFileTreeActionStatus('applied', { documentId: targetParentId, batchId: batchId });
return true;
}
async function pasteSidebarFileTreeClipboard(trigger) {
if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false;
var targetRow = trigger instanceof HTMLElement ? trigger : null;
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
}
var targetDocumentId = fileTreeRowDocumentId(targetRow) || currentDocumentId();
if (!targetDocumentId) return false;
var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId();
var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
var rows = fileTreeRowsByRowIds(runtimeState.sidebarFileTreeClipboard.rowIds);
if (rows.length === 0) return false;
var writable = await ensureFileTreeWritableTarget('paste', targetRow, runtimeState.sidebarFileTreeClipboard.rowIds, action === 'copy');
if (!writable) return false;
recordFileTreeAction('paste', {
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
documentId: targetDocumentId,
sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds,
clipboardAction: runtimeState.sidebarFileTreeClipboard.action
});
var plan = buildSidebarFileTreeDeletePlan(rows);
var workspaceId = resolveWorkspaceId(targetRow || document.body);
var failures = [];
if (action === 'copy') {
dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: runtimeState.sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId });
recordFileTreeActionStatus('copy-requested', { documentId: targetDocumentId });
return true;
}
for (var i = 0; i < plan.docRows.length; i += 1) {
var docRow = plan.docRows[i];
var documentId = fileTreeRowDocumentId(docRow);
if (!documentId || documentId === targetDocumentId) continue;
try {
await dispatchTreeCommand(targetRow || docRow, {
action: 'move',
workspaceId: workspaceId,
documentId: documentId,
parentId: targetDocumentId,
sortOrder: fileTreeChildCount(targetDocumentId) + i
});
} catch (error) {
failures.push(documentId + ': ' + (error && error.message ? error.message : '移动页面失败'));
}
}
var assetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean);
if (assetIds.length > 0) {
try {
await postSidebarFileTreeJson('/api/media/batch', {
action: 'move',
assetIds: assetIds,
targetDocumentId: targetDocumentId
});
} catch (error) {
failures.push(assetIds.join(',') + ': ' + (error && error.message ? error.message : '移动附件失败'));
}
}
if (failures.length > 0) {
recordFileTreeActionStatus('failed', { documentId: targetDocumentId, fallback: 'alert' });
window.alert('部分对象移动失败:' + failures.join(''));
return false;
}
runtimeState.sidebarFileTreeClipboard = null;
recordFileTreeActionStatus('applied', { documentId: targetDocumentId });
return true;
var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' });
if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null;
return ok;
}
async function deleteSelectedSidebarFileTreeRows(trigger) {
@@ -1409,8 +1575,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
if (total === 0) return false;
if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false;
recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total });
recordFileTreeActionStatus('pending', { count: total });
var batchId = nextFileTreeOperationBatchId('bulk-delete');
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-batch-id', batchId);
recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total, batchId: batchId });
recordFileTreeActionStatus('pending', { count: total, batchId: batchId });
var failures = [];
for (var i = 0; i < plan.docRows.length; i += 1) {
var docRow = plan.docRows[i];
@@ -1418,6 +1586,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
try {
await dispatchTreeCommand(trigger || docRow, {
action: 'archive',
batchId: batchId,
workspaceId: resolveWorkspaceId(docRow),
documentId: documentId
});
@@ -1432,6 +1601,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
try {
await dispatchTreeCommand(trigger || folderRow, {
action: 'archive',
batchId: batchId,
workspaceId: resolveWorkspaceId(folderRow),
documentId: folderId
});
@@ -1446,6 +1616,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
for (var lf = 0; lf < fileAssetIds.length; lf += 1) {
await dispatchTreeCommand(trigger || plan.fileAssetRows[lf], {
action: 'archive',
batchId: batchId,
workspaceId: resolveWorkspaceId(plan.fileAssetRows[lf]),
documentId: fileAssetIds[lf]
});
@@ -1455,7 +1626,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}
fileAssetIds.forEach(removeFileTreeAssetRow);
} catch (error) {
failures = failures.concat(fileAssetIds);
failures = failures.concat(fileAssetIds.map(function(assetId) {
return assetId + ': ' + (error && error.message ? error.message : '删除附件失败');
}));
}
}
for (var m = 0; m < plan.mindmapRows.length; m += 1) {
@@ -1465,6 +1638,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
if (currentSourceKind() === 'local_folder') {
await dispatchTreeCommand(trigger || mindmapRow, {
action: 'archive',
batchId: batchId,
workspaceId: resolveWorkspaceId(mindmapRow),
documentId: mindmapId
});
@@ -1485,6 +1659,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
if (currentSourceKind() === 'local_folder') {
await dispatchTreeCommand(trigger || tableRow, {
action: 'archive',
batchId: batchId,
workspaceId: resolveWorkspaceId(tableRow),
documentId: tableId
});
@@ -1502,13 +1677,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
sidebarFileTreeSelection.focusedRowId = null;
syncSidebarFileTreeSelection();
if (failures.length > 0) {
recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert' });
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: failures.length, count: total } }));
recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert', batchId: batchId });
window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : ''));
return false;
}
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal' });
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: 0, count: total } }));
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal', batchId: batchId });
if (currentSourceKind() !== 'local_folder') void refreshLocalFolderSidebarSnapshot();
return true;
}
@@ -1565,6 +1742,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
postSidebarFileTreeJson,
fileTreeRowsByRowIds,
fileTreeChildCount,
moveSidebarFileTreeRows,
pasteSidebarFileTreeClipboard,
deleteSelectedSidebarFileTreeRows,
};
@@ -45,25 +45,15 @@ export function createSidebarPageSettingsRuntime(context) {
}
function readGlobalShowHeadingNumbers() {
try {
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : '';
return raw === 'true' || raw === '1';
} catch (_) {
return false;
}
return Boolean(currentPageOptions().showHeadingNumbers);
}
function writeGlobalShowHeadingNumbers(value) {
try {
if (window.localStorage) {
window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false');
}
} catch (_) {}
document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value)));
void persistPageOptionsPatch({ showHeadingNumbers: Boolean(value) });
}
function effectiveShowHeadingNumbers(options) {
return readGlobalShowHeadingNumbers();
return Boolean(options && options.showHeadingNumbers);
}
function pageOptionIsSupported(name) {
@@ -161,7 +151,7 @@ export function createSidebarPageSettingsRuntime(context) {
function applyPageOptionsToShell() {
var options = currentPageOptions();
var globalShowHeadingNumbers = readGlobalShowHeadingNumbers();
var globalShowHeadingNumbers = Boolean(options.showHeadingNumbers);
var showHeadingNumbers = effectiveShowHeadingNumbers(options);
var shell = document.querySelector('.document-shell');
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
@@ -623,21 +613,27 @@ export function createSidebarPageSettingsRuntime(context) {
applyPageOptionsToShell();
renderPageSettingsPopover();
try {
var response = await fetch('/api/documents/options', {
method: 'POST',
var response = await fetch('/api/ui/preferences', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
...currentWorkspaceSourcePayload(),
options: nextOptions,
commandName: 'page.layout.updateOptions'
updates: nextOptions
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_settings_save_failed_' + response.status);
}
var savedOptions = payload && payload.result && payload.result.pageOptions && typeof payload.result.pageOptions === 'object'
? Object.assign(defaultPageOptions(), payload.result.pageOptions)
: nextOptions;
pageUiState.pageOptions = Object.assign({}, savedOptions);
nextOptions = Object.assign({}, pageUiState.pageOptions);
applyPageOptionsToShell();
renderPageSettingsPopover();
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
if (script) {
try {
@@ -54,6 +54,8 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
if (treeView === 'filetree') targetUrl.searchParams.set('treeView', 'filetree');
var fileTreeScope = String(options && options.fileTreeScope || '').trim();
if (fileTreeScope) targetUrl.searchParams.set('fileTreeScope', fileTreeScope);
copyWorkspaceSourceParams(targetUrl);
var url = targetUrl.pathname + targetUrl.search;
if (mnoteNavigationInFlight === url) return;
@@ -18,11 +18,29 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
shortMindmapFileName,
syncSidebarFileTreeSelection,
} = dependencies;
var fileTreeLazyChildrenCache = new Map();
var fileTreeExpandedRelativePaths = new Set();
var fileTreeViewState = {
rootUri: '',
scope: '',
expandedParents: new Set(),
selectedRowIds: new Set(),
focusedRowId: '',
activeRowId: '',
scrollTop: 0,
rowsByParent: new Map(),
loadedParents: new Set(),
loadingParents: new Map(),
dirtyParents: new Set(),
staleParents: new Set(),
revisionByParent: new Map(),
requestGeneration: 0,
latestGenerationByParent: new Map()
};
var fileTreeState = fileTreeViewState;
var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents;
var fileTreeLazyCacheRootUri = '';
var fileTreeExpansionStorageRootUri = '';
var fileTreeExpansionRestoreTimer = 0;
var fileTreeCommandBatchRefreshParents = new Map();
var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1';
function updateTitleEverywhere(documentId, title) {
@@ -70,6 +88,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function isFileTreePageRow(row) {
if (!(row instanceof HTMLElement)) return false;
if (row.getAttribute('data-asset-id')) return false;
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
if (documentId.indexOf('local-md:') === 0) return true;
var rowKind = row.getAttribute('data-row-kind') || '';
return rowKind === 'document' || rowKind === 'doc' || rowKind === 'index' || rowKind === 'markdown';
}
@@ -295,6 +315,134 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return false;
}
function parentRelativePathForPath(relativePath) {
var normalized = String(relativePath || '').trim().replace(/^\/+|\/+$/g, '');
if (!normalized || normalized === '.') return '';
var index = normalized.lastIndexOf('/');
return index > 0 ? normalized.slice(0, index) : '';
}
function addCommandRefreshParent(parents, value) {
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
if (normalized === '.') normalized = '';
parents.add(normalized);
}
function addAffectedParentsFromCommandResult(parents, result) {
var affectedParents = Array.isArray(result && result.affectedParents)
? result.affectedParents
: Array.isArray(result && result.execution && result.execution.affectedParents)
? result.execution.affectedParents
: null;
if (!affectedParents) return false;
var before = parents.size;
affectedParents.forEach(function(parent) {
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
});
return parents.size > before;
}
function collectFileTreeRefreshParentsForCommand(result, body) {
var parents = new Set();
if (!addAffectedParentsFromCommandResult(parents, result)) {
document.documentElement.setAttribute('data-mnote-filetree-command-refresh-fallback', 'legacy-path-fields');
var values = [
result && (result.parentRelativePath || result.parent_relative_path),
result && result.execution && (result.execution.parentRelativePath || result.execution.parent_relative_path),
result && (result.relativePath || result.relative_path),
result && result.execution && (result.execution.relativePath || result.execution.relative_path),
result && (result.previousRelativePath || result.previous_relative_path),
result && result.execution && (result.execution.previousRelativePath || result.execution.previous_relative_path),
body && (body.parentRelativePath || body.parent_relative_path)
];
values.forEach(function(value) {
var normalized = String(value || '').trim();
if (!normalized) return;
if (normalized.indexOf('/') >= 0 || /\.[^/]+$/.test(normalized)) addCommandRefreshParent(parents, parentRelativePathForPath(normalized));
else addCommandRefreshParent(parents, normalized);
});
}
if (!parents.size) addCommandRefreshParent(parents, currentFileTreeScope());
return parents;
}
function fileTreeRefreshParentsForCommand(result, body) {
var parents = collectFileTreeRefreshParentsForCommand(result, body);
return Promise.all(Array.from(parents).map(function(parentRelativePath) {
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
setTreeLiveApplyError(error && error.message ? error.message : '文件树局部刷新失败');
return false;
});
}));
}
function queueFileTreeBatchRefresh(batchId, result, body) {
var normalizedBatchId = String(batchId || '').trim();
if (!normalizedBatchId) return false;
var batchParents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId);
if (!batchParents) {
batchParents = new Set();
fileTreeCommandBatchRefreshParents.set(normalizedBatchId, batchParents);
}
collectFileTreeRefreshParentsForCommand(result, body).forEach(function(parent) {
batchParents.add(parent);
});
document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-pending', normalizedBatchId);
return true;
}
function flushFileTreeBatchRefresh(batchId) {
var normalizedBatchId = String(batchId || '').trim();
if (!normalizedBatchId) return false;
var parents = fileTreeCommandBatchRefreshParents.get(normalizedBatchId);
fileTreeCommandBatchRefreshParents.delete(normalizedBatchId);
if (!parents || !parents.size) return false;
document.documentElement.setAttribute('data-mnote-filetree-batch-refresh-applied', normalizedBatchId);
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
setTreeLiveApplyError(error && error.message ? error.message : '文件树批量刷新失败');
return false;
});
}));
return true;
}
function applyLocalFolderWatchBatch(payload) {
var batch = payload && (payload.payload || payload);
var affectedParents = Array.isArray(batch && batch.affectedParents)
? batch.affectedParents
: Array.isArray(batch && batch.affected_parents)
? batch.affected_parents
: [];
if (!affectedParents.length) {
setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents');
return false;
}
var parents = new Set();
affectedParents.forEach(function(parent) {
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
});
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
setTreeLiveApplyError(error && error.message ? error.message : '文件树 watch batch 刷新失败');
return false;
});
})).then(function() {
markLocalFolderWatchApplied('watch_batch');
});
return true;
}
function refreshLocalFolderAfterCommand(action, result, body) {
if (!localCommandNeedsProjectionRefresh(action, result)) return;
if (body && body.batchId) {
queueFileTreeBatchRefresh(body.batchId, result, body);
return;
}
void fileTreeRefreshParentsForCommand(result, body);
}
function sortOrderFromDelta(data) {
var raw = data && (data.sortOrder ?? data.sort_order);
var value = Number(raw);
@@ -440,6 +588,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return String(item && (item.relativePath || item.rootRelativePath) || '').trim();
}
function normalizeFileTreeRelativePath(value) {
var normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '');
return normalized === '.' ? '' : normalized;
}
function fileTreeRowByRelativePath(relativePath) {
var normalized = normalizeFileTreeRelativePath(relativePath);
if (!normalized) return null;
return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(normalized) + '"]');
}
function groupRowsByParent(rows) {
var runtimeFn = fileTreeRuntimeFunction('groupRowsByParent');
if (runtimeFn) return runtimeFn(rows);
@@ -454,6 +613,53 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return grouped;
}
function fileTreeParentKey(rootUri, parentRelativePath) {
return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
}
function currentFileTreeParentKey(parentRelativePath) {
return fileTreeParentKey(currentRootUri(), parentRelativePath);
}
function projectionParentRelativePath(projection) {
var resolved = readProjection(projection);
return String(resolved && (resolved.parentRelativePath || resolved.parent_relative_path) || '').trim();
}
function isFileTreeRootProjectionParent(parentRelativePath) {
return normalizeFileTreeRelativePath(parentRelativePath) === normalizeFileTreeRelativePath(currentFileTreeScope());
}
function rememberFileTreeProjection(parentRelativePath, rows, projection) {
var key = currentFileTreeParentKey(parentRelativePath);
fileTreeState.rowsByParent.set(key, rows);
fileTreeState.loadedParents.add(key);
fileTreeState.dirtyParents.delete(key);
fileTreeState.staleParents.delete(key);
var resolved = readProjection(projection);
var watchRevision = resolved && (resolved.watchRevision || resolved.watch_revision);
if (watchRevision) fileTreeState.revisionByParent.set(key, watchRevision);
}
function cachedFileTreeRows(parentRelativePath) {
return fileTreeState.rowsByParent.get(currentFileTreeParentKey(parentRelativePath)) || [];
}
function beginFileTreeRequest(key) {
fileTreeState.requestGeneration += 1;
fileTreeState.latestGenerationByParent.set(key, fileTreeState.requestGeneration);
return fileTreeState.requestGeneration;
}
function isLatestFileTreeRequest(key, generation) {
return fileTreeState.latestGenerationByParent.get(key) === generation;
}
function markFileTreeParentStale(key) {
fileTreeState.staleParents.add(key);
fileTreeState.dirtyParents.add(key);
}
function pageTreeChevronSvg() {
return '<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>';
}
@@ -586,7 +792,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var objectIdentity = fileObjectIdentity(item);
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
var iconKind = iconKindOf(item);
var cachedChildren = relativePath ? (fileTreeLazyChildrenCache.get(relativePath) || []) : [];
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
var title = isFileTreeProjectionPageRow(rowKind, assetId)
? fileTreePageTitle(rawTitle)
: normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity);
@@ -615,9 +821,19 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function ensureFileTreeLazyCacheScope() {
var rootUri = currentRootUri();
if (rootUri === fileTreeLazyCacheRootUri) return;
var scope = currentFileTreeScope();
if (rootUri === fileTreeLazyCacheRootUri && scope === fileTreeState.scope) return;
fileTreeLazyCacheRootUri = rootUri;
fileTreeLazyChildrenCache.clear();
fileTreeState.rootUri = rootUri;
fileTreeState.scope = scope;
fileTreeState.rowsByParent.clear();
fileTreeState.loadedParents.clear();
fileTreeState.loadingParents.clear();
fileTreeState.dirtyParents.clear();
fileTreeState.staleParents.clear();
fileTreeState.revisionByParent.clear();
fileTreeState.latestGenerationByParent.clear();
fileTreeState.requestGeneration += 1;
fileTreeExpandedRelativePaths.clear();
loadStoredFileTreeExpansionState(rootUri);
}
@@ -683,15 +899,80 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
if (changed) persistFileTreeExpansionState();
}
function rememberFileTreeSelectionState() {
fileTreeViewState.selectedRowIds.clear();
fileTreeViewState.focusedRowId = '';
fileTreeViewState.activeRowId = '';
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
if (!(row instanceof HTMLElement)) return;
var rowId = String(row.getAttribute('data-row-id') || '').trim();
if (!rowId) return;
if (row.getAttribute('data-selected') === 'true') fileTreeViewState.selectedRowIds.add(rowId);
if (row.getAttribute('data-focused') === 'true') fileTreeViewState.focusedRowId = rowId;
if (row.getAttribute('data-active') === 'true') fileTreeViewState.activeRowId = rowId;
});
}
function reprojectFileTreeSelectionState() {
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
if (!(row instanceof HTMLElement)) return;
var rowId = String(row.getAttribute('data-row-id') || '').trim();
row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)));
row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId));
if (fileTreeViewState.activeRowId) {
row.setAttribute('data-active', String(rowId === fileTreeViewState.activeRowId));
}
});
}
function renderFileProjection(projection) {
var tree = document.getElementById('sidebar-file-tree-root');
if (!tree) return false;
ensureFileTreeLazyCacheScope();
rememberFileTreeExpansionState();
rememberFileTreeSelectionState();
var rows = projectionItems(projection);
var parentRelativePath = projectionParentRelativePath(projection) || currentFileTreeScope();
rememberFileTreeProjection(parentRelativePath, rows, projection);
if (!isFileTreeRootProjectionParent(parentRelativePath)) {
return patchFileTreeParentChildren(parentRelativePath, rows);
}
var activeId = currentDocumentId();
var activeRowId = currentFileTreeActiveRowId();
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
var template = document.createElement('template');
template.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
tree.replaceChildren(template.content.cloneNode(true));
reprojectFileTreeSelectionState();
scheduleRestorePersistedFileTreeExpansionState();
return true;
}
function patchFileTreeParentChildren(parentRelativePath, rows) {
var row = fileTreeRowByRelativePath(parentRelativePath);
if (!(row instanceof HTMLElement)) return true;
var node = row.closest('.tree-node');
if (!node) return true;
rememberFileTreeSelectionState();
var button = row.querySelector('[data-rust-action="toggle"]');
var wasExpanded = row.getAttribute('aria-expanded') === 'true';
var children = node.querySelector(':scope > .tree-children');
if (!children && !wasExpanded && row.getAttribute('data-filetree-children-loaded') !== 'true') {
return true;
}
if (!children) {
children = document.createElement('ul');
children.className = 'tree-children';
node.appendChild(children);
}
var template = document.createElement('template');
template.innerHTML = renderFileRows('', groupRowsByParent(rows), currentDocumentId(), currentFileTreeActiveRowId());
children.replaceChildren(template.content.cloneNode(true));
children.classList.toggle('tree-children--collapsed', !wasExpanded);
row.setAttribute('data-filetree-children-loaded', 'true');
row.removeAttribute('data-filetree-children-loading');
setTreeRowExpanded(row, button, wasExpanded);
syncSidebarFileTreeSelection();
reprojectFileTreeSelectionState();
return true;
}
@@ -702,6 +983,23 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return renderedPage || renderedFile;
}
function renderLiveSidebarSnapshot(payload) {
var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false;
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
var hasFileProjection = fileProjection && hasProjectionItems(fileProjection);
if (currentFileTreeScope()) {
if (!hasFileProjection) return renderedPage;
var projectionParent = projectionParentRelativePath(fileProjection);
if (isFileTreeRootProjectionParent(projectionParent)) {
return renderFileProjection(fileProjection) || renderedPage;
}
document.documentElement.setAttribute('data-mnote-filetree-scoped-live-snapshot-ignored', projectionParent || 'root');
return true;
}
var renderedFile = hasFileProjection ? renderFileProjection(fileProjection) : false;
return renderedPage || renderedFile;
}
function replaceSidebarTreeFromDocument(nextDocument, rootId) {
var runtimeFn = fileTreeRuntimeFunction('replaceSidebarTreeFromDocument');
if (runtimeFn) return runtimeFn(nextDocument, rootId);
@@ -739,10 +1037,61 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return true;
}
function currentFileTreeScope() {
var params = new URLSearchParams(window.location.search);
var fromUrl = String(params.get('fileTreeScope') || '').trim();
if (fromUrl) return fromUrl;
var root = document.getElementById('sidebar-file-tree-root');
return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : '';
}
async function fetchFileTreeProjection(parentRelativePath, options) {
options = options || {};
var rootUri = currentRootUri();
if (!rootUri) return null;
var url = new URL(options.childrenOnly ? '/api/tree/projections/file/children' : '/api/tree/projections/file', window.location.origin);
var workspaceId = currentWorkspaceId();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
if (parentRelativePath) url.searchParams.set('parentRelativePath', parentRelativePath);
var currentId = currentDocumentId();
if (!options.childrenOnly && currentId) url.searchParams.set('rootNodeId', currentId);
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_projection_failed_' + response.status);
return readProjection(payload && (payload.result || payload));
}
async function refreshFileTreeParent(parentRelativePath) {
ensureFileTreeLazyCacheScope();
var key = currentFileTreeParentKey(parentRelativePath);
fileTreeState.dirtyParents.add(key);
if (!isFileTreeRootProjectionParent(parentRelativePath)) {
var row = fileTreeRowByRelativePath(parentRelativePath);
if (row instanceof HTMLElement && row.getAttribute('aria-expanded') !== 'true') {
markFileTreeParentStale(key);
return true;
}
}
if (fileTreeState.loadingParents.has(key)) {
return fileTreeState.loadingParents.get(key).then(function(rows) {
fileTreeState.dirtyParents.delete(key);
return isFileTreeRootProjectionParent(parentRelativePath)
? renderFileProjection({ parentRelativePath: parentRelativePath, items: rows })
: patchFileTreeParentChildren(parentRelativePath, rows);
});
}
var projection = await fetchFileTreeProjection(parentRelativePath, { childrenOnly: false });
if (!projection) return false;
return renderFileProjection(projection);
}
async function refreshLocalFolderSidebarSnapshot() {
var workspaceId = currentWorkspaceId();
var rootUri = currentRootUri();
var currentId = currentDocumentId();
var fileTreeScope = currentFileTreeScope();
if (!workspaceId || !rootUri) return false;
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
@@ -750,22 +1099,24 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
sidebarUrl.searchParams.set('sourceKind', 'local_folder');
sidebarUrl.searchParams.set('rootUri', rootUri);
if (currentId) sidebarUrl.searchParams.set('rootNodeId', currentId);
var fileUrl = new URL('/api/tree/projections/file', window.location.origin);
fileUrl.searchParams.set('workspaceId', workspaceId);
fileUrl.searchParams.set('sourceKind', 'local_folder');
fileUrl.searchParams.set('rootUri', rootUri);
if (currentId) fileUrl.searchParams.set('rootNodeId', currentId);
var responses = await Promise.all([
var responses = await Promise.allSettled([
fetch(sidebarUrl.toString(), { headers: { accept: 'application/json' } }),
fetch(fileUrl.toString(), { headers: { accept: 'application/json' } })
refreshFileTreeParent(fileTreeScope)
]);
if (!responses[0].ok && !responses[1].ok) return false;
var sidebarResponse = responses[0].status === 'fulfilled' ? responses[0].value : null;
var renderedFile = responses[1].status === 'fulfilled' ? responses[1].value : false;
if ((!sidebarResponse || !sidebarResponse.ok) && !renderedFile) return false;
var sidebarPayload = responses[0].ok ? await responses[0].json().catch(function() { return null; }) : null;
var filePayload = responses[1].ok ? await responses[1].json().catch(function() { return null; }) : null;
var renderedPage = sidebarPayload ? renderSidebarSnapshot(sidebarPayload.result || sidebarPayload) : false;
var renderedFile = filePayload ? renderFileProjection(filePayload.result || filePayload) : false;
var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null;
var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null;
var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)
? renderPageProjection(resolvedSidebarPayload)
: false;
if (renderedFile && fileTreeScope) {
var fileRoot = document.getElementById('sidebar-file-tree-root');
if (fileRoot instanceof HTMLElement) fileRoot.setAttribute('data-mnote-filetree-scope', fileTreeScope);
document.documentElement.setAttribute('data-mnote-filetree-scope', fileTreeScope);
}
if (!renderedPage && !renderedFile) return false;
syncSidebarFileTreeSelection();
schedulePendingLocalFolderRestoreFocus();
@@ -783,22 +1134,35 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
scheduleRestorePersistedFileTreeExpansionState();
var revision = '';
var refreshTimer = 0;
var treeLiveEventsActive = function() {
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
return treeTransport === 'local-folder-events';
};
var scheduleRefresh = function() {
if (treeLiveEventsActive()) return;
if (refreshTimer) return;
refreshTimer = window.setTimeout(function() {
refreshTimer = 0;
if (treeLiveEventsActive()) return;
var fileTreeScope = currentFileTreeScope();
if (fileTreeScope) {
markFileTreeParentStale(currentFileTreeParentKey(fileTreeScope));
markLocalFolderWatchApplied('scope_stale');
document.documentElement.setAttribute('data-mnote-filetree-scope-watch-stale', fileTreeScope);
return;
}
void refreshLocalFolderSidebarSnapshot();
}, 180);
};
var poll = async function() {
if (document.hidden) return;
// If tree live SSE transport is active for local_folder, skip polling (fallback)
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
if (treeTransport === 'local-folder-events') return;
if (treeLiveEventsActive()) return;
var url = new URL('/api/tree/local-folder-watch', window.location.origin);
url.searchParams.set('rootUri', rootUri);
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
if (!response.ok) return;
if (treeLiveEventsActive()) return;
var payload = await response.json();
var nextRevision = payload && payload.result && typeof payload.result.revision === 'string'
? payload.result.revision
@@ -881,8 +1245,23 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
persistFileTreeExpansionState();
}
function markExistingFileTreeChildrenLoaded(row, button) {
if (!(row instanceof HTMLElement)) return false;
var node = row.closest('.tree-node');
if (!node) return false;
var children = node.querySelector(':scope > .tree-children');
if (!(children instanceof HTMLElement)) return false;
row.setAttribute('data-filetree-children-loaded', 'true');
children.classList.remove('tree-children--collapsed');
setTreeRowExpanded(row, button, true);
syncSidebarFileTreeSelection();
return true;
}
function renderCachedFileTreeChildren(row, button, relativePath) {
var cachedRows = fileTreeLazyChildrenCache.get(relativePath) || [];
var key = currentFileTreeParentKey(relativePath);
if (fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key)) return false;
var cachedRows = cachedFileTreeRows(relativePath);
if (!cachedRows.length) return false;
var node = row.closest('.tree-node');
if (!node) return false;
@@ -892,7 +1271,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
children.className = 'tree-children';
node.appendChild(children);
}
children.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId());
var template = document.createElement('template');
template.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId());
children.replaceChildren(template.content.cloneNode(true));
children.classList.remove('tree-children--collapsed');
row.setAttribute('data-filetree-children-loaded', 'true');
setTreeRowExpanded(row, button, true);
@@ -900,31 +1281,47 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return true;
}
async function getFileTreeChildren(parentRelativePath) {
ensureFileTreeLazyCacheScope();
var key = currentFileTreeParentKey(parentRelativePath);
if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key) && !fileTreeState.staleParents.has(key)) {
return fileTreeState.rowsByParent.get(key) || [];
}
if (fileTreeState.loadingParents.has(key)) {
return fileTreeState.loadingParents.get(key);
}
var generation = beginFileTreeRequest(key);
var promise = fetchFileTreeProjection(parentRelativePath, { childrenOnly: true }).then(function(projection) {
if (!isLatestFileTreeRequest(key, generation)) {
return fileTreeState.rowsByParent.get(key) || [];
}
var rows = projectionItems(projection);
rememberFileTreeProjection(parentRelativePath, rows, projection);
return rows;
}).finally(function() {
fileTreeState.loadingParents.delete(key);
});
fileTreeState.loadingParents.set(key, promise);
return promise;
}
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;
var key = currentFileTreeParentKey(relativePath);
var stale = fileTreeState.staleParents.has(key) || fileTreeState.dirtyParents.has(key);
if (!stale && markExistingFileTreeChildrenLoaded(row, button)) return true;
if (!stale && 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;
setTreeRowExpanded(row, button, true);
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);
var rows = await getFileTreeChildren(relativePath);
if (!rows.length) {
row.setAttribute('data-filetree-children-loaded', 'true');
setTreeRowExpanded(row, button, true);
@@ -939,6 +1336,53 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
}
}
function fileTreeParentChainForRelativePath(relativePath) {
var normalized = normalizeFileTreeRelativePath(relativePath);
if (!normalized) return [];
var parts = normalized.split('/').filter(Boolean);
parts.pop();
var chain = [];
for (var index = 0; index < parts.length; index += 1) {
chain.push(parts.slice(0, index + 1).join('/'));
}
return chain;
}
async function revealFileTreeResource(input) {
input = input || {};
var rootUri = String(input.rootUri || currentRootUri() || '').trim();
if (!rootUri || rootUri !== currentRootUri()) return false;
ensureFileTreeLazyCacheScope();
var relativePath = normalizeFileTreeRelativePath(input.relativePath || input.relative_path || '');
var rowId = String(input.rowId || input.row_id || '').trim();
var parentChain = fileTreeParentChainForRelativePath(relativePath);
for (var index = 0; index < parentChain.length; index += 1) {
var parentRelativePath = parentChain[index];
var parentRow = fileTreeRowByRelativePath(parentRelativePath);
if (!(parentRow instanceof HTMLElement)) continue;
var button = parentRow.querySelector('[data-rust-action="toggle"]');
await getFileTreeChildren(parentRelativePath);
renderCachedFileTreeChildren(parentRow, button, parentRelativePath);
setTreeRowExpanded(parentRow, button, true);
}
var targetRow = rowId
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]')
: null;
if (!(targetRow instanceof HTMLElement) && relativePath) {
targetRow = fileTreeRowByRelativePath(relativePath);
}
if (!(targetRow instanceof HTMLElement)) return false;
var targetRowId = String(targetRow.getAttribute('data-row-id') || rowId || '').trim();
if (targetRowId) {
fileTreeViewState.selectedRowIds = new Set([targetRowId]);
fileTreeViewState.focusedRowId = targetRowId;
fileTreeViewState.activeRowId = targetRowId;
}
reprojectFileTreeSelectionState();
try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {}
return true;
}
function toggleChildren(row, button) {
var li = row && row.parentElement;
var children = li ? li.querySelector(':scope > .tree-children') : null;
@@ -962,6 +1406,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var relativePath = localFileTreeRelativePathFromRow(row);
if (!relativePath || !fileTreeExpandedRelativePaths.has(relativePath)) return;
var button = row.querySelector('[data-rust-action="toggle"]');
if (markExistingFileTreeChildrenLoaded(row, button)) {
restored = true;
return;
}
if (row.getAttribute('data-filetree-children-loaded') === 'true') {
setTreeRowExpanded(row, button, true);
restored = true;
@@ -998,7 +1446,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var result = detail.result || {};
if (action === 'create') {
applyCreatedDocumentLocally(result, body.parentId || null, body.title || '新页面');
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
refreshLocalFolderAfterCommand(action, result, body);
return;
}
if (action === 'rename' && body.documentId && body.title) {
@@ -1012,7 +1460,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
} else {
updateTitleEverywhere(body.documentId, body.title);
}
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
refreshLocalFolderAfterCommand(action, result, body);
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'rename');
return;
}
@@ -1020,20 +1468,25 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) {
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move');
}
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
refreshLocalFolderAfterCommand(action, result, body);
return;
}
if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) {
if (applyRemoveDocumentDelta({ documentId: body.documentId })) {
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove');
}
if (localCommandNeedsProjectionRefresh(action, result)) void refreshLocalFolderSidebarSnapshot();
refreshLocalFolderAfterCommand(action, result, body);
}
});
window.addEventListener('tree:local-command-batch-complete', function(event) {
var detail = event.detail || {};
flushFileTreeBatchRefresh(detail.batchId || detail.batch_id || '');
});
window.addEventListener('tree:snapshot', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
if (renderSidebarSnapshot(payload)) {
if (renderLiveSidebarSnapshot(payload)) {
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot');
return;
}
@@ -1061,7 +1514,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题');
});
}
if (renderSidebarSnapshot(payload)) {
if (renderLiveSidebarSnapshot(payload)) {
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta');
return;
}
@@ -1073,7 +1526,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
window.addEventListener('tree:resync', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
if (renderSidebarSnapshot(payload)) {
if (renderLiveSidebarSnapshot(payload)) {
refreshEditorLocalAttachmentExistence();
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
return;
@@ -1081,6 +1534,18 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync');
setTreeLiveApplyError('tree_resync_missing_projection_payload');
});
window.addEventListener('tree:local-folder-watch-batch', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
applyLocalFolderWatchBatch(payload);
});
window.addEventListener('tree:error', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
var code = payload && (payload.code || payload.error || payload.message);
document.documentElement.setAttribute('data-mnote-tree-live-error-schema', String(payload && payload.schema || ''));
setTreeLiveApplyError(code || 'tree_live_error');
});
}
return {
@@ -1091,14 +1556,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
commandDocumentTitle,
deltaNeedsProjectionRefresh,
fileTreePageTitle,
flushFileTreeBatchRefresh,
installTreeLiveApplyEventListeners,
isFileTreePageRow,
applyLocalFolderWatchBatch,
localCommandNeedsProjectionRefresh,
normalizeFileTreePageRenameTitle,
objectIdentityAttr,
refreshLocalFolderSidebarSnapshot,
removeDocumentRowForMode,
renderSidebarSnapshot,
revealFileTreeResource,
restorePersistedFileTreeExpansionState,
setTreeLiveApplyError,
startLocalFolderSidebarWatch,
@@ -301,13 +301,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (currentSourceKind() !== 'local_folder') return false;
var workspaceId = resolveWorkspaceId(trigger || document.body);
var effectiveParentId = String(parentId || '').trim();
var title = window.prompt('新建文件夹', '新建文件夹');
if (!title || !title.trim()) return false;
var result = await dispatchTreeCommand(trigger || document.body, {
action: 'create_folder',
workspaceId: workspaceId,
parentId: effectiveParentId || null,
title: title.trim()
title: '新建文件夹'
});
document.documentElement.setAttribute('data-mnote-filetree-folder-created', 'true');
document.documentElement.setAttribute('data-mnote-filetree-folder-created-id', commandDocumentId(result, result.id || ''));
@@ -357,6 +355,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
copyWorkspaceSourceParams,
cssEscape,
currentDocumentId,
currentRootUri,
currentSourceKind,
dispatchTreeCommand,
normalizeSidebarTreeMode,
@@ -387,12 +386,385 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const startLocalFolderSidebarWatch = (...args) => sidebarTreeLiveApply.startLocalFolderSidebarWatch(...args);
const deltaNeedsProjectionRefresh = (...args) => sidebarTreeLiveApply.deltaNeedsProjectionRefresh(...args);
const toggleChildren = (...args) => sidebarTreeLiveApply.toggleChildren(...args);
const revealFileTreeResource = (...args) => sidebarTreeLiveApply.revealFileTreeResource(...args);
function dispatchSidebarEvent(name, detail) {
document.documentElement.setAttribute('data-mnote-last-tree-action', name);
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
}
function sidebarShortcutWorkspaceId() {
return currentWorkspaceId()
|| (document.getElementById('sidebar-file-tree-root') && document.getElementById('sidebar-file-tree-root').getAttribute('data-workspace-id') || '')
|| (document.getElementById('sidebar-tree-root') && document.getElementById('sidebar-tree-root').getAttribute('data-workspace-id') || '');
}
function sidebarShortcutSourceKind() {
return currentSourceKind() || 'workspace';
}
function currentFileTreeScope() {
var params = new URLSearchParams(window.location.search);
var fromUrl = String(params.get('fileTreeScope') || '').trim();
if (fromUrl) return fromUrl;
var root = document.getElementById('sidebar-file-tree-root');
return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : '';
}
function currentTopbarTitle() {
var title = document.querySelector('[data-page-title-current="true"]');
return title && title.textContent ? title.textContent.trim() : '无标题';
}
function fileTreeRowTitleForShortcut(row, fallback) {
var title = row && row.querySelector ? row.querySelector(':scope > .tree-link > .tree-link-title') : null;
return title && title.textContent ? title.textContent.trim() : (fallback || '文件夹');
}
function sidebarShortcutRows() {
return Array.from(document.querySelectorAll('.wolai-starred-section [data-mnote-shortcut-kind]'));
}
function shortcutMatches(row, kind, targetId, relativePath, documentId) {
if (!(row instanceof HTMLElement)) return false;
if ((row.getAttribute('data-mnote-shortcut-kind') || '') !== kind) return false;
if (documentId && (row.getAttribute('data-mnote-shortcut-document-id') || row.getAttribute('data-document-id') || '') === documentId) return true;
if (relativePath && (row.getAttribute('data-mnote-shortcut-relative-path') || '') === relativePath) return true;
return Boolean(targetId && (row.getAttribute('data-mnote-shortcut-target-id') || row.getAttribute('data-node-id') || '') === targetId);
}
async function listSidebarShortcuts(workspaceId) {
var url = new URL('/api/sidebar/shortcuts', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
var response = await fetch(url.toString(), {
method: 'GET',
credentials: 'include',
headers: { accept: 'application/json' },
cache: 'no-store'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'sidebar_shortcuts_list_failed_' + response.status);
return payload && Array.isArray(payload.shortcuts) ? payload.shortcuts : [];
}
function shortcutRecordMatches(shortcut, kind, targetId, relativePath, documentId) {
if (!shortcut || shortcut.kind !== kind) return false;
var shortcutMetadata = shortcut && shortcut.metadata && typeof shortcut.metadata === 'object' ? shortcut.metadata : {};
var shortcutRootUri = String(shortcut.rootUri || shortcut.root_uri || shortcutMetadata.rootUri || shortcutMetadata.root_uri || '').trim();
var currentShortcutRootUri = currentRootUri();
if (shortcutRootUri && currentShortcutRootUri && shortcutRootUri !== currentShortcutRootUri) return false;
if (documentId && String(shortcut.documentId || shortcut.document_id || '') === documentId) return true;
if (relativePath && String(shortcut.relativePath || shortcut.relative_path || '') === relativePath) return true;
return Boolean(targetId && String(shortcut.targetId || shortcut.target_id || '') === targetId);
}
async function findSidebarShortcut(payload) {
var workspaceId = payload.workspaceId || sidebarShortcutWorkspaceId();
if (!workspaceId) return null;
var shortcuts = await listSidebarShortcuts(workspaceId);
return shortcuts.find(function(shortcut) {
return shortcutRecordMatches(shortcut, payload.kind, payload.targetId, payload.relativePath, payload.documentId);
}) || null;
}
async function upsertSidebarShortcut(payload) {
var response = await fetch('/api/sidebar/shortcuts', {
method: 'POST',
credentials: 'include',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
var result = await response.json().catch(function() { return null; });
if (!response.ok) throw new Error(result && (result.error || result.message) || 'sidebar_shortcut_upsert_failed_' + response.status);
return result && result.shortcut ? result.shortcut : null;
}
async function deleteSidebarShortcut(shortcutId) {
if (!shortcutId) return false;
var response = await fetch('/api/sidebar/shortcuts/' + encodeURIComponent(shortcutId), {
method: 'DELETE',
credentials: 'include',
headers: { accept: 'application/json' }
});
var result = await response.json().catch(function() { return null; });
if (!response.ok) throw new Error(result && (result.error || result.message) || 'sidebar_shortcut_delete_failed_' + response.status);
return true;
}
function closeSidebarShortcutMenu() {
var existing = document.querySelector('[data-testid="mnote-sidebar-shortcut-menu"]');
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
document.querySelectorAll('[data-mnote-shortcut-action="menu"][aria-expanded="true"]').forEach(function(button) {
button.setAttribute('aria-expanded', 'false');
});
}
async function removeSidebarShortcutByRow(row) {
if (!(row instanceof HTMLElement)) return false;
var shortcutId = String(row.getAttribute('data-mnote-shortcut-id') || '').trim();
if (!shortcutId) return false;
row.setAttribute('data-mnote-shortcut-pending', 'true');
try {
await deleteSidebarShortcut(shortcutId);
row.remove();
closeSidebarShortcutMenu();
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'remove');
return true;
} finally {
if (row.isConnected) row.removeAttribute('data-mnote-shortcut-pending');
}
}
function openSidebarShortcutMenu(row, trigger) {
if (!(row instanceof HTMLElement)) return;
closeSidebarShortcutMenu();
if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'true');
var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : row.getBoundingClientRect();
var menu = document.createElement('div');
menu.className = 'mnote-tree-context-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('data-testid', 'mnote-sidebar-shortcut-menu');
menu.innerHTML = '<button type="button" class="mnote-tree-context-menu__item" role="menuitem" data-mnote-sidebar-shortcut-menu-action="open"><span class="material-symbols-outlined mnote-tree-context-menu__icon" data-icon="login" aria-hidden="true"></span><span class="mnote-tree-context-menu__label">在右侧边栏打开</span></button><div class="mnote-tree-context-menu__separator" role="separator"></div><button type="button" class="mnote-tree-context-menu__item" role="menuitem" data-mnote-sidebar-shortcut-menu-action="copy-link"><span class="material-symbols-outlined mnote-tree-context-menu__icon" data-icon="link" aria-hidden="true"></span><span class="mnote-tree-context-menu__label">复制访问链接</span></button><button type="button" class="mnote-tree-context-menu__item mnote-tree-context-menu__item--danger" role="menuitem" data-mnote-sidebar-shortcut-menu-action="remove"><span class="material-symbols-outlined mnote-tree-context-menu__icon" data-icon="star_off" aria-hidden="true"></span><span class="mnote-tree-context-menu__label">取消星标</span></button>';
menu.__mnoteShortcutRow = row;
document.body.appendChild(menu);
var width = menu.offsetWidth || 220;
var left = Math.min(Math.max(8, rect.right - width), Math.max(8, window.innerWidth - width - 8));
var top = Math.min(Math.max(8, rect.bottom + 4), Math.max(8, window.innerHeight - (menu.offsetHeight || 120) - 8));
menu.style.left = left + 'px';
menu.style.top = top + 'px';
}
function removeSidebarShortcutRow(shortcut) {
sidebarShortcutRows().forEach(function(row) {
if (shortcutMatches(
row,
shortcut.kind,
String(shortcut.targetId || shortcut.target_id || ''),
String(shortcut.relativePath || shortcut.relative_path || ''),
String(shortcut.documentId || shortcut.document_id || '')
)) {
row.remove();
}
});
}
function renderSidebarShortcutRow(shortcut) {
if (!shortcut) return;
var section = document.querySelector('.wolai-starred-section');
if (!(section instanceof HTMLElement)) return;
removeSidebarShortcutRow(shortcut);
var kind = String(shortcut.kind || '').trim();
var shortcutId = String(shortcut.id || shortcut.targetId || shortcut.target_id || '').trim();
var targetId = String(shortcut.targetId || shortcut.target_id || '').trim();
var relativePath = String(shortcut.relativePath || shortcut.relative_path || '').trim();
var documentId = String(shortcut.documentId || shortcut.document_id || '').trim();
var sourceKind = String(shortcut.sourceKind || shortcut.source_kind || '').trim();
var metadata = shortcut.metadata && typeof shortcut.metadata === 'object' ? shortcut.metadata : {};
var rootUri = String(shortcut.rootUri || shortcut.root_uri || metadata.rootUri || metadata.root_uri || '').trim();
var workspaceId = String(shortcut.workspaceId || shortcut.workspace_id || sidebarShortcutWorkspaceId() || '').trim();
var title = String(shortcut.title || (kind === 'folder' ? '文件夹' : '无标题')).trim();
var href = '';
if (documentId) {
var targetUrl = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin);
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
if (sourceKind) targetUrl.searchParams.set('sourceKind', sourceKind);
if (rootUri) targetUrl.searchParams.set('rootUri', rootUri);
href = targetUrl.pathname + targetUrl.search;
}
var row = document.createElement(href ? 'a' : 'div');
row.className = 'wolai-page-row';
if (href) row.setAttribute('href', href);
else {
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
}
row.setAttribute('data-testid', 'wolai-sidebar-row');
row.setAttribute('data-node-id', shortcutId || targetId);
row.setAttribute('data-document-id', documentId || shortcutId || targetId);
if (shortcutId) row.setAttribute('data-mnote-shortcut-id', shortcutId);
if (workspaceId) row.setAttribute('data-workspace-id', workspaceId);
row.setAttribute('data-mnote-shortcut-kind', kind);
if (sourceKind) row.setAttribute('data-mnote-shortcut-source-kind', sourceKind);
row.setAttribute('data-mnote-shortcut-target-id', targetId);
if (relativePath) row.setAttribute('data-mnote-shortcut-relative-path', relativePath);
if (rootUri) row.setAttribute('data-mnote-shortcut-root-uri', rootUri);
if (documentId) row.setAttribute('data-mnote-shortcut-document-id', documentId);
row.setAttribute('data-depth', '0');
row.setAttribute('data-active', String(documentId && documentId === currentDocumentId()));
row.innerHTML = '<span class="wolai-row-caret" aria-hidden="true"></span><span class="wolai-row-icon"><span class="material-symbols-outlined wolai-row-symbol" data-icon="' + (kind === 'folder' ? 'folder_open' : 'home') + '" aria-hidden="true"></span></span><span class="wolai-row-title">' + escapeHtml(title) + '</span><button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>';
section.appendChild(row);
}
async function toggleSidebarShortcut(payload) {
var workspaceId = payload.workspaceId || sidebarShortcutWorkspaceId();
if (!workspaceId) return false;
var normalized = Object.assign({}, payload, { workspaceId: workspaceId });
var existing = await findSidebarShortcut(normalized);
if (existing && existing.id) {
await deleteSidebarShortcut(existing.id);
removeSidebarShortcutRow(existing);
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'remove');
return true;
}
var shortcut = await upsertSidebarShortcut(normalized);
renderSidebarShortcutRow(shortcut);
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-last-action', 'upsert');
return true;
}
function currentPageShortcutPayload() {
var documentId = currentDocumentId();
var workspaceId = sidebarShortcutWorkspaceId();
if (!documentId || !workspaceId) return null;
return {
workspaceId: workspaceId,
kind: 'page',
sourceKind: sidebarShortcutSourceKind(),
targetId: documentId,
documentId: documentId,
title: currentTopbarTitle(),
icon: 'star',
rootUri: currentRootUri(),
metadataJson: JSON.stringify({ rootUri: currentRootUri() })
};
}
function folderShortcutPayload(detail, trigger) {
detail = detail || {};
var row = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
var rowKind = String(detail.rowKind || (row && row.getAttribute('data-row-kind')) || '').trim();
if (rowKind !== 'folder' && rowKind !== 'directory') return null;
var relativePath = String(detail.localRelativePath || (row && row.getAttribute('data-local-relative-path')) || '').trim();
if (!relativePath) return null;
var workspaceId = String(detail.workspaceId || sidebarShortcutWorkspaceId() || '').trim();
if (!workspaceId) return null;
var rowId = String(detail.rowId || (row && row.getAttribute('data-row-id')) || '').trim();
return {
workspaceId: workspaceId,
kind: 'folder',
sourceKind: 'local_folder',
targetId: rowId || ('folder:' + relativePath),
relativePath: relativePath,
title: detail.title || fileTreeRowTitleForShortcut(row, relativePath),
icon: 'folder_open',
rootUri: currentRootUri(),
metadataJson: JSON.stringify({ rootUri: currentRootUri() })
};
}
async function toggleCurrentPageSidebarShortcut(trigger) {
var payload = currentPageShortcutPayload();
if (!payload) return;
if (trigger instanceof HTMLElement) trigger.setAttribute('data-mnote-shortcut-pending', 'true');
try {
await toggleSidebarShortcut(payload);
} finally {
if (trigger instanceof HTMLElement) trigger.removeAttribute('data-mnote-shortcut-pending');
}
}
async function toggleFolderSidebarShortcut(detail, trigger) {
var payload = folderShortcutPayload(detail, trigger);
if (!payload) return false;
await toggleSidebarShortcut(payload);
return true;
}
function ensureStarredFolderFileTreeHost(workspaceId) {
var root = document.getElementById('sidebar-file-tree-root');
if (root instanceof HTMLElement) return root;
var panel = document.getElementById('wolai-sidebar-file-tree-panel');
if (!(panel instanceof HTMLElement)) return null;
var section = document.createElement('div');
section.className = 'sidebar-tree-section sidebar-file-tree-section';
root = document.createElement('div');
root.id = 'sidebar-file-tree-root';
root.className = 'sidebar-tree';
root.setAttribute('data-tree-shell-mode', 'filetree');
if (workspaceId) root.setAttribute('data-workspace-id', workspaceId);
section.appendChild(root);
panel.appendChild(section);
return root;
}
function persistStarredFolderScope(workspaceId, rootUri, relativePath) {
var targetUrl = new URL(window.location.href);
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
targetUrl.searchParams.set('sourceKind', 'local_folder');
targetUrl.searchParams.set('rootUri', rootUri);
targetUrl.searchParams.set('treeView', 'filetree');
targetUrl.searchParams.set('fileTreeScope', relativePath);
window.history.replaceState(window.history.state, '', targetUrl.pathname + targetUrl.search + targetUrl.hash);
if (document.body instanceof HTMLElement) {
document.body.setAttribute('data-mnote-source-kind', 'local_folder');
document.body.setAttribute('data-mnote-root-uri', rootUri);
}
}
function readShortcutRootUri(row) {
if (!(row instanceof HTMLElement)) return '';
return String(row.getAttribute('data-mnote-shortcut-root-uri') || '').trim();
}
async function openStarredFolderShortcut(row) {
if (!(row instanceof HTMLElement)) return false;
var relativePath = String(row.getAttribute('data-mnote-shortcut-relative-path') || '').trim();
var workspaceId = String(row.getAttribute('data-workspace-id') || '').trim() || sidebarShortcutWorkspaceId();
var rootUri = readShortcutRootUri(row);
if (!relativePath || !rootUri || !workspaceId) {
document.documentElement.setAttribute('data-mnote-sidebar-shortcut-open-error', !rootUri ? 'missing_root_uri' : 'missing_target');
return false;
}
document.documentElement.removeAttribute('data-mnote-sidebar-shortcut-open-error');
var tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"]');
if (tab instanceof HTMLElement) switchSidebarTreeTab(tab);
var root = ensureStarredFolderFileTreeHost(workspaceId);
if (root instanceof HTMLElement) {
root.setAttribute('data-workspace-id', workspaceId);
root.setAttribute('data-mnote-filetree-scope', relativePath);
root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath);
}
persistStarredFolderScope(workspaceId, rootUri, relativePath);
document.documentElement.setAttribute('data-mnote-filetree-scope', relativePath);
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
sidebarUrl.searchParams.set('workspaceId', workspaceId);
sidebarUrl.searchParams.set('sourceKind', 'local_folder');
sidebarUrl.searchParams.set('rootUri', rootUri);
var url = new URL('/api/tree/projections/file', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('parentRelativePath', relativePath);
var sidebarResponse = await fetch(sidebarUrl.toString(), {
method: 'GET',
credentials: 'include',
headers: { accept: 'application/json' },
cache: 'no-store'
});
var sidebarPayload = await sidebarResponse.json().catch(function() { return null; });
if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status);
var response = await fetch(url.toString(), {
method: 'GET',
credentials: 'include',
headers: { accept: 'application/json' },
cache: 'no-store'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'scoped_filetree_failed_' + response.status);
var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {};
var fileProjection = payload && (payload.result || payload);
var rendered = renderSidebarSnapshot(Object.assign({}, sidebarProjection, {
dataset: Object.assign({}, sidebarProjection.dataset || {}, { kernel_file_tree_projection: fileProjection })
}));
root = document.getElementById('sidebar-file-tree-root');
if (root instanceof HTMLElement) {
root.setAttribute('data-workspace-id', workspaceId);
root.setAttribute('data-mnote-filetree-scope', relativePath);
root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath);
}
return rendered;
}
const sidebarFileTreeOpen = createSidebarFileTreeOpenRuntime({
copyWorkspaceSourceParams,
currentDocumentId,
@@ -736,14 +1108,19 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
async function healLegacyOfficeAttachmentParagraphs() {
var editor = document.querySelector('.editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) return;
var index = await fetchLegacyOfficeAttachmentIndex();
var paragraphs = Array.from(editor.querySelectorAll('p'));
paragraphs.forEach(function(paragraph) {
var candidates = paragraphs.filter(function(paragraph) {
if (!(paragraph instanceof HTMLParagraphElement)) return;
if (paragraph.querySelector('a, img, video, audio, table, iframe, canvas')) return;
if (paragraph.childNodes.length !== 1 || paragraph.firstChild?.nodeType !== Node.TEXT_NODE) return;
var fileName = String(paragraph.textContent || '').trim();
if (!fileName) return;
return Boolean(inferOnlyOfficeFileType(fileName, ''));
});
if (!candidates.length) return;
var index = await fetchLegacyOfficeAttachmentIndex();
candidates.forEach(function(paragraph) {
var fileName = String(paragraph.textContent || '').trim();
var detail = index[fileName];
if (!detail) return;
var link = document.createElement('a');
@@ -1545,6 +1922,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
createPage,
cssEscape,
currentDocumentId,
currentRootUri,
currentSourceKind,
deleteSingleFileTreeAsset,
dispatchSidebarEvent,
@@ -1563,6 +1941,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args),
refreshLocalFolderSidebarSnapshot,
removeFileTreeAssetRow,
revealFileTreeResource,
resolveWorkspaceId,
runtimeState: sidebarFileTreeCommandState,
selectedSidebarFileTreeSelection: sidebarFileTreeSelection,
@@ -1620,6 +2000,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const postSidebarFileTreeJson = (...args) => sidebarFileTreeCommand.postSidebarFileTreeJson(...args);
const fileTreeRowsByRowIds = (...args) => sidebarFileTreeCommand.fileTreeRowsByRowIds(...args);
const fileTreeChildCount = (...args) => sidebarFileTreeCommand.fileTreeChildCount(...args);
const moveSidebarFileTreeRows = (...args) => sidebarFileTreeCommand.moveSidebarFileTreeRows(...args);
const pasteSidebarFileTreeClipboard = (...args) => sidebarFileTreeCommand.pasteSidebarFileTreeClipboard(...args);
const deleteSelectedSidebarFileTreeRows = (...args) => sidebarFileTreeCommand.deleteSelectedSidebarFileTreeRows(...args);
@@ -1891,6 +2272,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
buildLocalOnlyOfficeOpenUrl,
buildOnlyOfficeOpenPath,
buildOnlyOfficeOpenUrl,
closestAction,
currentDocumentId,
currentRootUri,
currentWorkspaceSourcePayload,
@@ -2274,6 +2656,58 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var pageShortcutTrigger = closestAction(e.target, '[data-mnote-action="toggle-sidebar-shortcut"]');
if (pageShortcutTrigger) {
e.preventDefault();
void toggleCurrentPageSidebarShortcut(pageShortcutTrigger);
return;
}
var shortcutMenu = closestAction(e.target, '[data-testid="mnote-sidebar-shortcut-menu"]');
var shortcutMenuAction = closestAction(e.target, '[data-mnote-sidebar-shortcut-menu-action]');
if (shortcutMenuAction) {
e.preventDefault();
var action = String(shortcutMenuAction.getAttribute('data-mnote-sidebar-shortcut-menu-action') || '').trim();
var shortcutRowFromMenu = shortcutMenu && shortcutMenu.__mnoteShortcutRow instanceof HTMLElement
? shortcutMenu.__mnoteShortcutRow
: null;
if (action === 'remove') {
void removeSidebarShortcutByRow(shortcutRowFromMenu);
return;
}
if (action === 'open') {
closeSidebarShortcutMenu();
if (shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('data-mnote-shortcut-kind') === 'folder') {
void openStarredFolderShortcut(shortcutRowFromMenu);
} else if (shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('href')) {
window.location.assign(shortcutRowFromMenu.getAttribute('href'));
}
return;
}
if (action === 'copy-link') {
var href = shortcutRowFromMenu && shortcutRowFromMenu.getAttribute('href') || window.location.href;
if (navigator.clipboard && href) void navigator.clipboard.writeText(new URL(href, window.location.origin).toString());
closeSidebarShortcutMenu();
return;
}
}
if (!shortcutMenu) closeSidebarShortcutMenu();
var shortcutMenuTrigger = closestAction(e.target, '.wolai-starred-section [data-mnote-shortcut-action="menu"]');
if (shortcutMenuTrigger) {
e.preventDefault();
var shortcutRow = shortcutMenuTrigger.closest('[data-mnote-shortcut-id]');
openSidebarShortcutMenu(shortcutRow, shortcutMenuTrigger);
return;
}
var folderShortcutRow = closestAction(e.target, '.wolai-starred-section [data-mnote-shortcut-kind="folder"]');
if (folderShortcutRow) {
e.preventDefault();
void openStarredFolderShortcut(folderShortcutRow);
return;
}
var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]');
if (localFolderTrigger) {
e.preventDefault();
@@ -2295,6 +2729,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var createFolderTrigger = closestAction(e.target, '[data-mnote-action="create-folder"]');
if (createFolderTrigger) {
e.preventDefault();
if (currentSourceKind() !== 'local_folder') return;
var scope = currentFileTreeScope();
var parentId = scope ? 'local:folder:' + scope : null;
void createFileTreeFolder(createFolderTrigger, parentId);
return;
}
var fileTree = document.getElementById('sidebar-file-tree-root');
if (fileTree && fileTree.contains(e.target)) {
var fileBtn = closestAction(e.target, '[data-rust-action]');
@@ -2345,7 +2789,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree', fileTreeScope: currentFileTreeScope() });
} else if (assetId) {
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow), openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab' });
}
@@ -2355,6 +2799,29 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (sidebarPageTree.handlePageTreeClick(e, { closestAction: closestAction })) return;
});
window.addEventListener('tree.sidebarShortcut.toggleFolder', function(event) {
var detail = event.detail || {};
var row = detail.rowId
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.rowId) + '"]')
: null;
void toggleFolderSidebarShortcut(detail, row);
});
window.addEventListener('tree.filetree.internal-drop', function(event) {
var detail = event.detail || {};
var rowIds = Array.isArray(detail.rowIds) ? detail.rowIds : [];
if (!rowIds.length) return;
var targetRow = detail.targetRowId
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]')
: null;
recordFileTreeAction('internal-drop', {
rowId: detail.targetRowId || '',
sourceRowIds: rowIds,
copy: Boolean(detail.copy)
});
void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) });
});
document.addEventListener('contextmenu', function(event) {
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
if (editorAttachmentLink instanceof HTMLAnchorElement) {
@@ -156,7 +156,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
function copyWorkspaceSourceParams(targetUrl) {
var params = new URLSearchParams(window.location.search);
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
['sourceKind', 'rootUri', 'fileTreeScope', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
var value = (params.get(name) || '').trim();
if (value) targetUrl.searchParams.set(name, value);
});
@@ -87,6 +87,18 @@
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('watch_batch', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:local-folder-watch-batch', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('tree_error', function(event){
var payload = JSON.parse(event.data || '{}');
dispatchTreeEvent('tree:error', { payload: payload, bootstrap: bootstrap });
});
source.addEventListener('block.delta', function(event){
var payload = JSON.parse(event.data || '{}');
dispatchTreeEvent('tree:block-delta', { payload: payload, bootstrap: bootstrap });
@@ -174,6 +174,57 @@ impl BufferStore {
}
}
/// 本地文件 rename/move 后重绑打开的 Markdown buffer。
pub fn rekey_local_folder_markdown(
&self,
workspace_id: &str,
root_uri: &str,
previous_relative_path: &str,
previous_document_id: &str,
next_relative_path: &str,
next_document_id: &str,
) -> Option<DocumentBuffer> {
let previous_path = build_local_folder_workspace_path(
workspace_id,
root_uri,
previous_relative_path,
previous_document_id,
);
let next_path = build_local_folder_workspace_path(
workspace_id,
root_uri,
next_relative_path,
next_document_id,
);
let previous_key = BufferKey::from_workspace_path(&previous_path);
let next_key = BufferKey::from_workspace_path(&next_path);
let mut inner = self.inner.write().expect("BufferStore lock");
let mut buffer = inner.buffers.remove(&previous_key)?;
buffer.workspace_path = next_path;
inner.buffers.insert(next_key, buffer.clone());
Some(buffer)
}
/// 本地文件 delete/archive/purge 后标记打开的 Markdown buffer 已删除。
pub fn mark_local_folder_markdown_deleted(
&self,
workspace_id: &str,
root_uri: &str,
relative_path: &str,
document_id: &str,
) -> Option<DocumentBuffer> {
let path =
build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id);
let key = BufferKey::from_workspace_path(&path);
let mut inner = self.inner.write().expect("BufferStore lock");
if let Some(buf) = inner.buffers.get_mut(&key) {
buf.mark_deleted();
Some(buf.clone())
} else {
None
}
}
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
pub fn init_buffer(
&self,
@@ -449,4 +500,70 @@ mod tests {
assert_eq!(buf.file_version.as_deref(), Some("v2"));
assert_eq!(buf.base_content_hash.as_deref(), Some("sha256:saved"));
}
#[test]
fn document_buffer_rekeys_after_local_file_operation_rename() {
let store = BufferStore::new();
let root_uri = "file:///tmp/mnote-buffer-rekey";
let old_path = build_local_folder_workspace_path(
"local:test",
root_uri,
"docs/Old.md",
"local-md:docs~2FOld.md",
);
store.init_buffer(&old_path, Some("v1".into()), Some("sha256:old".into()));
let rekeyed = store
.rekey_local_folder_markdown(
"local:test",
root_uri,
"docs/Old.md",
"local-md:docs~2FOld.md",
"docs/New.md",
"local-md:docs~2FNew.md",
)
.expect("buffer should be rekeyed");
assert_eq!(rekeyed.workspace_path.relative_path, "docs/New.md");
assert_eq!(
rekeyed
.workspace_path
.object_identity
.document_id
.as_deref(),
Some("local-md:docs~2FNew.md")
);
assert!(store.get_by_path(&old_path).is_none());
let next_path = build_local_folder_workspace_path(
"local:test",
root_uri,
"docs/New.md",
"local-md:docs~2FNew.md",
);
assert!(store.get_by_path(&next_path).is_some());
}
#[test]
fn document_buffer_marks_deleted_after_local_file_operation_archive() {
let store = BufferStore::new();
let root_uri = "file:///tmp/mnote-buffer-delete";
let path = build_local_folder_workspace_path(
"local:test",
root_uri,
"docs/Delete.md",
"local-md:docs~2FDelete.md",
);
store.get_or_create(&path);
let deleted = store
.mark_local_folder_markdown_deleted(
"local:test",
root_uri,
"docs/Delete.md",
"local-md:docs~2FDelete.md",
)
.expect("buffer should be marked deleted");
assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted);
}
}
+51 -2
View File
@@ -139,7 +139,12 @@ pub async fn update_options(
)
.with_context(context)
})?;
let (wired_options, ignored_options, warnings) = filter_wired_page_options(options);
let (wired_options, ignored_options, warnings) =
if input.effective_source_kind().as_deref() == Some("local_folder") {
filter_local_ui_preference_options(options)
} else {
filter_wired_page_options(options)
};
page_command(
state,
context,
@@ -235,7 +240,16 @@ async fn page_command(
)
.with_context(context)
})?;
crate::routes::update_local_page_options(&root_uri, &document_id, &options)?
crate::routes::ui_preferences::update_page_preferences_from_value(
state,
context,
context.auth.actor_id.trim(),
workspace_id.as_deref().unwrap_or_default(),
"local_folder",
&root_uri,
&document_id,
&options,
)?
}
_ => {
return Err(WebError::bad_request_code(
@@ -443,6 +457,41 @@ fn filter_wired_page_options(options: Value) -> (Value, Vec<String>, Vec<Value>)
(Value::Object(out), ignored, warnings)
}
fn filter_local_ui_preference_options(options: Value) -> (Value, Vec<String>, Vec<Value>) {
let allowed = [
"wideLayout",
"smallText",
"layoutDensity",
"pageFont",
"showHeadingNumbers",
"showToc",
"showStructure",
"showWordCount",
"collapseBacklinks",
"hideChildPages",
"showBlockRefCount",
"hideTitleHeader",
];
let mut out = serde_json::Map::new();
let mut ignored = Vec::new();
let mut warnings = Vec::new();
if let Value::Object(map) = options {
for (key, value) in map {
if allowed.contains(&key.as_str()) {
out.insert(key, value);
} else {
warnings.push(json!({
"code": "page_option_not_wired",
"field": key.clone(),
"message": "页面设置字段尚未接入 SQLite UI 偏好,已忽略"
}));
ignored.push(key);
}
}
}
(Value::Object(out), ignored, warnings)
}
fn summarize_blocks(content: &Value) -> Vec<Value> {
let mut out = Vec::new();
collect_blocks(content, &mut out);
+24 -19
View File
@@ -6,8 +6,7 @@ use crate::routes::command_support::{
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access, update_local_markdown_title, update_local_page_options,
write_local_markdown_page_body,
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
};
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
@@ -66,7 +65,15 @@ fn find_document_buffer_state(
relative_path,
document_id,
);
return buffer_store.get_by_path(&ws_path);
if let Some(buffer) = buffer_store.get_by_path(&ws_path) {
return Some(buffer);
}
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.root_uri == root_uri
&& buf.workspace_path.relative_path == relative_path
&& buf.workspace_path.object_identity.document_id.as_deref()
== Some(document_id)
});
}
return buffer_store.all_buffers().into_iter().find(|buf| {
buf.workspace_path.root_uri == root_uri
@@ -1022,7 +1029,16 @@ pub async fn options(
})?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let result = update_local_page_options(root_uri, document_id, &body.options)?;
let result = crate::routes::ui_preferences::update_page_preferences_from_value(
&state,
&context,
context.auth.actor_id.trim(),
body.workspace_id.as_deref().unwrap_or_default(),
body.source_kind.as_deref().unwrap_or("local_folder"),
root_uri,
document_id,
&body.options,
)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
@@ -1486,7 +1502,7 @@ mod tests {
}
#[tokio::test]
async fn local_folder_documents_save_title_and_options_write_to_disk() {
async fn local_folder_documents_save_title_and_options_store_ui_preferences_in_sqlite() {
let root = std::env::temp_dir().join(format!(
"mnote-local-documents-write-{}",
std::process::id()
@@ -1615,20 +1631,9 @@ mod tests {
assert!(markdown.contains("## Saved Heading"));
assert!(markdown.contains("Saved body"));
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
.expect("read page options");
let options_json: Value = serde_json::from_str(&options).expect("options json");
assert_eq!(
options_json["pages"][&renamed_document_id]["wideLayout"],
true
);
assert_eq!(
options_json["pages"][&renamed_document_id]["showToc"],
false
);
assert_eq!(
options_json["pages"][&renamed_document_id]["hideTitleHeader"],
false
assert!(
!root.join(".mnote").join("page-options.json").exists(),
"local folder UI 偏好不应继续写入 .mnote/page-options.json"
);
let _ = std::fs::remove_dir_all(&root);
+66 -29
View File
@@ -8,11 +8,12 @@ use crate::routes::local_folder_source::{
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
load_sidebar_tree_html, load_workspace_shell_projection,
attach_sidebar_shortcuts_to_dataset, build_document_panes_bootstrap_json,
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_local_file_tree_html, render_local_sidebar_tree_html,
render_editor_runtime_preload_links, render_local_file_tree_html,
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::{
@@ -67,6 +68,7 @@ pub(crate) struct RootEntryQuery {
workspace_id: Option<String>,
source_kind: Option<String>,
root_uri: Option<String>,
file_tree_scope: Option<String>,
tree_view: Option<String>,
restore_focus_row_id: Option<String>,
}
@@ -300,6 +302,11 @@ pub async fn root_entry(
.as_deref()
.map(str::trim)
.is_some_and(|value| value == "filetree");
let file_tree_scope = query
.file_tree_scope
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let (
workspace_id,
workspace_projection,
@@ -318,15 +325,8 @@ pub async fn root_entry(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, 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 page_tree_snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let workspace_id = page_tree_snapshot
.dataset
.get("workspace")
.and_then(|workspace| workspace.get("id"))
@@ -337,8 +337,15 @@ pub async fn root_entry(
.to_string();
let requested_or_recent_page_id =
choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
let mut workspace_dataset = page_tree_snapshot.dataset.clone();
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
&snapshot.dataset,
&workspace_dataset,
&workspace_id,
requested_or_recent_page_id.as_deref(),
"本地文件夹",
@@ -352,20 +359,18 @@ pub async fn root_entry(
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = if requests_filetree_first {
String::new()
} else {
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?
};
let sidebar_tree_html =
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
let restore_focus_row_id = query
.restore_focus_row_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let file_tree_html = render_local_file_tree_html(
let file_tree_html = render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?;
(
workspace_id,
@@ -399,8 +404,15 @@ pub async fn root_entry(
.filter(|value| !value.is_empty())
.unwrap_or("local-folder")
.to_string();
let mut workspace_dataset = snapshot.dataset.clone();
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
&snapshot.dataset,
&workspace_dataset,
&workspace_id,
requested_page_id.as_deref(),
&default_workspace_name,
@@ -437,6 +449,7 @@ pub async fn root_entry(
None,
);
let workspace_projection = load_workspace_shell_projection(
Some(&state),
state.config(),
&context,
&workspace_id,
@@ -489,6 +502,7 @@ pub async fn root_entry(
} else {
None
},
file_tree_scope,
);
let workspace_name = workspace_projection.workspace_name.clone();
let active_page_id = selected_active_page_id.unwrap_or_default();
@@ -593,12 +607,19 @@ pub async fn root_entry(
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
}
};
let editor_runtime_preload_links =
if body_extra.contains("document-editor-adapter-runtime.js") {
render_editor_runtime_preload_links()
} else {
""
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
{}
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
@@ -607,6 +628,7 @@ pub async fn root_entry(
</body>
</html>"#,
escape_html(&html_title),
editor_runtime_preload_links,
crate::ssr::MNOTE_CSS,
escape_html(context.auth.actor_id.as_str()),
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
@@ -654,21 +676,25 @@ pub async fn trash_entry(
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
let workspace_projection = build_workspace_shell_projection(
&json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
}),
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
None,
"我的空间",
&mut workspace_dataset,
);
let workspace_projection =
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
let trash_workbench_html = render_local_trash_workbench_html(
&workspace_id,
@@ -714,6 +740,7 @@ pub async fn trash_entry(
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let workspace_projection = load_workspace_shell_projection(
Some(&state),
state.config(),
&context,
&workspace_id,
@@ -732,6 +759,7 @@ pub async fn trash_entry(
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id)
.await
@@ -2779,6 +2807,9 @@ mod tests {
assert!(html.contains(r#"data-mnote-shell="workspace""#));
assert!(html.contains("local_folder"));
assert!(html.contains("README.md"));
assert!(html.contains(r#"id="sidebar-tree-root""#));
assert!(html.contains(r#"data-shell-mode="page""#));
assert!(html.contains(r#"data-node-id="local-md:README.md""#));
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""#));
@@ -2832,6 +2863,12 @@ mod tests {
assert!(html.contains(
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
));
assert!(html.contains(
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
));
assert!(html.contains(
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
));
assert!(
include_str!("../../browser/document-resource-tab-runtime.js")
.contains("openResourceInActiveTab")
@@ -2966,6 +2966,89 @@ mod tests {
let _ = fs::remove_dir_all(&audit_dir);
}
#[tokio::test]
async fn hermes_tools_update_options_local_folder_stores_ui_preferences_in_sqlite() {
let root = std::env::temp_dir().join(format!(
"mnote-page-options-local-folder-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.page.update_options",
"workspaceId": "local-ws-user-1",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"sessionId": "sess_page_options_local",
"runId": "run_page_options_local",
"toolCallId": "call_page_options_local",
"traceId": "trace_page_options_local",
"idempotencyKey": "idem_page_options_local",
"dryRun": false,
"args": {
"options": {
"wideLayout": true,
"showHeadingNumbers": true,
"hideTitleHeader": false
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert_eq!(status, StatusCode::OK, "{text}");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["result"]["source"], "local_folder");
assert_eq!(
payload["result"]["commandName"],
"page.layout.updateOptions"
);
assert_eq!(
payload["result"]["result"]["pageOptions"]["wideLayout"],
true
);
assert_eq!(
payload["result"]["result"]["pageOptions"]["showHeadingNumbers"],
true
);
assert_eq!(
payload["result"]["result"]["pageOptions"]["hideTitleHeader"],
false
);
assert!(
!root.join(".mnote").join("page-options.json").exists(),
"AI 页面设置工具不应继续写入 .mnote/page-options.json"
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
let response = app()
+108 -23
View File
@@ -19,6 +19,10 @@ use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize;
use serde_json::{json, Value};
#[cfg(test)]
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionQuery {
@@ -89,31 +93,39 @@ async fn project_projection(
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let snapshot = if projection == KernelProjectionKind::FileTree {
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(),
)?
let root_uri = root_uri.to_string();
let parent_relative_path = query
.parent_relative_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let root_node_id = query
.root_node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let snapshot = tokio::task::spawn_blocking(move || {
#[cfg(test)]
block_local_folder_projection_for_test();
if projection == KernelProjectionKind::FileTree {
if let Some(parent_relative_path) = parent_relative_path.as_deref() {
load_local_folder_file_tree_children_snapshot(&root_uri, parent_relative_path)
} else if root_node_id.is_some() {
load_local_folder_file_tree_snapshot_with_reveal(
&root_uri,
root_node_id.as_deref(),
)
} else {
load_local_folder_file_tree_snapshot(&root_uri)
}
} else {
load_local_folder_file_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot(&root_uri)
}
} else {
load_local_folder_page_tree_snapshot(root_uri)?
};
})
.await
.map_err(|error| WebError::internal(format!("本地树 projection 构建任务失败: {error}")))??;
return Ok(ok_response(&context, snapshot.projection));
}
@@ -137,6 +149,14 @@ async fn project_projection(
Ok(ok_response(&context, snapshot.projection))
}
#[cfg(test)]
fn block_local_folder_projection_for_test() {
let delay_ms = LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
if delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
}
pub async fn project_tree_sidebar(
state: State<AppState>,
context: Extension<RequestContext>,
@@ -245,6 +265,7 @@ mod tests {
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use std::time::{Duration, Instant};
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -443,6 +464,70 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_folder_file_projection_does_not_block_leptos_runtime_asset_request() {
let root = std::env::temp_dir().join(format!(
"mnote-local-kernel-projection-runtime-asset-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme");
let root_uri = format!("file://{}", root.display());
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
let app = app();
let projection_app = app.clone();
let projection_uri = format!(
"/api/tree/projections/file/children?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs"
);
super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS
.store(400, std::sync::atomic::Ordering::SeqCst);
struct ResetLocalProjectionBlock;
impl Drop for ResetLocalProjectionBlock {
fn drop(&mut self) {
super::LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS
.store(0, std::sync::atomic::Ordering::SeqCst);
}
}
let _reset_block = ResetLocalProjectionBlock;
let projection_task = tokio::spawn(async move {
projection_app
.oneshot(
Request::builder()
.uri(projection_uri)
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("projection request"),
)
.await
.expect("projection response")
});
let started = Instant::now();
tokio::task::yield_now().await;
let asset_response = app
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("asset request"),
)
.await
.expect("asset response");
let asset_elapsed = started.elapsed();
let projection_response = projection_task.await.expect("projection task");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(asset_response.status(), StatusCode::OK);
assert_eq!(projection_response.status(), StatusCode::OK);
assert!(
asset_elapsed < Duration::from_millis(150),
"leptos-tiptap runtime asset 不应被本地 FileTree projection 扫描阻塞,实际等待 {asset_elapsed:?}"
);
}
#[tokio::test]
async fn tree_projection_routes_keep_ok_response_shape() {
let response = app()
@@ -19,7 +19,7 @@ use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::error::RecvError;
use tokio::time::{interval, MissedTickBehavior};
use tokio::time::{interval, timeout, MissedTickBehavior};
type BoxedEventStream =
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
@@ -177,18 +177,38 @@ async fn build_tree_live_stream(
loop {
match subscription.receiver.recv().await {
Ok(_watcher_payload) => {
// Rebuild full snapshot on any filesystem change
if let Some(resync_payload) =
rebuild_tree_resync_payload(&root_uri, &workspace_id)
{
Ok(watcher_payload) => {
let mut watcher_payloads = vec![watcher_payload];
loop {
match timeout(Duration::from_millis(120), subscription.receiver.recv())
.await
{
Ok(Ok(next_payload)) => watcher_payloads.push(next_payload),
Ok(Err(RecvError::Lagged(_))) => continue,
Ok(Err(RecvError::Closed)) => return None,
Err(_) => break,
}
}
if let Some(batch_payload) = build_local_folder_watch_batch_payload(
&root_uri,
&workspace_id,
watcher_payloads,
) {
return Some((
Ok(stream_event("resync", &resync_payload)),
Ok(stream_event("watch_batch", &batch_payload)),
(None, subscription, root_uri, workspace_id),
));
}
// Snapshot load failed — continue waiting for next change
continue;
let error_payload = build_tree_live_error_payload(
&root_uri,
&workspace_id,
"tree_live_watch_batch_failed",
"local folder watcher batch payload missing paths",
);
return Some((
Ok(stream_event("tree_error", &error_payload)),
(None, subscription, root_uri, workspace_id),
));
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
@@ -276,6 +296,96 @@ fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Val
))
}
fn parent_relative_path_for_watch_path(relative_path: &str) -> String {
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
if normalized.is_empty() || normalized == "." {
return String::new();
}
normalized
.rsplit_once('/')
.map(|(parent, _)| parent.to_string())
.unwrap_or_default()
}
fn build_local_folder_watch_batch_payload(
root_uri: &str,
workspace_id: &str,
watcher_payloads: Vec<Value>,
) -> Option<Value> {
let revision = local_folder_watch_revision(root_uri).ok()?;
let mut changed_paths = Vec::new();
let mut affected_parents = Vec::new();
let mut event_kinds = Vec::new();
let mut seen_paths = std::collections::BTreeSet::new();
let mut seen_parents = std::collections::BTreeSet::new();
let mut seen_kinds = std::collections::BTreeSet::new();
for payload in watcher_payloads {
let relative_path = payload
.get("relativePath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let event_kind = payload
.get("eventKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown");
if seen_paths.insert(relative_path.to_string()) {
changed_paths.push(json!({
"relativePath": relative_path,
"kind": event_kind,
}));
}
if seen_kinds.insert(event_kind.to_string()) {
event_kinds.push(event_kind.to_string());
}
let parent = parent_relative_path_for_watch_path(relative_path);
if seen_parents.insert(parent.clone()) {
affected_parents.push(json!({
"relativePath": parent,
"reason": "child-watch",
}));
}
}
if changed_paths.is_empty() {
return None;
}
Some(json!({
"schema": "mnote.local_folder_watch_batch.v1",
"kind": "watch_batch",
"sourceKind": "local_folder",
"rootUri": root_uri,
"workspaceId": workspace_id,
"revision": revision.revision,
"watchRevision": revision,
"changedPaths": changed_paths,
"affectedParents": affected_parents,
"eventKinds": event_kinds,
"fallbackResync": false,
}))
}
fn build_tree_live_error_payload(
root_uri: &str,
workspace_id: &str,
code: &str,
message: &str,
) -> Value {
json!({
"schema": "mnote.tree_live_error.v1",
"kind": "error",
"phase": "tree_live_resync",
"sourceKind": "local_folder",
"rootUri": root_uri,
"workspaceId": workspace_id,
"code": code,
"message": message,
"fallbackResync": true,
"revision": system_time_ms(SystemTime::now()).to_string(),
})
}
fn build_tree_snapshot_payload(
root_uri: &str,
workspace_id: &str,
@@ -548,4 +658,66 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() {
let root = test_root("tree-live-watch-batch");
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs/README.md"), "# Initial\n").expect("write initial");
let root_uri = format!("file://{}", root.display());
let workspace_id =
local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id");
let payload = build_local_folder_watch_batch_payload(
&root_uri,
&workspace_id,
vec![
json!({
"relativePath": "docs/README.md",
"eventKind": "Modify(Data)",
}),
json!({
"relativePath": "docs/New.md",
"eventKind": "Create(File)",
}),
],
)
.expect("watch batch payload");
assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1");
assert_eq!(payload["kind"], "watch_batch");
assert_eq!(payload["fallbackResync"], false);
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
assert!(
payload["affectedParents"]
.as_array()
.expect("affected parents")
.iter()
.any(|parent| parent["relativePath"].as_str() == Some("docs")
&& parent["reason"].as_str() == Some("child-watch")),
"watch batch 应声明 docs affected parent: {payload}"
);
assert!(payload["eventKinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("Modify(Data)")));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn tree_live_error_payload_is_structured() {
let payload = build_tree_live_error_payload(
"file:///test",
"local:test",
"tree_live_resync_failed",
"failed",
);
assert_eq!(payload["schema"], "mnote.tree_live_error.v1");
assert_eq!(payload["phase"], "tree_live_resync");
assert_eq!(payload["fallbackResync"], true);
assert_eq!(payload["code"], "tree_live_resync_failed");
}
}
File diff suppressed because it is too large Load Diff
@@ -56,7 +56,7 @@ pub(crate) fn query_local_search_index(
title_only: bool,
exact: bool,
) -> Result<Value, WebError> {
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let normalized_query = normalize_search_text(query);
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
@@ -294,6 +294,23 @@ fn rebuild_local_search_index(
Ok(index)
}
fn load_or_rebuild_local_search_index(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
) -> Result<LocalSearchIndex, WebError> {
match read_local_search_index(root_path) {
Ok(Some(index))
if index.version == LOCAL_SEARCH_INDEX_VERSION
&& index.root_uri == root_uri
&& index.workspace_id == workspace_id =>
{
Ok(index)
}
Ok(_) | Err(_) => rebuild_local_search_index(root_path, root_uri, workspace_id),
}
}
fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
let index_path = root_path
.join(".mnote")
@@ -999,4 +1016,74 @@ mod tests {
.any(|document| document.path == "README.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_query_reads_existing_index_without_rebuilding() {
let root = temp_root("mnote-local-search-query-cache");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-query-cache";
let child_path = root.join("docs").join("child.md");
fs::write(
&child_path,
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
)
.expect("write child");
let first_projection = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OriginalToken",
None,
10,
false,
false,
)
.expect("first query");
assert_eq!(
first_projection["results"].as_array().map(Vec::len),
Some(1)
);
fs::write(
&child_path,
"---\ntitle: Child\n---\n# Child\nUnindexedToken body.\n",
)
.expect("update child without refresh");
let stale_projection = query_local_search_index(
&root,
&root_uri,
workspace_id,
"UnindexedToken",
None,
10,
false,
false,
)
.expect("query existing index");
assert_eq!(
stale_projection["results"].as_array().map(Vec::len),
Some(0)
);
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
.expect("incremental refresh");
let refreshed_projection = query_local_search_index(
&root,
&root_uri,
workspace_id,
"UnindexedToken",
None,
10,
false,
false,
)
.expect("query refreshed index");
assert_eq!(
refreshed_projection["results"].as_array().map(Vec::len),
Some(1)
);
let _ = fs::remove_dir_all(&root);
}
}
@@ -61,6 +61,7 @@ pub async fn mindmap_object_shell(
let file_tree_html =
render_local_file_tree_html(root_uri, Some(&doc_id), None).unwrap_or_default();
let workspace_projection = load_workspace_shell_projection(
Some(&state),
state.config(),
&context,
workspace_id.as_deref().unwrap_or("local-folder"),
@@ -74,6 +75,7 @@ pub async fn mindmap_object_shell(
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
(
Some(workspace_name),
@@ -82,6 +84,7 @@ pub async fn mindmap_object_shell(
)
} else if let Some(workspace_id) = workspace_id.as_deref() {
let workspace_projection = load_workspace_shell_projection(
Some(&state),
state.config(),
&context,
workspace_id,
@@ -109,6 +112,7 @@ pub async fn mindmap_object_shell(
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
(
Some(workspace_name),
@@ -268,7 +272,7 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
return window.__mnoteLeptosTiptapRuntimePromise;
}
window.__mnoteLeptosTiptapRuntimePromise = (async () => {
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json');
if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`);
const manifest = await manifestResponse.json();
if (!manifest.entryAssetPath) throw new Error('island manifest entryAssetPath');
+19 -1
View File
@@ -23,16 +23,18 @@ mod query_support;
mod resource_trash;
mod search;
mod session;
pub(crate) mod sidebar_shortcuts;
mod snapshot_support;
mod sse;
mod stream_support;
mod tree;
pub(crate) mod ui_preferences;
pub(crate) mod web_shell;
mod ws;
pub(crate) use local_folder_source::{
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
update_local_markdown_title, update_local_page_options, write_local_markdown_page_body,
update_local_markdown_title, write_local_markdown_page_body,
};
pub(crate) use local_search_index::refresh_local_search_index_for_path;
@@ -82,6 +84,14 @@ pub fn build_router(state: AppState) -> Router {
"/api/page-aggregate/{document_id}",
get(web_shell::page_aggregate),
)
.route(
"/api/ui/preferences/effective",
get(ui_preferences::effective_preferences),
)
.route(
"/api/ui/preferences",
put(ui_preferences::update_preferences),
)
.route(
"/api/leptos-tiptap-runtime/manifest.json",
get(web_shell::leptos_tiptap_manifest),
@@ -263,6 +273,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/whoami", get(session::session))
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route(
"/api/sidebar/shortcuts",
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
)
.route(
"/api/sidebar/shortcuts/{shortcut_id}",
delete(sidebar_shortcuts::delete_shortcut),
)
.route(
"/api/admin/access-policy",
get(local_folder_source::get_local_access_policy),
@@ -0,0 +1,285 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::current_actor_id;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SidebarShortcutListQuery {
#[serde(default, alias = "workspace_id")]
workspace_id: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SidebarShortcutUpsertRequest {
#[serde(default)]
id: Option<String>,
#[serde(default, alias = "workspace_id")]
workspace_id: String,
#[serde(default, alias = "root_uri")]
root_uri: Option<String>,
#[serde(default)]
kind: String,
#[serde(default, alias = "source_kind")]
source_kind: String,
#[serde(default, alias = "target_id")]
target_id: String,
#[serde(default, alias = "relative_path")]
relative_path: Option<String>,
#[serde(default, alias = "document_id")]
document_id: Option<String>,
#[serde(default)]
title: String,
#[serde(default)]
icon: Option<String>,
#[serde(default, alias = "sort_order")]
sort_order: Option<i64>,
#[serde(default, alias = "metadata_json")]
metadata_json: Option<String>,
}
pub async fn list_shortcuts(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<SidebarShortcutListQuery>,
) -> Result<Json<Value>, WebError> {
let workspace_id = query.workspace_id.trim();
if workspace_id.is_empty() {
return Err(WebError::bad_request_code(
"sidebar_shortcut_workspace_required",
"缺少 workspaceId",
)
.with_context(&context));
}
let actor_id = require_actor_id(&state, &context)?;
let shortcuts = state
.control_plane()
.list_sidebar_shortcuts(&actor_id, workspace_id)
.map_err(|error| {
WebError::internal(format!("读取星标置顶失败: {error}")).with_context(&context)
})?;
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
"workspaceId": workspace_id,
"shortcuts": shortcuts.iter().map(shortcut_to_json).collect::<Vec<_>>(),
})))
}
pub async fn upsert_shortcut(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<SidebarShortcutUpsertRequest>,
) -> Result<Json<Value>, WebError> {
let actor_id = require_actor_id(&state, &context)?;
let workspace_id = request.workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err(WebError::bad_request_code(
"sidebar_shortcut_workspace_required",
"缺少 workspaceId",
)
.with_context(&context));
}
let kind = request.kind.trim().to_string();
let target_id = request.target_id.trim().to_string();
let title = request.title.trim().to_string();
let root_uri = request
.root_uri
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let metadata_json =
normalize_shortcut_metadata_json(request.metadata_json, root_uri.as_deref())?;
let shortcut = state
.control_plane()
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: request.id,
user_id: actor_id.clone(),
workspace_id: workspace_id.clone(),
root_uri,
kind,
source_kind: request
.source_kind
.trim()
.to_string()
.if_empty_else(|| "local_folder".to_string()),
target_id,
relative_path: request
.relative_path
.map(|value| value.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty()),
document_id: request
.document_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
title,
icon: request
.icon
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
sort_order: request.sort_order.unwrap_or(0),
metadata_json,
})
.map_err(|error| {
WebError::bad_request_code(
"sidebar_shortcut_upsert_failed",
format!("写入星标置顶失败: {error}"),
)
.with_context(&context)
})?;
append_shortcut_audit(&state, &actor_id, "sidebar.shortcut.upserted", &shortcut);
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
"shortcut": shortcut_to_json(&shortcut),
})))
}
pub async fn delete_shortcut(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(shortcut_id): Path<String>,
) -> Result<Json<Value>, WebError> {
let actor_id = require_actor_id(&state, &context)?;
state
.control_plane()
.delete_sidebar_shortcut(&actor_id, &shortcut_id)
.map_err(|error| {
WebError::bad_request_code(
"sidebar_shortcut_delete_failed",
format!("移除星标置顶失败: {error}"),
)
.with_context(&context)
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "sidebar.shortcut.removed".to_string(),
target_kind: "sidebar_shortcut".to_string(),
target_id: Some(shortcut_id.clone()),
metadata_json: "{}".to_string(),
});
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
"removedShortcutId": shortcut_id,
})))
}
pub(crate) fn load_sidebar_shortcut_dataset(
state: &AppState,
context: &RequestContext,
workspace_id: &str,
) -> Vec<Value> {
let Some(actor_id) = current_actor_id(state, context) else {
return Vec::new();
};
state
.control_plane()
.list_sidebar_shortcuts_with_global_local(&actor_id, workspace_id)
.map(|shortcuts| shortcuts.iter().map(shortcut_to_json).collect())
.unwrap_or_default()
}
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
current_actor_id(state, context)
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"sidebar_shortcut_auth_required",
"星标置顶需要登录用户",
)
.with_context(context)
})
}
fn shortcut_to_json(shortcut: &SidebarShortcutRecord) -> Value {
json!({
"id": shortcut.id,
"userId": shortcut.user_id,
"workspaceId": shortcut.workspace_id,
"rootUri": shortcut.root_uri,
"kind": shortcut.kind,
"sourceKind": shortcut.source_kind,
"targetId": shortcut.target_id,
"relativePath": shortcut.relative_path,
"documentId": shortcut.document_id,
"title": shortcut.title,
"icon": shortcut.icon,
"sortOrder": shortcut.sort_order,
"status": shortcut.status,
"metadata": serde_json::from_str::<Value>(&shortcut.metadata_json).unwrap_or(Value::Null),
"createdAt": shortcut.created_at,
"updatedAt": shortcut.updated_at,
"revision": shortcut.revision,
})
}
fn normalize_shortcut_metadata_json(
metadata_json: Option<String>,
root_uri: Option<&str>,
) -> Result<String, WebError> {
let mut metadata = metadata_json
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| serde_json::from_str::<Value>(value))
.transpose()
.map_err(|error| {
WebError::bad_request_code(
"sidebar_shortcut_metadata_invalid",
format!("星标置顶 metadataJson 必须是 JSON 对象: {error}"),
)
})?
.unwrap_or_else(|| json!({}));
if !metadata.is_object() {
metadata = json!({});
}
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
if let Some(object) = metadata.as_object_mut() {
object.insert("rootUri".to_string(), Value::String(root_uri.to_string()));
}
}
serde_json::to_string(&metadata)
.map_err(|error| WebError::internal(format!("星标置顶 metadataJson 序列化失败: {error}")))
}
fn append_shortcut_audit(
state: &AppState,
actor_id: &str,
action: &str,
shortcut: &SidebarShortcutRecord,
) {
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.to_string()),
action: action.to_string(),
target_kind: "sidebar_shortcut".to_string(),
target_id: Some(shortcut.id.clone()),
metadata_json: json!({
"workspaceId": shortcut.workspace_id,
"kind": shortcut.kind,
"targetId": shortcut.target_id,
})
.to_string(),
});
}
trait EmptyStringExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}
impl EmptyStringExt for String {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
if self.trim().is_empty() {
fallback()
} else {
self
}
}
}
+112 -1
View File
@@ -86,6 +86,7 @@ pub struct TreeCommandEnvelope {
pub target_resource_meta: Option<Value>,
pub selection: Option<Value>,
pub operation: Option<String>,
pub batch_id: Option<String>,
pub document_id: Option<String>,
pub parent_id: Option<String>,
pub target_parent_id: Option<String>,
@@ -114,6 +115,7 @@ pub struct TreeCommandEnvelopeContext {
pub target_resource_meta: Option<Value>,
pub selection: Option<Value>,
pub operation: Option<String>,
pub batch_id: Option<String>,
}
impl TreeCommandEnvelopeContext {
@@ -130,6 +132,7 @@ impl TreeCommandEnvelopeContext {
target_resource_meta: envelope.target_resource_meta.clone(),
selection: envelope.selection.clone(),
operation: read_optional_non_empty(envelope.operation.clone()),
batch_id: read_optional_non_empty(envelope.batch_id.clone()),
}
}
}
@@ -525,6 +528,25 @@ pub(crate) fn collect_filetree_render_rows(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let relative_path = item
.get("relativePath")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("workspacePath"))
.and_then(|workspace_path| workspace_path.get("relativePath"))
.and_then(Value::as_str)
})
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("extra"))
.and_then(|extra| extra.get("source"))
.and_then(|source| source.get("relativePath"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let icon_kind = item
.get("iconHint")
.and_then(Value::as_str)
@@ -571,6 +593,7 @@ pub(crate) fn collect_filetree_render_rows(
icon_kind,
document_id,
asset_id,
relative_path,
object_identity: resource_meta
.and_then(|meta| meta.get("objectIdentity"))
.and_then(|value| serde_json::to_string(value).ok()),
@@ -2092,6 +2115,79 @@ async fn resolve_tree_create_workspace_id(
})
}
fn operation_resource_relative_path(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_object)
.and_then(|object| object.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.map(ToOwned::to_owned)
}
fn operation_resource_document_id(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_object)
.and_then(|object| object.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| path.starts_with("local-md:"))
.map(ToOwned::to_owned)
}
fn apply_local_file_operation_participants(
buffer_store: &crate::document_buffer_store::BufferStore,
workspace_id: &str,
root_uri: &str,
action: &str,
execution: &Value,
) {
let previous_relative_path = operation_resource_relative_path(execution, "previousResource");
let previous_document_id = operation_resource_document_id(execution, "previousResource");
let next_relative_path = operation_resource_relative_path(execution, "resource");
let next_document_id = operation_resource_document_id(execution, "resource");
match action {
"rename" | "move" => {
if let (
Some(previous_relative_path),
Some(previous_document_id),
Some(next_relative_path),
Some(next_document_id),
) = (
previous_relative_path.as_deref(),
previous_document_id.as_deref(),
next_relative_path.as_deref(),
next_document_id.as_deref(),
) {
let _ = buffer_store.rekey_local_folder_markdown(
workspace_id,
root_uri,
previous_relative_path,
previous_document_id,
next_relative_path,
next_document_id,
);
}
}
"delete" | "archive" | "trash" | "purge" => {
if let (Some(previous_relative_path), Some(previous_document_id)) = (
previous_relative_path.as_deref(),
previous_document_id.as_deref(),
) {
let _ = buffer_store.mark_local_folder_markdown_deleted(
workspace_id,
root_uri,
previous_relative_path,
previous_document_id,
);
}
}
_ => {}
}
}
pub async fn tree_command(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -2292,10 +2388,18 @@ pub async fn tree_command(
.with_context(&context)
.with_header("x-error-phase", "tree_local_executor")
})?;
let local_workspace_id = local_workspace_id_from_root_uri(root_uri)?;
apply_local_file_operation_participants(
&state.buffer_store,
&local_workspace_id,
root_uri,
action,
&execution,
);
return Ok(json_response(
&context,
json!({
"workspaceId": local_workspace_id_from_root_uri(root_uri)?,
"workspaceId": local_workspace_id,
"action": action,
"documentId": execution
.get("documentId")
@@ -2304,6 +2408,12 @@ pub async fn tree_command(
"parentId": requested_parent_id,
"title": requested_title,
"sortOrder": requested_sort_order,
"affectedParents": execution.get("affectedParents").cloned().unwrap_or(Value::Null),
"revealTarget": execution.get("revealTarget").cloned().unwrap_or(Value::Null),
"selectTarget": execution.get("selectTarget").cloned().unwrap_or(Value::Null),
"operationId": execution.get("operationId").cloned().unwrap_or(Value::Null),
"batchId": envelope_context.batch_id.clone(),
"schema": execution.get("schema").cloned().unwrap_or(Value::Null),
"updatedAt": Value::Null,
"execution": execution,
"artifacts": Value::Null,
@@ -3999,6 +4109,7 @@ mod tests {
"rowIds": ["doc:page_child"]
})),
operation: Some("tree.node.rename".into()),
batch_id: None,
};
let rename_wire = create_command_wire(
@@ -0,0 +1,489 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::page_aggregate::{PageAggregate, PageOptions};
use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::{
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
};
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
use serde::Deserialize;
use serde_json::Value;
use serde_json::{json, Map};
use std::collections::BTreeMap;
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
pub(crate) const SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER: &str = "external_local_folder";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UiPreferencesQuery {
#[serde(default, alias = "workspace_id")]
workspace_id: String,
#[serde(default, alias = "source_kind")]
source_kind: String,
#[serde(default, alias = "root_uri")]
root_uri: String,
#[serde(default, alias = "document_id")]
document_id: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UiPreferencesUpdateRequest {
#[serde(default, alias = "workspace_id")]
workspace_id: String,
#[serde(default, alias = "source_kind")]
source_kind: String,
#[serde(default, alias = "root_uri")]
root_uri: String,
#[serde(default, alias = "document_id")]
document_id: String,
#[serde(default)]
updates: BTreeMap<String, Value>,
}
#[derive(Debug, Clone)]
struct PagePreferenceScope {
workspace_id: String,
source_kind: String,
source_family: String,
document_id: String,
}
#[derive(Debug, Clone)]
struct EffectivePagePreferences {
scope: PagePreferenceScope,
page_options: PageOptions,
sources: BTreeMap<String, String>,
}
pub(crate) async fn effective_preferences(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<UiPreferencesQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = require_actor_id(&state, &context)?;
let effective = resolve_effective_page_preferences(
&state,
&actor_id,
&query.workspace_id,
&query.source_kind,
&query.root_uri,
&query.document_id,
PageOptions::default(),
)?;
Ok(Json(effective_preferences_payload(effective)))
}
pub(crate) async fn update_preferences(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<UiPreferencesUpdateRequest>,
) -> Result<Json<Value>, WebError> {
let actor_id = require_actor_id(&state, &context)?;
let result = update_page_preferences_from_value(
&state,
&context,
&actor_id,
&request.workspace_id,
&request.source_kind,
&request.root_uri,
&request.document_id,
&Value::Object(request.updates.into_iter().collect()),
)?;
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
"result": result,
})))
}
pub(crate) fn update_page_preferences_from_value(
state: &AppState,
context: &RequestContext,
actor_id: &str,
workspace_id: &str,
source_kind: &str,
root_uri: &str,
document_id: &str,
updates: &Value,
) -> Result<Value, WebError> {
ensure_actor_user(state, actor_id)?;
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
let update_map = updates.as_object().ok_or_else(|| {
WebError::bad_request_code("ui_preference_updates_invalid", "updates 必须是对象")
.with_context(context)
})?;
for (key, value) in update_map {
let Some((scope_kind, scope_id)) = preference_scope_for_key(key, &scope) else {
continue;
};
state
.control_plane()
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: actor_id.to_string(),
workspace_id: preference_workspace_for_scope(&scope.workspace_id, &scope_kind),
source_kind: preference_source_kind_for_scope(&scope.source_kind, &scope_kind),
scope_kind,
scope_id,
key: key.trim().to_string(),
value_json: value.to_string(),
})
.map_err(|error| {
WebError::bad_request_code(
"ui_preference_write_failed",
format!("写入 UI 偏好失败: {error}"),
)
.with_context(context)
})?;
}
let effective = resolve_effective_page_preferences(
state,
actor_id,
workspace_id,
source_kind,
root_uri,
document_id,
PageOptions::default(),
)?;
Ok(effective_preferences_payload(effective)["result"].clone())
}
pub(crate) fn source_family_for_page(
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<String, WebError> {
if source_kind.map(str::trim) != Some("local_folder") {
return Ok("workspace".to_string());
}
let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string());
};
if local_root_has_workspace_manifest(root_uri)? {
Ok(SOURCE_FAMILY_MY_SPACE.to_string())
} else {
Ok(SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER.to_string())
}
}
pub(crate) fn apply_effective_page_preferences(
state: &AppState,
context: &RequestContext,
aggregate: &mut PageAggregate,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<(), WebError> {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return Ok(());
}
let effective = resolve_effective_page_preferences(
state,
actor_id,
&aggregate.identity.workspace_id,
source_kind.unwrap_or_default(),
root_uri.unwrap_or_default(),
&aggregate.identity.document_id,
aggregate.layout.page_options.clone(),
)?;
aggregate.layout.page_options = effective.page_options;
aggregate.layout_options = serde_json::to_value(&aggregate.layout.page_options)
.unwrap_or_else(|_| serde_json::json!({}));
Ok(())
}
fn resolve_effective_page_preferences(
state: &AppState,
actor_id: &str,
workspace_id: &str,
source_kind: &str,
root_uri: &str,
document_id: &str,
base_options: PageOptions,
) -> Result<EffectivePagePreferences, WebError> {
let scope = page_preference_scope(workspace_id, source_kind, root_uri, document_id)?;
let preferences = state
.control_plane()
.list_user_ui_preferences(
actor_id,
Some(&scope.workspace_id),
Some(&scope.source_kind),
)
.map_err(|error| WebError::internal(format!("SQLite UI 偏好读取失败: {error}")))?;
let mut page_options = base_options;
if scope.source_family == SOURCE_FAMILY_EXTERNAL_LOCAL_FOLDER {
page_options.hide_title_header = true;
}
let mut sources = BTreeMap::new();
apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?;
Ok(EffectivePagePreferences {
scope,
page_options,
sources,
})
}
fn apply_preference_records(
page_options: &mut PageOptions,
sources: &mut BTreeMap<String, String>,
scope: &PagePreferenceScope,
preferences: &[UserUiPreferenceRecord],
) -> Result<(), WebError> {
for scope_kind in ["global", "source_family", "workspace", "document"] {
for preference in preferences
.iter()
.filter(|preference| preference.scope_kind.trim() == scope_kind)
{
let scope_matches = match scope_kind {
"global" => preference.scope_id.trim() == "default",
"source_family" => preference.scope_id.trim() == scope.source_family,
"workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id,
_ => false,
};
if !scope_matches {
continue;
}
let value = serde_json::from_str::<Value>(&preference.value_json).map_err(|error| {
WebError::internal(format!(
"SQLite UI 偏好 JSON 无效 {}: {error}",
preference.key
))
})?;
if apply_page_option_value(page_options, &preference.key, &value) {
sources.insert(preference.key.clone(), scope_kind.to_string());
}
}
}
Ok(())
}
fn page_preference_scope(
workspace_id: &str,
source_kind: &str,
root_uri: &str,
document_id: &str,
) -> Result<PagePreferenceScope, WebError> {
let source_kind = source_kind.trim();
let source_kind = if source_kind.is_empty() {
"convex_workspace"
} else {
source_kind
};
let workspace_id = workspace_id.trim().to_string().if_empty_else(|| {
if source_kind == "local_folder" {
local_workspace_id_from_root_uri(root_uri).unwrap_or_else(|_| "local-folder".into())
} else {
"default".into()
}
});
Ok(PagePreferenceScope {
workspace_id,
source_kind: source_kind.to_string(),
source_family: source_family_for_page(Some(source_kind), Some(root_uri))?,
document_id: document_id.trim().to_string(),
})
}
fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> {
match key.trim() {
"hideTitleHeader" | "hide_title_header" => {
Some(("source_family".to_string(), scope.source_family.clone()))
}
"showHeadingNumbers" | "show_heading_numbers" | "showWordCount" | "show_word_count" => {
Some(("global".to_string(), "default".to_string()))
}
"wideLayout"
| "wide_layout"
| "smallText"
| "small_text"
| "layoutDensity"
| "layout_density"
| "pageFont"
| "page_font"
| "showToc"
| "show_toc"
| "showStructure"
| "show_structure"
| "collapseBacklinks"
| "collapse_backlinks"
| "hideChildPages"
| "hide_child_pages"
| "showBlockRefCount"
| "show_block_ref_count" => Some(("workspace".to_string(), scope.workspace_id.clone())),
_ => None,
}
}
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace" || scope_kind == "document" {
Some(workspace_id.to_string())
} else {
None
}
}
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace" || scope_kind == "document" {
Some(source_kind.to_string())
} else {
None
}
}
fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({}));
let sources = effective
.sources
.into_iter()
.map(|(key, value)| (key, Value::String(value)))
.collect::<Map<String, Value>>();
json!({
"ok": true,
"owner": "mnote-web",
"result": {
"scope": {
"sourceFamily": effective.scope.source_family,
"workspaceId": effective.scope.workspace_id,
"sourceKind": effective.scope.source_kind,
"documentId": effective.scope.document_id,
},
"pageOptions": page_options,
"sources": Value::Object(sources),
}
})
}
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
current_actor_id(state, context)
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"ui_preference_auth_required",
"UI 偏好需要登录用户",
)
.with_context(context)
})
}
fn ensure_actor_user(state: &AppState, actor_id: &str) -> Result<(), WebError> {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(actor_id.to_string()),
email: None,
username: actor_id.to_string(),
display_name: actor_id.to_string(),
role: None,
password_hash: None,
})
.map(|_| ())
.map_err(|error| WebError::internal(format!("SQLite 用户初始化失败: {error}")))
}
fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value) -> bool {
match key {
"hideTitleHeader" | "hide_title_header" => {
if let Some(value) = value.as_bool() {
options.hide_title_header = value;
return true;
}
}
"showHeadingNumbers" | "show_heading_numbers" => {
if let Some(value) = value.as_bool() {
options.show_heading_numbers = value;
return true;
}
}
"wideLayout" | "wide_layout" => {
if let Some(value) = value.as_bool() {
options.wide_layout = value;
return true;
}
}
"smallText" | "small_text" => {
if let Some(value) = value.as_bool() {
options.small_text = value;
return true;
}
}
"layoutDensity" | "layout_density" => {
if let Some(value) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
options.layout_density = value.to_string();
return true;
}
}
"pageFont" | "page_font" => {
if let Some(value) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
options.page_font = value.to_string();
return true;
}
}
"showToc" | "show_toc" => {
if let Some(value) = value.as_bool() {
options.show_toc = value;
return true;
}
}
"showStructure" | "show_structure" => {
if let Some(value) = value.as_bool() {
options.show_structure = value;
return true;
}
}
"showWordCount" | "show_word_count" => {
if let Some(value) = value.as_bool() {
options.show_word_count = value;
return true;
}
}
"collapseBacklinks" | "collapse_backlinks" => {
if let Some(value) = value.as_bool() {
options.collapse_backlinks = value;
return true;
}
}
"hideChildPages" | "hide_child_pages" => {
if let Some(value) = value.as_bool() {
options.hide_child_pages = value;
return true;
}
}
"showBlockRefCount" | "show_block_ref_count" => {
if let Some(value) = value.as_bool() {
options.show_block_ref_count = value;
return true;
}
}
_ => {}
}
false
}
trait EmptyStringExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}
impl EmptyStringExt for String {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
if self.trim().is_empty() {
fallback()
} else {
self
}
}
}
+401 -44
View File
@@ -10,6 +10,7 @@ use crate::routes::documents::{
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
resolve_local_markdown_page_aggregate,
};
@@ -35,7 +36,7 @@ use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::json;
use serde_json::{json, Value};
use std::fs;
use std::path::{Component, Path as FsPath, PathBuf};
@@ -43,6 +44,10 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
#[cfg(test)]
static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
@@ -50,6 +55,7 @@ pub struct DocumentShellQuery {
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub tree_view: Option<String>,
pub file_tree_scope: Option<String>,
pub secondary_document_id: Option<String>,
pub secondary_source_kind: Option<String>,
pub secondary_root_uri: Option<String>,
@@ -129,6 +135,7 @@ pub async fn document_page_shell(
};
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let mut workspace_projection = load_workspace_shell_projection(
Some(&state),
state.config(),
&context,
&workspace_id,
@@ -148,11 +155,17 @@ pub async fn document_page_shell(
.as_deref()
.map(str::trim)
.is_some_and(|value| value == "filetree");
let file_tree_scope = query
.file_tree_scope
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
let root_uri = query.root_uri.as_deref().unwrap_or_default();
(
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
render_local_file_tree_html(root_uri, Some(&document_id), None).unwrap_or_default(),
render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
.unwrap_or_default(),
)
} else {
(
@@ -179,6 +192,7 @@ pub async fn document_page_shell(
} else {
None
},
file_tree_scope,
);
let workspace_name = workspace_projection.workspace_name.clone();
let page_subtree_json =
@@ -245,6 +259,7 @@ pub async fn document_page_shell(
<head>
<meta charset="utf-8">
<title>{}</title>
{}
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
@@ -261,6 +276,7 @@ pub async fn document_page_shell(
</body>
</html>"#,
escape_html(title),
render_editor_runtime_preload_links(),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
@@ -646,6 +662,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
}
pub(crate) fn render_editor_runtime_preload_links() -> &'static str {
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">
<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
}
fn runtime_asset_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../spikes/leptos-tiptap-spike/generated/island")
@@ -678,6 +699,10 @@ fn runtime_asset_content_type(asset_path: &str) -> &'static str {
}
}
fn runtime_asset_cache_control() -> &'static str {
"public, max-age=3600, stale-while-revalidate=86400"
}
pub async fn editor_image_placeholder_asset() -> Response {
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
<rect width="640" height="360" rx="18" fill="#f3f4f6"/>
@@ -705,7 +730,7 @@ pub async fn resource_open_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -719,7 +744,7 @@ pub async fn local_upload_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -733,7 +758,7 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -747,7 +772,7 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -761,7 +786,7 @@ pub async fn sidebar_page_settings_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -775,7 +800,7 @@ pub async fn sidebar_shell_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -789,7 +814,7 @@ pub async fn sidebar_workspace_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -803,7 +828,7 @@ pub async fn sidebar_page_tree_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -817,7 +842,7 @@ pub async fn sidebar_tree_live_apply_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -831,7 +856,7 @@ pub async fn sidebar_filetree_open_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -845,7 +870,7 @@ pub async fn sidebar_filetree_command_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -859,7 +884,7 @@ pub async fn sidebar_filetree_upload_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -873,7 +898,7 @@ pub async fn sidebar_attachment_open_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -887,7 +912,7 @@ pub async fn filetree_keyboard_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -901,7 +926,7 @@ pub async fn filetree_dnd_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -915,7 +940,7 @@ pub async fn filetree_context_menu_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -929,7 +954,7 @@ pub async fn filetree_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -943,7 +968,7 @@ pub async fn filetree_selection_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -957,7 +982,7 @@ pub async fn tree_live_controller_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -971,7 +996,7 @@ pub async fn tree_shell_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -985,7 +1010,7 @@ pub async fn tree_shell_render_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -999,7 +1024,7 @@ pub async fn tree_shell_page_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1013,7 +1038,7 @@ pub async fn tree_shell_state_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1027,7 +1052,7 @@ pub async fn tree_shell_icons_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1041,7 +1066,7 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1055,7 +1080,7 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1069,7 +1094,7 @@ pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1083,7 +1108,7 @@ pub async fn tree_shell_picker_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1097,7 +1122,7 @@ pub async fn tree_shell_dom_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1112,7 +1137,7 @@ pub async fn document_conflict_panel_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1126,7 +1151,7 @@ pub async fn document_pane_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1140,7 +1165,7 @@ pub async fn document_mindmap_host_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1154,7 +1179,7 @@ pub async fn document_resource_tab_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1168,7 +1193,7 @@ pub async fn document_session_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1182,7 +1207,7 @@ pub async fn document_slash_position_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1196,7 +1221,7 @@ pub async fn document_tiptap_conversion_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1210,7 +1235,7 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
@@ -1228,6 +1253,10 @@ pub async fn leptos_tiptap_manifest() -> Response {
});
let mut response = Json(manifest).into_response();
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(runtime_asset_cache_control()),
);
response
}
@@ -1251,7 +1280,7 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
header::CONTENT_TYPE,
runtime_asset_content_type(&asset_path),
)
.header(header::CACHE_CONTROL, "no-store")
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(bytes))
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
@@ -1312,7 +1341,24 @@ pub(crate) async fn build_page_aggregate_snapshot(
})?;
ensure_local_workspace_read_access_with_state(state, context, root_uri)
.map_err(|error| error.with_context(context))?;
return resolve_local_markdown_page_aggregate(root_uri, document_id);
let root_uri_for_build = root_uri.to_string();
let root_uri_for_preferences = root_uri_for_build.clone();
let document_id = document_id.to_string();
let mut aggregate = tokio::task::spawn_blocking(move || {
#[cfg(test)]
block_local_page_aggregate_for_test();
resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id)
})
.await
.map_err(|error| WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")))??;
super::ui_preferences::apply_effective_page_preferences(
state,
context,
&mut aggregate,
source_kind,
Some(root_uri_for_preferences.as_str()),
)?;
return Ok(aggregate);
}
let meta = load_document_meta_result(
@@ -1398,6 +1444,7 @@ pub(crate) fn escape_script_json(value: &str) -> String {
}
pub(crate) async fn load_workspace_shell_projection(
state: Option<&AppState>,
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
@@ -1412,7 +1459,7 @@ pub(crate) async fn load_workspace_shell_projection(
query: None,
max_results: None,
};
let dataset = match load_projection_snapshot(config, context, &spec).await {
let mut dataset = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => snapshot.dataset,
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
@@ -1444,6 +1491,9 @@ pub(crate) async fn load_workspace_shell_projection(
"degraded_reason": "projection_unavailable"
}),
};
if let Some(state) = state {
attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset);
}
build_workspace_shell_projection(
&dataset,
@@ -1453,6 +1503,25 @@ pub(crate) async fn load_workspace_shell_projection(
)
}
pub(crate) fn attach_sidebar_shortcuts_to_dataset(
state: &AppState,
context: &RequestContext,
workspace_id: &str,
dataset: &mut Value,
) {
let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset(
state,
context,
workspace_id,
);
if shortcuts.is_empty() {
return;
}
if let Some(object) = dataset.as_object_mut() {
object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts));
}
}
/// 加载侧栏页面树 HTMLSSR
///
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
@@ -1605,7 +1674,23 @@ pub(crate) fn render_local_file_tree_html(
active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?;
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
}
pub(crate) fn render_local_file_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
file_tree_scope: Option<&str>,
) -> Result<String, WebError> {
let snapshot = if let Some(scope) = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_file_tree_children_snapshot(root_uri, scope)?
} else {
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
let rows =
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
@@ -1613,14 +1698,23 @@ pub(crate) fn render_local_file_tree_html(
}))
}
#[cfg(test)]
fn block_local_page_aggregate_for_test() {
let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
if delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput};
use serde_json::Value;
use std::time::{Duration, Instant};
use tower::util::ServiceExt;
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
@@ -1917,6 +2011,12 @@ mod tests {
assert!(html.contains("data-mnote-resource-tab-host"));
assert!(runtime.contains("openPrimaryMindmap"));
assert!(runtime.contains("openResourceInActiveTab"));
assert!(html.contains(
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
));
assert!(html.contains(
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
));
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
@@ -2033,6 +2133,78 @@ mod tests {
);
}
#[tokio::test]
async fn mnote_browser_runtime_assets_are_cacheable() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("cache-control");
assert_ne!(cache_control, "no-store");
assert!(
cache_control.contains("max-age"),
"runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
);
}
#[tokio::test]
async fn leptos_tiptap_runtime_assets_are_cacheable() {
let manifest_response = app()
.clone()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/manifest.json")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_cache_control = manifest_response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("manifest cache-control");
assert_ne!(manifest_cache_control, "no-store");
assert!(
manifest_cache_control.contains("max-age"),
"leptos-tiptap manifest 应允许浏览器缓存,避免每次重新发现 runtime 入口"
);
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.expect("cache-control");
assert_ne!(cache_control, "no-store");
assert!(
cache_control.contains("max-age"),
"leptos-tiptap runtime 资产应允许浏览器缓存,避免远程高延迟时每次重拉"
);
}
#[tokio::test]
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
@@ -2243,6 +2415,190 @@ mod tests {
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
}
#[tokio::test]
async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-runtime-asset-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle");
std::fs::write(
root.join("Cold Start").join("Cold Start.md"),
"# Cold Start\n\nEditor cold start target.\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let app = app();
let aggregate_app = app.clone();
let aggregate_uri = format!(
"/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}"
);
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
.store(400, std::sync::atomic::Ordering::SeqCst);
struct ResetLocalAggregateBlock;
impl Drop for ResetLocalAggregateBlock {
fn drop(&mut self) {
super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
.store(0, std::sync::atomic::Ordering::SeqCst);
}
}
let _reset_block = ResetLocalAggregateBlock;
let aggregate_task = tokio::spawn(async move {
aggregate_app
.oneshot(
Request::builder()
.uri(aggregate_uri)
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("aggregate request"),
)
.await
.expect("aggregate response")
});
let started = Instant::now();
tokio::task::yield_now().await;
let asset_response = app
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("asset request"),
)
.await
.expect("asset response");
let asset_elapsed = started.elapsed();
let aggregate_response = aggregate_task.await.expect("aggregate task");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(asset_response.status(), StatusCode::OK);
assert_eq!(aggregate_response.status(), StatusCode::OK);
assert!(
asset_elapsed < Duration::from_millis(150),
"leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞,实际等待 {asset_elapsed:?}"
);
}
#[tokio::test]
async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-ui-pref-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(root.join("README.md"), "# Readme Heading\n正文\n").expect("write local md");
let root_uri = format!("file://{}", root.display());
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri)
.expect("workspace id");
state
.control_plane()
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "user_test".into(),
workspace_id: Some(workspace_id),
source_kind: Some("local_folder".into()),
scope_kind: "source_family".into(),
scope_id: "external_local_folder".into(),
key: "hideTitleHeader".into(),
value_json: "false".into(),
})
.expect("upsert title header preference");
let context = request_context("user_test", "user");
let aggregate = super::build_page_aggregate_snapshot(
&state,
&context,
"local-md:README.md",
None,
Some("local_folder"),
Some(&root_uri),
)
.await
.expect("local page aggregate");
let _ = std::fs::remove_dir_all(&root);
assert!(!aggregate.layout.page_options.hide_title_header);
}
#[tokio::test]
async fn ui_preferences_api_updates_and_returns_effective_page_options() {
let app = app();
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/ui/preferences")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"workspaceId": "ws_demo",
"sourceKind": "convex_workspace",
"documentId": "doc_1",
"updates": {
"showHeadingNumbers": true,
"layoutDensity": "compact"
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1")
.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");
assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true);
assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact");
assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global");
assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
}
#[tokio::test]
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
let root =
@@ -2469,6 +2825,7 @@ mod tests {
let filetree_html =
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
let workspace_projection = super::load_workspace_shell_projection(
None,
&config,
&context,
"ws_demo",
+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 {
@@ -14,6 +14,7 @@ pub struct FileTreeRenderRow {
pub icon_kind: String,
pub document_id: Option<String>,
pub asset_id: Option<String>,
pub relative_path: Option<String>,
pub object_identity: Option<String>,
pub selected: bool,
}
@@ -90,7 +91,7 @@ fn render_filetree_row(
};
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
@@ -101,6 +102,7 @@ fn render_filetree_row(
document_id = escape_html(command_document_id),
owner_document_id = escape_html(owner_document_id),
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()),
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
selected = row.selected,
toggle_html = toggle_html,
@@ -176,6 +178,7 @@ mod tests {
icon_kind: "page".into(),
document_id: Some("page_root".into()),
asset_id: None,
relative_path: None,
object_identity: Some(
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
),
@@ -193,6 +196,7 @@ mod tests {
icon_kind: "mindmap".into(),
document_id: Some("page_root".into()),
asset_id: Some("mind_1".into()),
relative_path: Some("assets/思维导图.json".into()),
object_identity: Some(
r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
),
@@ -210,6 +214,7 @@ mod tests {
assert!(html.contains("data-doc-id=\"page_root\""));
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
assert!(html.contains("data-asset-id=\"mind_1\""));
assert!(html.contains("data-local-relative-path=\"assets/思维导图.json\""));
assert!(html.contains("data-object-identity=\"{&quot;objectKind&quot;:&quot;mindmap&quot;"));
assert!(html.contains("tree-children"));
assert!(html.contains("首页 &lt;安全&gt;"));
@@ -234,6 +239,7 @@ mod tests {
icon_kind: "folder".into(),
document_id: None,
asset_id: None,
relative_path: Some("docs".into()),
object_identity: None,
selected: false,
},
@@ -249,6 +255,7 @@ mod tests {
icon_kind: "file".into(),
document_id: Some("local-md:docs~2FREADME.md".into()),
asset_id: None,
relative_path: Some("docs/README.md".into()),
object_identity: None,
selected: false,
},
@@ -256,8 +263,34 @@ mod tests {
});
assert!(html.contains("data-row-id=\"local:folder:docs\""));
assert!(html.contains("data-local-relative-path=\"docs\""));
assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\""));
assert!(!html.contains("README.md"));
assert!(!html.contains("tree-children--collapsed"));
}
#[test]
fn filetree_ssr_rows_include_local_relative_path() {
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
rows: vec![FileTreeRenderRow {
row_id: "local:folder:design/03-rust-web".into(),
row_kind: "folder".into(),
node_id: "local:node:design/03-rust-web".into(),
parent_node_id: None,
title: "03-rust-web".into(),
depth: 1,
expandable: true,
expanded: false,
icon_kind: "folder".into(),
document_id: None,
asset_id: None,
relative_path: Some("design/03-rust-web".into()),
object_identity: None,
selected: false,
}],
});
assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#));
assert!(html.contains(r#"<button type="button" class="tree-link""#));
}
}
@@ -133,6 +133,7 @@ mod tests {
icon_kind: "page".into(),
document_id: Some("page_root".into()),
asset_id: None,
relative_path: None,
object_identity: None,
selected: false,
},
@@ -148,6 +149,7 @@ mod tests {
icon_kind: "file".into(),
document_id: Some("page_root".into()),
asset_id: Some("asset_1".into()),
relative_path: Some("asset_1".into()),
object_identity: None,
selected: false,
},
+404 -13
View File
@@ -23,6 +23,14 @@ pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
pub shortcut_id: Option<String>,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub kind: Option<String>,
pub target_id: Option<String>,
pub relative_path: Option<String>,
pub document_id: Option<String>,
pub parent_id: Option<String>,
pub href: String,
pub depth: u32,
@@ -88,6 +96,21 @@ pub fn build_workspace_shell_projection(
})
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
.collect::<Vec<_>>();
let starred_page_ids = starred_items
.iter()
.map(|item| item.id.clone())
.collect::<std::collections::BTreeSet<_>>();
for shortcut in sidebar_shortcuts(dataset) {
if let Some(item) = shortcut_to_item(
shortcut,
workspace_id,
active_page_id.as_deref(),
&documents,
&starred_page_ids,
) {
starred_items.push(item);
}
}
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
@@ -214,6 +237,14 @@ fn document_to_item(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
shortcut_id: None,
workspace_id: Some(workspace_id.to_string()),
source_kind: None,
root_uri: None,
kind: Some("page".to_string()),
target_id: Some(id.to_string()),
relative_path: None,
document_id: Some(id.to_string()),
parent_id,
href: format!("/documents/{id}?workspaceId={workspace_id}"),
depth,
@@ -221,6 +252,207 @@ fn document_to_item(
})
}
fn sidebar_shortcuts(dataset: &Value) -> Vec<&Value> {
dataset
.get("sidebar_shortcuts")
.or_else(|| dataset.get("sidebarShortcuts"))
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn shortcut_to_item(
shortcut: &Value,
workspace_id: &str,
active_page_id: Option<&str>,
documents: &[Value],
starred_page_ids: &std::collections::BTreeSet<String>,
) -> Option<WorkspaceShellItem> {
let shortcut_workspace_id = shortcut
.get("workspace_id")
.or_else(|| shortcut.get("workspaceId"))
.and_then(Value::as_str)
.unwrap_or(workspace_id);
let source_kind = shortcut
.get("source_kind")
.or_else(|| shortcut.get("sourceKind"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("workspace");
if shortcut_workspace_id != workspace_id && source_kind != "local_folder" {
return None;
}
let kind = shortcut
.get("kind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let target_id = shortcut
.get("target_id")
.or_else(|| shortcut.get("targetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
if kind == "page" {
let document_id = shortcut
.get("document_id")
.or_else(|| shortcut.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(target_id);
if starred_page_ids.contains(document_id) {
return None;
}
let root_uri = shortcut_root_uri(shortcut);
if let Some(document) = documents.iter().find(|document| {
document.get("id").and_then(Value::as_str).map(str::trim) == Some(document_id)
}) {
let mut item = document_to_item(document, workspace_id, active_page_id)?;
let shortcut_id = shortcut
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(document_id)
.to_string();
item.id = shortcut_id.clone();
item.shortcut_id = Some(shortcut_id);
item.kind = Some("page".to_string());
item.workspace_id = Some(shortcut_workspace_id.to_string());
item.source_kind = Some(source_kind.to_string());
item.root_uri = root_uri.clone();
item.href = shortcut_document_href(
document_id,
shortcut_workspace_id,
source_kind,
root_uri.as_deref(),
);
item.target_id = Some(target_id.to_string());
item.document_id = Some(document_id.to_string());
return Some(item);
}
}
let title = shortcut
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
if kind == "folder" {
"文件夹"
} else {
"无标题"
}
});
let relative_path = shortcut
.get("relative_path")
.or_else(|| shortcut.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let document_id = shortcut
.get("document_id")
.or_else(|| shortcut.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let root_uri = shortcut_root_uri(shortcut);
let shortcut_id = shortcut
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(target_id)
.to_string();
Some(WorkspaceShellItem {
id: shortcut_id.clone(),
title: title.to_string(),
icon: shortcut
.get("icon")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| (kind == "folder").then(|| "folder_open".to_string())),
shortcut_id: Some(shortcut_id),
workspace_id: Some(shortcut_workspace_id.to_string()),
source_kind: Some(source_kind.to_string()),
root_uri: root_uri.clone(),
kind: Some(kind.to_string()),
target_id: Some(target_id.to_string()),
relative_path,
document_id: document_id.clone(),
parent_id: None,
href: document_id
.as_ref()
.map(|id| {
shortcut_document_href(id, shortcut_workspace_id, source_kind, root_uri.as_deref())
})
.unwrap_or_default(),
depth: 0,
active: kind == "page"
&& active_page_id.is_some_and(|active_id| document_id.as_deref() == Some(active_id)),
})
}
fn shortcut_root_uri(shortcut: &Value) -> Option<String> {
shortcut
.get("rootUri")
.or_else(|| shortcut.get("root_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
shortcut
.get("metadata")
.and_then(|metadata| metadata.get("rootUri").or_else(|| metadata.get("root_uri")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn shortcut_document_href(
document_id: &str,
workspace_id: &str,
source_kind: &str,
root_uri: Option<&str>,
) -> String {
let mut href = format!(
"/documents/{}?workspaceId={}",
document_id,
encode_query_component(workspace_id)
);
if !source_kind.trim().is_empty() && source_kind != "workspace" {
href.push_str("&sourceKind=");
href.push_str(&encode_query_component(source_kind));
}
if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
href.push_str("&rootUri=");
href.push_str(&encode_query_component(root_uri));
}
href
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::new();
for byte in value.as_bytes() {
match *byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(*byte as char)
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
for item in items {
item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
@@ -336,7 +568,7 @@ mod tests {
Some("doc_root"),
"开发用户 的工作区",
);
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
assert!(html.contains("data-node-id=\"doc_root\""));
@@ -346,12 +578,52 @@ mod tests {
assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
assert!(html.contains("data-mnote-action=\"create-page\""));
assert!(html.contains("data-testid=\"wolai-sidebar-create-folder\""));
assert!(html.contains("data-mnote-action=\"create-folder\""));
assert!(html.contains("wolai-section-add--folder"));
assert!(html.contains("data-icon=\"folder_open\""));
assert!(!html.contains(">create_new_folder</span>"));
assert!(html.contains("class=\"material-symbols-outlined"));
assert!(html.contains("data-icon=\"home\""));
assert!(html.contains("data-icon=\"star\""));
assert!(html.contains("data-icon=\"delete\""));
}
#[test]
fn workspace_shell_sidebar_html_renders_sqlite_folder_shortcut_attrs() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": [],
"sidebarShortcuts": [{
"id": "shortcut_design",
"workspaceId": "local-ws:demo",
"kind": "folder",
"sourceKind": "local_folder",
"targetId": "folder:design",
"relativePath": "design",
"title": "design",
"icon": "folder_open",
"metadata": {
"rootUri": "file:///tmp/mnote-demo"
}
}]
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
assert_eq!(projection.starred_items.len(), 1);
assert!(html.contains(r#"data-mnote-shortcut-kind="folder""#));
assert!(html.contains(r#"data-mnote-shortcut-id="shortcut_design""#));
assert!(html.contains(r#"data-workspace-id="local-ws:demo""#));
assert!(html.contains(r#"data-mnote-shortcut-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-shortcut-target-id="folder:design""#));
assert!(html.contains(r#"data-mnote-shortcut-relative-path="design""#));
assert!(html.contains(r#"data-mnote-shortcut-root-uri="file:///tmp/mnote-demo""#));
assert!(html.contains(r#"data-mnote-shortcut-action="menu""#));
assert!(html.contains(r#"role="button""#));
}
#[test]
fn workspace_shell_sidebar_html_uses_single_tabbed_tree_host() {
let dataset = json!({
@@ -371,6 +643,7 @@ mod tests {
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
None,
None,
);
assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#));
@@ -401,6 +674,7 @@ mod tests {
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
Some("filetree"),
None,
);
assert!(html.contains(
@@ -413,6 +687,26 @@ mod tests {
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
}
#[test]
fn workspace_shell_sidebar_html_marks_filetree_scope() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": []
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(
&projection,
None,
Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
Some("filetree"),
Some("design"),
);
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(html.contains(r#"data-mnote-filetree-scope="design""#));
}
#[test]
fn workspace_shell_sidebar_html_outputs_empty_state() {
let dataset = json!({
@@ -421,7 +715,7 @@ mod tests {
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(&projection, None, None, None);
let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
assert!(!html.contains("暂无页面"));
@@ -442,7 +736,7 @@ mod tests {
"开发用户 的工作区",
);
let degraded_html =
render_workspace_shell_sidebar_html(&degraded_projection, None, None, None);
render_workspace_shell_sidebar_html(&degraded_projection, None, None, None, None);
assert!(degraded_projection.degraded);
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
@@ -456,7 +750,7 @@ mod tests {
});
let dev_projection =
build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None);
let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None, None);
assert!(dev_projection.dev_fixture);
assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
@@ -469,6 +763,7 @@ pub fn render_workspace_shell_sidebar_html(
sidebar_tree_html: Option<&str>,
file_tree_html: Option<&str>,
initial_tree_mode: Option<&str>,
file_tree_scope: Option<&str>,
) -> String {
let starred_rows = if projection.starred_items.is_empty() {
String::new()
@@ -500,8 +795,18 @@ pub fn render_workspace_shell_sidebar_html(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
let file_tree_scope_attr = file_tree_scope
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
format!(
r#" data-mnote-filetree-scope="{}""#,
escape_html(value)
)
})
.unwrap_or_default();
format!(
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}">{html}</div></div>"#,
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
escape_html(&projection.workspace_id),
)
})
@@ -594,10 +899,11 @@ pub fn render_workspace_shell_sidebar_html(
);
format!(
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button><button type="button" class="wolai-section-add wolai-section-add--folder" data-testid="wolai-sidebar-create-folder" data-mnote-action="create-folder" data-workspace-id="{}" title="新建文件夹" aria-label="新建文件夹"><span class="material-symbols-outlined" data-icon="folder_open" aria-hidden="true"></span></button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
render_symbol("star", "wolai-section-icon"),
render_symbol("folder_open", "wolai-folder-icon"),
escape_html(&projection.workspace_id),
escape_html(&projection.workspace_id),
)
}
@@ -613,20 +919,105 @@ fn render_item_row(item: &WorkspaceShellItem) -> String {
.as_deref()
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let workspace_attr = item
.workspace_id
.as_deref()
.map(|workspace_id| format!(r#" data-workspace-id="{}""#, escape_html(workspace_id)))
.unwrap_or_default();
let shortcut_id_attr = item
.shortcut_id
.as_deref()
.map(|shortcut_id| format!(r#" data-mnote-shortcut-id="{}""#, escape_html(shortcut_id)))
.unwrap_or_default();
let source_kind_attr = item
.source_kind
.as_deref()
.map(|source_kind| {
format!(
r#" data-mnote-shortcut-source-kind="{}""#,
escape_html(source_kind)
)
})
.unwrap_or_default();
let root_uri_attr = item
.root_uri
.as_deref()
.map(|root_uri| {
format!(
r#" data-mnote-shortcut-root-uri="{}""#,
escape_html(root_uri)
)
})
.unwrap_or_default();
let aria_current = if item.active {
r#" aria-current="page""#
} else {
""
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{parent_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}><span class="wolai-row-caret" aria-hidden="true"></span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span></a>"#,
escape_html(&item.href),
escape_html(&item.id),
escape_html(&item.id),
item.depth,
item.active,
let kind_attr = item
.kind
.as_deref()
.map(|kind| format!(r#" data-mnote-shortcut-kind="{}""#, escape_html(kind)))
.unwrap_or_default();
let target_attr = item
.target_id
.as_deref()
.map(|target_id| {
format!(
r#" data-mnote-shortcut-target-id="{}""#,
escape_html(target_id)
)
})
.unwrap_or_default();
let relative_attr = item
.relative_path
.as_deref()
.map(|relative_path| {
format!(
r#" data-mnote-shortcut-relative-path="{}""#,
escape_html(relative_path)
)
})
.unwrap_or_default();
let document_attr = item
.document_id
.as_deref()
.map(|document_id| {
format!(
r#" data-mnote-shortcut-document-id="{}""#,
escape_html(document_id)
)
})
.unwrap_or_default();
let row_body = format!(
r#"<span class="wolai-row-caret" aria-hidden="true"></span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span>"#,
render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
escape_html(&item.title),
);
let shortcut_action = item
.shortcut_id
.as_deref()
.map(|_| {
r#"<button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>"#
.to_string()
})
.unwrap_or_default();
if item.href.trim().is_empty() {
return format!(
r#"<div class="wolai-page-row{active_class}" role="button" tabindex="0" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</div>"#,
escape_html(&item.id),
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
item.depth,
item.active,
);
}
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</a>"#,
escape_html(&item.href),
escape_html(&item.id),
escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
item.depth,
item.active,
)
}