收口本地工作区清理与资源投影
清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。 新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。 扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。 验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
This commit is contained in:
@@ -213,7 +213,7 @@ pub async fn document_page_shell(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
|
||||
<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="{}">
|
||||
{}
|
||||
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
@@ -228,6 +228,8 @@ pub async fn document_page_shell(
|
||||
escape_html(title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&document_id),
|
||||
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
|
||||
escape_html(primary_root_uri.unwrap_or("")),
|
||||
secondary_requested,
|
||||
secondary_invalid,
|
||||
body_content,
|
||||
@@ -557,11 +559,24 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
|
||||
}
|
||||
writeLastSavedTitle(title);
|
||||
updateVisibleTitle(input, title, currentTarget.documentId);
|
||||
const result = payload?.result || {};
|
||||
const nextDocumentId = String(result.documentId || result.id || currentTarget.documentId || '').trim();
|
||||
const previousDocumentId = currentTarget.documentId;
|
||||
const nextTitle = String(result.title || title || '无标题').trim() || '无标题';
|
||||
if (nextDocumentId) {
|
||||
input.setAttribute('data-document-id', nextDocumentId);
|
||||
}
|
||||
writeLastSavedTitle(nextTitle);
|
||||
updateVisibleTitle(input, nextTitle, nextDocumentId || currentTarget.documentId);
|
||||
setStatus(input, 'saved');
|
||||
window.dispatchEvent(new CustomEvent('tree:title-updated', {
|
||||
detail: { documentId: currentTarget.documentId, workspaceId: currentTarget.workspaceId || null, title, payload },
|
||||
detail: {
|
||||
documentId: nextDocumentId || currentTarget.documentId,
|
||||
previousDocumentId,
|
||||
workspaceId: currentTarget.workspaceId || null,
|
||||
title: nextTitle,
|
||||
payload,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setStatus(input, 'error', error instanceof Error ? error.message : String(error));
|
||||
@@ -896,6 +911,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const mindmapId = firstNonEmptyText(
|
||||
block?.props?.mindmapId,
|
||||
block?.props?.mindmap_id,
|
||||
block?.props?.sourcePath,
|
||||
block?.props?.source_path,
|
||||
block?.mindmapId,
|
||||
block?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
@@ -1023,10 +1040,84 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return textToTiptapDocument(fallbackText);
|
||||
};
|
||||
|
||||
const pageBodyTiptapDocument = (body, fallbackText = '') => {
|
||||
const decodeLocalIdSegment = (segment) => {
|
||||
const encoded = String(segment || '').replace(/~([0-9a-fA-F]{2})/g, '%$1');
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
} catch (_) {
|
||||
return String(segment || '').replace(/~2F/g, '/').replace(/~20/g, ' ');
|
||||
}
|
||||
};
|
||||
|
||||
const localMarkdownRelativePathFromDocumentId = (documentId) => {
|
||||
const raw = String(documentId || '').trim();
|
||||
const segment = raw.startsWith('local-md:') ? raw.slice('local-md:'.length) : raw;
|
||||
return decodeLocalIdSegment(segment).replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
const localMarkdownDirectoryFromDocumentId = (documentId) => {
|
||||
const relativePath = localMarkdownRelativePathFromDocumentId(documentId);
|
||||
const slash = relativePath.lastIndexOf('/');
|
||||
return slash >= 0 ? relativePath.slice(0, slash) : '';
|
||||
};
|
||||
|
||||
const isExternalOrSpecialUrl = (value) => {
|
||||
const text = String(value || '').trim();
|
||||
return !text
|
||||
|| text.startsWith('#')
|
||||
|| text.startsWith('data:')
|
||||
|| text.startsWith('blob:')
|
||||
|| text.startsWith('mailto:')
|
||||
|| text.startsWith('http://')
|
||||
|| text.startsWith('https://')
|
||||
|| text.startsWith('/api/');
|
||||
};
|
||||
|
||||
const normalizeLocalAssetRelativePath = (value, context) => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || isExternalOrSpecialUrl(text)) return text;
|
||||
if (text.startsWith('/')) return text.replace(/^\/+/, '');
|
||||
const baseDir = localMarkdownDirectoryFromDocumentId(context?.documentId);
|
||||
return (baseDir ? `${baseDir}/${text}` : text)
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.')
|
||||
.join('/');
|
||||
};
|
||||
|
||||
const localFileOpenUrlForTiptap = (value, context) => {
|
||||
if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value;
|
||||
const relativePath = normalizeLocalAssetRelativePath(value, context);
|
||||
if (!relativePath || isExternalOrSpecialUrl(relativePath)) return value;
|
||||
const url = new URL('/api/local-folder/files/open', window.location.origin);
|
||||
url.searchParams.set('rootUri', context.rootUri);
|
||||
url.searchParams.set('path', relativePath);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const localizeTiptapAssetUrls = (node, context) => {
|
||||
if (!node || typeof node !== 'object') return node;
|
||||
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
|
||||
node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) };
|
||||
}
|
||||
if (Array.isArray(node.marks)) {
|
||||
node.marks = node.marks.map((mark) => {
|
||||
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
|
||||
return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } };
|
||||
});
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context));
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context);
|
||||
}
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return toTiptapDocument(blockDocument, fallbackText);
|
||||
return toTiptapDocument(body?.content, fallbackText);
|
||||
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context);
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context);
|
||||
};
|
||||
|
||||
const inlineTextNodes = (node) => {
|
||||
@@ -1089,6 +1180,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const mindmapId = firstNonEmptyText(
|
||||
attrs?.mindmapId,
|
||||
attrs?.mindmap_id,
|
||||
attrs?.sourcePath,
|
||||
attrs?.source_path,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id,
|
||||
@@ -1106,10 +1199,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const tiptapNodeToEditorBlock = (node, index) => {
|
||||
const blockId = blockIdOf(node, index);
|
||||
if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') {
|
||||
const mindmapId = firstNonEmptyText(
|
||||
node?.attrs?.mindmapId,
|
||||
node?.attrs?.mindmap_id,
|
||||
node?.attrs?.sourcePath,
|
||||
node?.attrs?.source_path,
|
||||
blockId
|
||||
);
|
||||
return {
|
||||
blockId,
|
||||
blockType: 'mindmap',
|
||||
props: mindmapPropsFromAttrs(node?.attrs, blockId),
|
||||
props: {
|
||||
...mindmapPropsFromAttrs(node?.attrs, blockId),
|
||||
sourcePath: mindmapId,
|
||||
},
|
||||
contentNodes: [],
|
||||
childBlockIds: [],
|
||||
};
|
||||
@@ -1577,6 +1680,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
|
||||
if (!session || session.sourceKind !== 'local_folder') return false;
|
||||
if (!sessionHasRecentExternalSignal(session)) return false;
|
||||
if (!sessionPlainText(session)) return false;
|
||||
return !aggregatePlainText(nextAggregate);
|
||||
};
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const conflictEnvelopeFromResponse = (payload) => (
|
||||
payload?.error?.details?.conflict
|
||||
|| payload?.details?.conflict
|
||||
@@ -1610,7 +1722,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
@@ -1937,6 +2049,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const scheduleSessionExternalRefresh = (session, source) => {
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.externalRefreshTimer) return;
|
||||
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
|
||||
session.externalRefreshTimer = window.setTimeout(() => {
|
||||
@@ -1949,6 +2062,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const refreshSessionFromExternalChange = async (session, source) => {
|
||||
if (document.hidden) return;
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
|
||||
try {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
@@ -1973,11 +2087,26 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
|
||||
return;
|
||||
}
|
||||
const nextAggregate = payload?.result;
|
||||
let nextAggregate = payload?.result;
|
||||
if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) {
|
||||
await delay(500);
|
||||
try {
|
||||
const retryAggregate = await fetchLatestSessionAggregate(session);
|
||||
if (aggregatePlainText(retryAggregate)) {
|
||||
nextAggregate = retryAggregate;
|
||||
} else {
|
||||
markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。');
|
||||
return;
|
||||
}
|
||||
} catch (_retryError) {
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
@@ -2036,11 +2165,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const payload = parseLocalFolderEventPayload(event);
|
||||
if (!payload) return;
|
||||
Array.from(channel.sessions.values()).forEach((targetSession) => {
|
||||
if (!targetSession || targetSession.views.size === 0) return;
|
||||
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
|
||||
const eventKind = String(payload.eventKind || '');
|
||||
const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name');
|
||||
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
|
||||
if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return;
|
||||
if (targetsCurrentDocument && targetSession.saving) {
|
||||
targetSession.externalChangePending = false;
|
||||
targetSession.lastSelfSaveSignalAt = Date.now();
|
||||
return;
|
||||
}
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
|
||||
@@ -2256,8 +2391,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
|
||||
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
|
||||
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody);
|
||||
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
|
||||
const session = {
|
||||
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
|
||||
documentId: runtimeDescriptor.bootstrap.documentId,
|
||||
@@ -2282,6 +2417,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
externalChangePending: false,
|
||||
externalRefreshSource: '',
|
||||
lastExternalChangeSignalAt: 0,
|
||||
lastSelfSaveSignalAt: 0,
|
||||
lastUserInputAt: 0,
|
||||
status: 'booting',
|
||||
error: null,
|
||||
@@ -3498,10 +3634,16 @@ mod tests {
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||
assert!(html.contains("const pageBodyTiptapDocument = (body, fallbackText = '') => {"));
|
||||
assert!(html.contains(
|
||||
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
|
||||
));
|
||||
assert!(html.contains("body?.blockDocument || body?.block_document"));
|
||||
assert!(html.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody);"));
|
||||
assert!(html.contains("const tiptapDocument = pageBodyTiptapDocument(pageBody);"));
|
||||
assert!(html.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content"));
|
||||
assert!(html
|
||||
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
|
||||
assert!(html.contains(
|
||||
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
|
||||
));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
@@ -3655,10 +3797,10 @@ mod tests {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
|
||||
root.join("Local Aggregate").join("Local Aggregate.md"),
|
||||
"# Local Heading\n正文内容\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
@@ -3668,7 +3810,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
@@ -3695,7 +3837,7 @@ mod tests {
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(
|
||||
payload["result"]["identity"]["documentId"],
|
||||
"local-md:README.md"
|
||||
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
|
||||
);
|
||||
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
||||
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
||||
@@ -3710,10 +3852,19 @@ mod tests {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
|
||||
.expect("write child md");
|
||||
std::fs::create_dir_all(root.join("Local Shell")).expect("create local page bundle");
|
||||
std::fs::create_dir_all(root.join("docs").join("Child Page"))
|
||||
.expect("create local child bundle");
|
||||
std::fs::write(
|
||||
root.join("Local Shell").join("Local Shell.md"),
|
||||
"# Local Shell\n正文\n",
|
||||
)
|
||||
.expect("write root md");
|
||||
std::fs::write(
|
||||
root.join("docs").join("Child Page").join("Child Page.md"),
|
||||
"# Child Page\n",
|
||||
)
|
||||
.expect("write child md");
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
@@ -3722,7 +3873,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
@@ -3771,6 +3922,10 @@ mod tests {
|
||||
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
|
||||
assert!(html.contains("mayAffectMissingDocument"));
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("targetSession.views.size === 0"));
|
||||
assert!(html.contains("session.views.size === 0"));
|
||||
assert!(html.contains("targetSession.saving"));
|
||||
assert!(html.contains("lastSelfSaveSignalAt"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
assert!(html.contains("mnote-editor-conflict-panel"));
|
||||
@@ -3875,6 +4030,8 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Spec"));
|
||||
assert!(html.contains("assets/spec.pdf"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user