diff --git a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md index 9d073dd4..d1680a3e 100644 --- a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md +++ b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md @@ -370,7 +370,7 @@ - [x] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。 -### 11.9 清理与迁移收尾 +### 11.9 清理与迁移收尾11 - [ ] 把手写 parser 标记为过渡实现。11 diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index ae64ffe8..466c7274 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -76,40 +76,40 @@ pub async fn document_page_shell( .await?; let title = aggregate.head.title.as_str(); let workspace_id = aggregate.identity.workspace_id.clone(); - let requested_secondary_document_id = normalize_optional_owned(query.secondary_document_id.as_deref()); + let requested_secondary_document_id = + normalize_optional_owned(query.secondary_document_id.as_deref()); let secondary_source_kind = normalize_source_kind( - query.secondary_source_kind + query + .secondary_source_kind .as_deref() .or(primary_source_kind), ); - let secondary_root_uri = normalize_optional_query_value( - query.secondary_root_uri - .as_deref() - .or(primary_root_uri), - ); + let secondary_root_uri = + normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri)); let mut secondary_requested = false; let mut secondary_invalid = false; - let secondary_aggregate = if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() { - secondary_requested = true; - match build_page_aggregate_snapshot( - &state, - &context, - secondary_document_id, - query.workspace_id.as_deref(), - secondary_source_kind, - secondary_root_uri, - ) - .await - { - Ok(aggregate) => Some(aggregate), - Err(_) => { - secondary_invalid = true; - None + let secondary_aggregate = + if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() { + secondary_requested = true; + match build_page_aggregate_snapshot( + &state, + &context, + secondary_document_id, + query.workspace_id.as_deref(), + secondary_source_kind, + secondary_root_uri, + ) + .await + { + Ok(aggregate) => Some(aggregate), + Err(_) => { + secondary_invalid = true; + None + } } - } - } else { - None - }; + } else { + None + }; let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let mut workspace_projection = load_workspace_shell_projection( state.config(), @@ -153,18 +153,14 @@ pub async fn document_page_shell( let page_options_json = serde_json::to_string(&aggregate.layout.page_options) .unwrap_or_else(|_| "null".to_string()); let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string()); - let bootstrap_json = build_editor_bootstrap_json( - &aggregate, - &context, - primary_source_kind, - primary_root_uri, - ); - let secondary_page_subtree_json = secondary_aggregate - .as_ref() - .map(|aggregate| serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string())); - let secondary_page_options_json = secondary_aggregate - .as_ref() - .map(|aggregate| serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string())); + let bootstrap_json = + build_editor_bootstrap_json(&aggregate, &context, primary_source_kind, primary_root_uri); + let secondary_page_subtree_json = secondary_aggregate.as_ref().map(|aggregate| { + serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string()) + }); + let secondary_page_options_json = secondary_aggregate.as_ref().map(|aggregate| { + serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string()) + }); let secondary_snapshot_json = secondary_aggregate .as_ref() .map(|aggregate| serde_json::to_string(aggregate).unwrap_or_else(|_| "null".to_string())); @@ -565,6 +561,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const replaceUrlState = (url) => { window.history.replaceState({}, '', url.pathname + url.search + url.hash); }; + const pushUrlState = (url) => { + window.history.pushState({}, '', url.pathname + url.search + url.hash); + }; const clearSecondaryParams = () => { const url = currentUrl(); secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); @@ -584,6 +583,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam); if (primaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, primaryRootUri); else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam); + if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') { + void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({ + documentId, + sourceKind: primarySourceKind || null, + rootUri: primaryRootUri || null, + url, + }); + return; + } window.location.assign(url.pathname + url.search + url.hash); }); @@ -592,6 +600,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { event.preventDefault(); const url = currentUrl(); secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); + if (typeof window.__mnoteDocumentPaneRuntime?.closeSecondaryDocument === 'function') { + window.__mnoteDocumentPaneRuntime.closeSecondaryDocument({ url }); + return; + } window.location.assign(url.pathname + url.search + url.hash); }); }); @@ -968,9 +980,29 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri); return url; }; + const pageAggregateUrlFromDescriptor = (descriptor) => { + const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); + url.searchParams.set('sourceKind', descriptor.sourceKind || 'convex_workspace'); + if (descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); + if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); + return url; + }; + const buildBootstrapFromAggregate = (aggregate, descriptor, paneRole) => ({ + schema: 'mnote.editor_bootstrap.v1', + documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId, + workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '', + paneRole, + sourceKind: descriptor.sourceKind || 'convex_workspace', + rootUri: descriptor.rootUri || '', + pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__', + saveEndpoint: '/api/documents/save', + titleEndpoint: '/api/documents/title', + editorHostKind: 'leptos_tiptap_island', + }); const documentSessionRegistry = new Map(); const localFolderEventRegistry = new Map(); + const paneViewRegistry = new Map(); let nextViewId = 1; const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; const SESSION_RELEASE_DELAY_MS = 1200; @@ -1452,6 +1484,174 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return session; }; + const updatePaneChrome = (runtimeDescriptor) => { + const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); + if (!(pane instanceof HTMLElement)) return; + const aggregate = runtimeDescriptor.aggregate || {}; + const bootstrap = runtimeDescriptor.bootstrap || {}; + const title = aggregate?.head?.title || '无标题'; + const documentId = bootstrap.documentId || aggregate?.identity?.documentId || ''; + const workspaceId = bootstrap.workspaceId || aggregate?.identity?.workspaceId || ''; + pane.setAttribute('data-pane-document-id', documentId); + pane.setAttribute('data-pane-workspace-id', workspaceId); + pane.setAttribute('data-pane-visible', 'true'); + pane.hidden = false; + const shell = pane.querySelector('.document-shell'); + if (shell instanceof HTMLElement) { + shell.setAttribute('data-document-id', documentId); + shell.setAttribute('data-workspace-id', workspaceId); + const options = aggregate?.layout?.pageOptions || {}; + shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout))); + shell.setAttribute('data-page-small-text', String(Boolean(options.smallText))); + shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal')); + shell.setAttribute('data-page-font', String(options.pageFont || 'default')); + shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers))); + } + pane.querySelectorAll('[data-page-title-input="true"]').forEach((node) => { + if (!(node instanceof HTMLTextAreaElement)) return; + node.value = title; + node.setAttribute('data-document-id', documentId); + node.setAttribute('data-workspace-id', workspaceId); + node.setAttribute('data-title-save-status', 'saved'); + node.style.height = 'auto'; + node.style.height = `${Math.max(48, node.scrollHeight)}px`; + }); + pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => { + if (node instanceof HTMLElement) node.textContent = title; + }); + if (runtimeDescriptor.paneRole === 'primary') { + document.body.dataset.documentId = documentId; + document.title = title; + const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); + if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { + if (row instanceof HTMLElement) { + row.setAttribute('data-active', 'false'); + row.setAttribute('data-selected', 'false'); + } + }); + const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&'); + document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => { + if (!(row instanceof HTMLElement)) return; + if (row.getAttribute('data-shell-mode') === 'filetree') { + row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `index:${documentId}`)); + } else { + row.setAttribute('data-active', 'true'); + } + }); + } + }; + + const fetchPageAggregateForPane = async (descriptor) => { + const response = await fetch(pageAggregateUrlFromDescriptor(descriptor).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`page_aggregate_failed_${response.status}`); + const payload = await response.json(); + if (!payload?.result) throw new Error('page_aggregate_missing_result'); + return payload.result; + }; + + const replacePaneDocument = async (paneRole, descriptor, options = {}) => { + 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 previousView = paneViewRegistry.get(paneRole); + if (previousView) { + unmountEditorViewBinding(previousView); + paneViewRegistry.delete(paneRole); + } + const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor); + const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole); + const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap }; + updatePaneChrome(runtimeDescriptor); + if (paneRole === 'secondary' && workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'true'); + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; + applyStoredSecondaryWidth(); + } + const session = getOrCreateDocumentSession(runtimeDescriptor); + const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + const mountOptions = { + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {}, + }; + setStatus(runtimeDescriptor, 'loading-assets'); + clearEmbeddedLocalDraft(runtimeDescriptor); + try { + const mountId = runtime.mount(runtimeDescriptor.root, mountOptions); + view.mountId = mountId; + runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); + runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); + setStatus(runtimeDescriptor, 'mounting-editor'); + paneViewRegistry.set(paneRole, view); + return view; + } catch (error) { + unmountEditorViewBinding(view); + throw error; + } + }; + + const descriptorFromCurrentUrl = (paneRole, documentId, explicit = {}) => { + const url = currentUrl(); + if (paneRole === 'secondary') { + return { + documentId, + workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', + sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace', + rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '', + }; + } + return { + documentId, + workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', + sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace', + rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '', + }; + }; + + const updatePrimaryUrl = (descriptor, urlFromCaller) => { + const url = urlFromCaller instanceof URL + ? urlFromCaller + : new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); + if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); + if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind); + if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); + pushUrlState(url); + }; + + const closeSecondaryPane = (url) => { + const view = paneViewRegistry.get('secondary'); + if (view) { + unmountEditorViewBinding(view); + paneViewRegistry.delete('secondary'); + } + const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); + if (pane instanceof HTMLElement) { + pane.hidden = true; + pane.setAttribute('data-pane-visible', 'false'); + } + const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`); + if (root instanceof HTMLElement) root.replaceChildren(); + if (workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'false'); + workspace.style.removeProperty('grid-template-columns'); + } + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = true; + replaceUrlState(url); + }; + const handleSessionChange = (session, view, event) => { const payload = normalizeEnvelopePayload(event); if (!payload) return; @@ -1629,12 +1829,36 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); setStatus(runtimeDescriptor, 'mounting-editor'); + paneViewRegistry.set(runtimeDescriptor.paneRole, view); } catch (error) { unmountEditorViewBinding(view); throw error; } }; + window.__mnoteDocumentPaneRuntime = { + openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { + const id = typeof documentId === 'string' ? documentId.trim() : ''; + if (!id) return false; + const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); + await replacePaneDocument('primary', descriptor); + updatePrimaryUrl(descriptor, url instanceof URL ? url : null); + return true; + }, + openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { + const id = typeof documentId === 'string' ? documentId.trim() : ''; + if (!id) return false; + const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri }); + await replacePaneDocument('secondary', descriptor); + if (url instanceof URL) replaceUrlState(url); + return true; + }, + closeSecondaryDocument: ({ url } = {}) => { + closeSecondaryPane(url instanceof URL ? url : currentUrl()); + return true; + }, + }; + window.addEventListener('pagehide', () => { Array.from(documentSessionRegistry.values()).forEach((session) => { sessionViews(session).forEach((view) => { @@ -2377,7 +2601,9 @@ mod tests { assert!(html.contains("localFolderEventRegistry")); assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("external-change-conflict")); - assert!(!html.contains("setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);")); + assert!(!html.contains( + "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" + )); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index afaebf98..ba71dd39 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -633,6 +633,28 @@ const SIDEBAR_TREE_JS: &str = r##" copyWorkspaceSourceParams(targetUrl); var url = targetUrl.pathname + targetUrl.search; if (mnoteNavigationInFlight === url) return; + if (typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === 'function') { + mnoteNavigationInFlight = url; + document.documentElement.setAttribute('data-mnote-navigation-pending', 'true'); + document.documentElement.setAttribute('data-mnote-navigation-target', nodeId); + window.__mnoteDocumentPaneRuntime.openPrimaryDocument({ + documentId: nodeId, + workspaceId: workspaceId || '', + sourceKind: targetUrl.searchParams.get('sourceKind') || 'convex_workspace', + rootUri: targetUrl.searchParams.get('rootUri') || '', + url: targetUrl, + }).then(function(){ + if (mnoteNavigationInFlight === url) mnoteNavigationInFlight = ''; + document.documentElement.removeAttribute('data-mnote-navigation-pending'); + }).catch(function(error){ + console.warn('mnote pane 内导航失败,将回退整页导航', error); + if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') { + window.__mnoteTreeLiveEventSource.close(); + } + window.location.assign(url); + }); + return; + } mnoteNavigationInFlight = url; document.documentElement.setAttribute('data-mnote-navigation-pending', 'true'); document.documentElement.setAttribute('data-mnote-navigation-target', nodeId); diff --git a/scripts/task165-rust-web-dual-pane-smoke.js b/scripts/task165-rust-web-dual-pane-smoke.js index 2f306212..7971d137 100644 --- a/scripts/task165-rust-web-dual-pane-smoke.js +++ b/scripts/task165-rust-web-dual-pane-smoke.js @@ -401,6 +401,25 @@ async function readDocumentSessionSnapshot(page) { }); } +async function readNoReloadProbe(page) { + return await page.evaluate(({ primaryRootSelector, secondaryRootSelector }) => ({ + pagehideCount: Number(window.sessionStorage?.getItem("__mnoteSmokePagehideCount") || "0"), + primaryMountId: document.querySelector(primaryRootSelector)?.getAttribute("data-runtime-mount-id") || "", + secondaryMountId: document.querySelector(secondaryRootSelector)?.getAttribute("data-runtime-mount-id") || "", + }), { + primaryRootSelector: paneRootSelector("primary"), + secondaryRootSelector: paneRootSelector("secondary"), + }); +} + +async function waitForDocumentPath(page, documentId) { + await page.waitForFunction( + (expectedDocumentId) => decodeURIComponent(window.location.pathname).endsWith(`/documents/${expectedDocumentId}`), + documentId, + { timeout: UI_TIMEOUT_MS }, + ); +} + function countRequests(requests, startIndex, predicate) { return requests.slice(startIndex).filter(predicate).length; } @@ -462,15 +481,24 @@ async function runFixturePhase(page, baseUrl, requests) { assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view"); const navigationIndex = requests.length; + const beforeFixtureNavigation = await readNoReloadProbe(page); + assert(beforeFixtureNavigation.secondaryMountId, "fixture 导航前应已挂载 secondary editor"); await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith("/documents/doc_other"), { - timeout: UI_TIMEOUT_MS, - waitUntil: "domcontentloaded", - }); + await waitForDocumentPath(page, "doc_other"); await waitForDualPaneReady(page); - await waitForRequestCount(requests, navigationIndex, isTreeEventRequest, 1, "sidebar 导航后的 tree EventSource 建连"); await page.waitForTimeout(800); - assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 1, "sidebar 导航后当前页面仍应只建立一条 tree EventSource"); + const afterFixtureNavigation = await readNoReloadProbe(page); + assert.equal( + afterFixtureNavigation.pagehideCount, + beforeFixtureNavigation.pagehideCount, + "fixture sidebar 导航不应触发整页 pagehide", + ); + assert.equal( + afterFixtureNavigation.secondaryMountId, + beforeFixtureNavigation.secondaryMountId, + "fixture sidebar 导航不应重挂 secondary editor", + ); + assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 0, "fixture sidebar 导航不应重建 tree EventSource"); const navigatedFixtureUrl = new URL(page.url()); assert.equal( navigatedFixtureUrl.searchParams.get("secondaryDocumentId"), @@ -496,10 +524,7 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) { "不同文档双开时 session 应分别归属到两个 documentId", ); await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith(`/documents/${fixture.thirdDocumentId}`), { - timeout: UI_TIMEOUT_MS, - waitUntil: "domcontentloaded", - }); + await waitForDocumentPath(page, fixture.thirdDocumentId); await waitForDualPaneReady(page); const navigatedUrl = new URL(page.url()); assert.equal( @@ -581,12 +606,12 @@ async function runLocalFolderPhase(page, baseUrl, requests, fixture) { const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8"); assert(savedMarkdown.includes(externalText), "外部写盘未落到本地 Markdown 文件"); + const closeBefore = await readNoReloadProbe(page); await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((nextUrl) => !nextUrl.searchParams.has("secondaryDocumentId"), { - timeout: UI_TIMEOUT_MS, - waitUntil: "commit", - }); + await page.waitForFunction(() => !new URL(window.location.href).searchParams.has("secondaryDocumentId"), {}, { timeout: UI_TIMEOUT_MS }); await waitForSinglePaneReady(page); + const closeAfter = await readNoReloadProbe(page); + assert.equal(closeAfter.pagehideCount, closeBefore.pagehideCount, "关闭 secondary 不应触发整页 pagehide"); await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForSinglePaneReady(page); const finalUrl = new URL(page.url()); @@ -631,6 +656,21 @@ async function runSinglePaneTitlePhase(page, baseUrl, fixture) { assert.equal(afterClick.inputValue.trim(), expectedTitle, "点击标题后输入框标题不应闪成“无标题”"); assert.equal(afterClick.metaTitle.trim(), expectedTitle, "点击标题后页头标题不应闪成“无标题”"); assert.equal(afterClick.breadcrumbTitle.trim(), expectedTitle, "点击标题后 breadcrumb 标题不应闪成“无标题”"); + + const beforeOpenRight = await readNoReloadProbe(page); + assert(beforeOpenRight.primaryMountId, "打开 secondary 前应已挂载 primary editor"); + await page.evaluate((documentId) => { + window.dispatchEvent(new CustomEvent("tree.page.open-right", { detail: { documentId } })); + }, fixture.sideDocumentId); + await page.waitForFunction(() => new URL(window.location.href).searchParams.has("secondaryDocumentId"), {}, { timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + const afterOpenRight = await readNoReloadProbe(page); + assert.equal(afterOpenRight.pagehideCount, beforeOpenRight.pagehideCount, "打开 secondary 不应触发整页 pagehide"); + assert.equal(afterOpenRight.primaryMountId, beforeOpenRight.primaryMountId, "打开 secondary 不应重挂 primary editor"); + const openRightUrl = new URL(page.url()); + assert.equal(openRightUrl.searchParams.get("secondaryDocumentId"), fixture.sideDocumentId, "打开 secondary 后 URL 应写入 secondaryDocumentId"); + assert.equal(openRightUrl.searchParams.get("secondarySourceKind"), "local_folder", "打开 secondary 后 URL 应保留 secondarySourceKind"); + assert.equal(openRightUrl.searchParams.get("secondaryRootUri"), fixture.rootUri, "打开 secondary 后 URL 应保留 secondaryRootUri"); } async function main() { @@ -655,6 +695,13 @@ async function main() { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); + await context.addInitScript(() => { + const key = "__mnoteSmokePagehideCount"; + window.addEventListener("pagehide", () => { + const current = Number(window.sessionStorage?.getItem(key) || "0"); + window.sessionStorage?.setItem(key, String(current + 1)); + }); + }); const page = await context.newPage(); page.on("request", (request) => {