Files
mnote/rust/crates/mnote-web/src/routes/web_shell.rs
T

2597 lines
107 KiB
Rust
Raw Normal View History

2026-04-29 12:24:44 +08:00
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::document_buffer_store::{self};
2026-04-29 12:24:44 +08:00
use crate::error::WebError;
use crate::page_aggregate::PageAggregate;
use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::gateway::default_workspace_name_for_context;
2026-05-08 00:41:03 +08:00
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot,
2026-05-08 00:41:03 +08:00
resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
2026-04-29 12:24:44 +08:00
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
2026-04-29 14:36:24 +08:00
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput,
2026-04-29 12:24:44 +08:00
};
2026-04-29 14:36:24 +08:00
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
WorkspaceShellProjection,
};
use axum::body::Body;
2026-04-29 12:24:44 +08:00
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
2026-04-29 12:24:44 +08:00
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::json;
use std::fs;
2026-04-29 14:36:24 +08:00
use std::path::{Component, Path as FsPath, PathBuf};
2026-04-29 12:24:44 +08:00
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";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
pub workspace_id: Option<String>,
2026-05-08 00:41:03 +08:00
pub source_kind: Option<String>,
pub root_uri: Option<String>,
2026-05-26 14:34:47 +08:00
pub tree_view: Option<String>,
pub secondary_document_id: Option<String>,
pub secondary_source_kind: Option<String>,
pub secondary_root_uri: Option<String>,
2026-04-29 12:24:44 +08:00
}
pub async fn document_page_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
let primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref());
2026-04-29 12:24:44 +08:00
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
primary_source_kind,
primary_root_uri,
2026-04-29 12:24:44 +08:00
)
.await?;
// 初始化 BufferStorelocal_folder 文档打开时记录 file_version
if primary_source_kind == Some("local_folder") {
if let Some(root_uri) = primary_root_uri {
let relative_path = document_id
.strip_prefix("local-md:")
.unwrap_or("")
.replace("~2F", "/");
let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string());
let ws_path = document_buffer_store::build_local_folder_workspace_path(
&aggregate.identity.workspace_id,
root_uri,
&relative_path,
&document_id,
);
state.buffer_store.init_buffer(&ws_path, file_version, None);
}
}
let title = aggregate.head.title.as_str();
2026-04-29 14:36:24 +08:00
let workspace_id = aggregate.identity.workspace_id.clone();
2026-05-09 06:24:50 +08:00
let requested_secondary_document_id =
normalize_optional_owned(query.secondary_document_id.as_deref());
let secondary_source_kind = normalize_source_kind(
2026-05-09 06:24:50 +08:00
query
.secondary_source_kind
.as_deref()
.or(primary_source_kind),
);
2026-05-09 06:24:50 +08:00
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;
2026-05-09 06:24:50 +08:00
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
}
}
2026-05-09 06:24:50 +08:00
} else {
None
};
let default_workspace_name = default_workspace_name_for_context(&state, &context);
2026-04-29 14:36:24 +08:00
let mut workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
&workspace_id,
Some(&document_id),
&default_workspace_name,
)
.await;
apply_active_page(&mut workspace_projection, Some(&document_id));
2026-05-08 00:41:03 +08:00
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
2026-05-26 14:34:47 +08:00
let requests_filetree_first = query
.tree_view
.as_deref()
.map(str::trim)
.is_some_and(|value| value == "filetree");
2026-05-08 00:41:03 +08:00
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(),
2026-05-08 00:41:03 +08:00
)
} else {
(
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default(),
load_file_tree_html(
state.config(),
&context,
&workspace_id,
Some(&document_id),
None,
)
.await
.unwrap_or_default(),
2026-05-08 00:41:03 +08:00
)
};
2026-04-29 14:36:24 +08:00
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
2026-05-26 14:34:47 +08:00
if requests_filetree_first {
Some("filetree")
} else {
None
},
2026-04-29 14:36:24 +08:00
);
let workspace_name = workspace_projection.workspace_name.clone();
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
2026-05-06 21:44:20 +08:00
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.unwrap_or_else(|_| "null".to_string());
2026-04-29 12:24:44 +08:00
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
2026-05-09 06:24:50 +08:00
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()));
let secondary_bootstrap_json = secondary_aggregate.as_ref().map(|aggregate| {
build_editor_bootstrap_json_with_ids(
aggregate,
&context,
secondary_source_kind,
secondary_root_uri,
"__MNOTE_SECONDARY_PAGE_AGGREGATE__",
"secondary",
)
});
let panes_bootstrap_json = build_document_panes_bootstrap_json(
&aggregate,
&context,
primary_source_kind,
primary_root_uri,
secondary_aggregate.as_ref(),
secondary_source_kind,
secondary_root_uri,
secondary_requested,
secondary_invalid,
2026-05-08 00:41:03 +08:00
);
2026-04-29 12:24:44 +08:00
let body_content = crate::ssr::render_view(leptos::view! {
2026-04-29 14:36:24 +08:00
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
workspace_id={workspace_id.clone()}
2026-04-29 14:36:24 +08:00
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
page_subtree_json={page_subtree_json}
2026-05-06 21:44:20 +08:00
page_options_json={page_options_json}
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
secondary_document_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.document_id.clone()).unwrap_or_default()}
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
2026-04-29 14:36:24 +08:00
/>
2026-04-29 12:24:44 +08:00
});
let hermes_settings_config_script = render_hermes_settings_config_script();
2026-04-29 12:24:44 +08:00
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
<style>{}</style>
</head>
2026-05-20 10:43:38 +08:00
<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="{}">
2026-04-29 12:24:44 +08:00
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
2026-04-29 14:36:24 +08:00
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}
{}
2026-04-29 14:36:24 +08:00
{}
{}
{}
{}
2026-04-29 12:24:44 +08:00
</body>
</html>"#,
escape_html(title),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
2026-05-20 10:43:38 +08:00
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
escape_html(primary_root_uri.unwrap_or("")),
secondary_requested,
secondary_invalid,
2026-04-29 12:24:44 +08:00
body_content,
escape_script_json(&snapshot_json),
2026-04-29 14:36:24 +08:00
escape_script_json(&bootstrap_json),
escape_script_json(&panes_bootstrap_json),
hermes_settings_config_script,
secondary_snapshot_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
.unwrap_or_default(),
secondary_bootstrap_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__" type="application/json">{}</script>"#, escape_script_json(value)))
.unwrap_or_default(),
render_document_title_controller_script(),
2026-04-29 14:36:24 +08:00
render_editor_island_adapter_script(),
r#"<script type="module" src="/api/mnote-browser-runtime/document-conflict-panel-runtime.js"></script>"#.to_string(),
2026-04-29 12:24:44 +08:00
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "document");
stamp_recent_page_cookie(response.headers_mut(), &document_id);
Ok(response)
}
pub(crate) fn build_editor_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
2026-05-08 00:41:03 +08:00
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
build_editor_bootstrap_json_with_ids(
aggregate,
context,
source_kind,
root_uri,
"__MNOTE_PAGE_AGGREGATE__",
"primary",
)
}
fn render_hermes_settings_config_script() -> String {
let Some(base_url) = [
"MNOTE_WEB_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_API_BASE_URL",
]
.into_iter()
.find_map(env_or_dotenv)
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()) else {
return String::new();
};
let settings_url = format!("{base_url}/hermes/settings");
let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
escape_script_json(&encoded)
)
}
fn env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
if cfg!(test) {
return None;
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((candidate_key, value)) = line.split_once('=') else {
continue;
};
if candidate_key.trim() != key {
continue;
}
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
pub(crate) fn build_editor_bootstrap_json_with_ids(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
page_aggregate_script_id: &str,
pane_role: &str,
) -> String {
let normalized_source_kind = source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("convex_workspace");
let save_endpoint = if normalized_source_kind == "local_folder" {
"/api/page-body/write"
} else {
"/api/documents/save"
};
2026-04-29 14:36:24 +08:00
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
"workspaceId": aggregate.identity.workspace_id,
"paneRole": pane_role,
"sourceKind": normalized_source_kind,
2026-05-08 00:41:03 +08:00
"rootUri": root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(""),
"pageAggregateScriptId": page_aggregate_script_id,
"saveEndpoint": save_endpoint,
"titleEndpoint": "/api/documents/title",
2026-04-29 14:36:24 +08:00
"editorHostKind": "leptos_tiptap_island",
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
}))
.unwrap_or_else(|_| "{}".to_string())
}
2026-05-13 22:43:16 +08:00
pub(crate) fn build_document_panes_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
secondary_aggregate: Option<&PageAggregate>,
secondary_source_kind: Option<&str>,
secondary_root_uri: Option<&str>,
secondary_requested: bool,
secondary_invalid: bool,
) -> String {
let primary = json!({
"role": "primary",
"aggregate": aggregate,
"bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
aggregate,
context,
source_kind,
root_uri,
"__MNOTE_PAGE_AGGREGATE__",
"primary",
)).unwrap_or_else(|_| json!({})),
});
let secondary = secondary_aggregate.map(|aggregate| {
json!({
"role": "secondary",
"aggregate": aggregate,
"bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
aggregate,
context,
secondary_source_kind,
secondary_root_uri,
"__MNOTE_SECONDARY_PAGE_AGGREGATE__",
"secondary",
)).unwrap_or_else(|_| json!({})),
})
});
serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": secondary_requested,
"secondaryInvalid": secondary_invalid,
"panes": match secondary {
Some(secondary) => vec![primary, secondary],
None => vec![primary],
},
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn normalize_optional_query_value(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn normalize_source_kind(value: Option<&str>) -> Option<&str> {
normalize_optional_query_value(value)
}
fn normalize_optional_owned(value: Option<&str>) -> Option<String> {
normalize_optional_query_value(value).map(ToOwned::to_owned)
}
pub(crate) fn render_document_title_controller_script() -> &'static str {
r#"<script>
(() => {
const CONTRACT = 'mnote.document_title_controller.v1';
const inputs = Array.from(document.querySelectorAll('[data-page-title-input="true"]')).filter((node) => node instanceof HTMLTextAreaElement);
if (!inputs.length) return;
const cssEscape = (value) => {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
};
const autosize = (input) => {
input.style.height = 'auto';
input.style.height = `${Math.max(48, input.scrollHeight)}px`;
};
const setStatus = (input, status, message) => {
input.setAttribute('data-title-save-status', status);
const shell = input.closest('.document-shell');
if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
if (message) input.setAttribute('data-title-save-error', message);
else input.removeAttribute('data-title-save-error');
};
const setText = (selector, title) => {
document.querySelectorAll(selector).forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
};
const fileTreePageTitle = (value) => {
const normalized = String(value || '无标题').trim() || '无标题';
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
};
const updateVisibleTitle = (input, title, documentId) => {
const pane = input.closest('[data-document-pane="true"]');
const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId;
if (isPrimaryDocument) {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
2026-05-21 05:40:06 +08:00
const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
if (documentId) {
const escapedId = cssEscape(documentId);
document.querySelectorAll(`[data-page-title-input="true"][data-document-id="${escapedId}"]`).forEach((node) => {
if (!(node instanceof HTMLTextAreaElement) || node === input) return;
node.value = title;
autosize(node);
setStatus(node, 'saved');
});
document.querySelectorAll(`[data-document-pane="true"][data-pane-document-id="${escapedId}"] [data-page-title-current="true"]`).forEach((node) => {
if (node instanceof HTMLElement && node !== input) node.textContent = title;
});
} else {
pane?.querySelectorAll('[data-page-title-current]').forEach((node) => {
if (node instanceof HTMLElement && node !== input) node.textContent = title;
});
}
const current = document.querySelector('.wolai-breadcrumb-current');
if (current instanceof HTMLElement && isPrimaryDocument) {
let titleNode = current.querySelector('[data-page-title-current]');
if (!(titleNode instanceof HTMLElement)) {
titleNode = document.createElement('span');
titleNode.setAttribute('data-page-title-current', 'true');
current.appendChild(titleNode);
}
titleNode.textContent = title;
}
if (!documentId) return;
const escapedId = cssEscape(documentId);
2026-05-06 21:44:20 +08:00
const escapedDocRowId = cssEscape(`doc:${documentId}`);
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title));
2026-04-30 06:58:17 +08:00
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
};
inputs.forEach((input) => {
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
2026-05-11 13:16:34 +08:00
const resolveTitleTarget = (targetInput) => {
const paneRole = (targetInput.getAttribute('data-pane-role') || 'primary').trim();
const rawDocumentId = (targetInput.getAttribute('data-document-id') || '').trim();
const documentId = rawDocumentId || (
paneRole === 'primary'
? (document.body?.dataset.documentId || '').trim()
: ''
);
const query = new URLSearchParams(window.location.search);
const paneQueryParams = paneRole === 'secondary'
? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' }
: { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' };
return {
documentId,
workspaceId: (targetInput.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(),
sourceKind: (query.get(paneQueryParams.sourceKindParam) || '').trim(),
rootUri: (query.get(paneQueryParams.rootUriParam) || '').trim(),
};
};
const readLastSavedTitle = () => input.getAttribute('data-title-last-saved') || '无标题';
const writeLastSavedTitle = (title) => {
input.setAttribute('data-title-last-saved', title || '无标题');
};
writeLastSavedTitle(input.value.trim() || '无标题');
let saving = false;
const saveTitle = async () => {
const title = input.value.trim() || '无标题';
2026-05-11 13:16:34 +08:00
const currentTarget = resolveTitleTarget(input);
autosize(input);
2026-05-11 13:16:34 +08:00
if (!currentTarget.documentId || saving || title === readLastSavedTitle()) {
updateVisibleTitle(input, title, currentTarget.documentId);
setStatus(input, 'saved');
return;
}
saving = true;
setStatus(input, 'saving');
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
2026-05-11 13:16:34 +08:00
documentId: currentTarget.documentId,
workspaceId: currentTarget.workspaceId || null,
sourceKind: currentTarget.sourceKind || undefined,
rootUri: currentTarget.rootUri || undefined,
title,
commandName: 'page.head.updateTitle',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
}
2026-05-20 10:43:38 +08:00
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', {
2026-05-20 10:43:38 +08:00
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));
} finally {
saving = false;
}
};
input.addEventListener('input', () => {
autosize(input);
2026-05-11 13:16:34 +08:00
setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty');
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
input.blur();
}
});
input.addEventListener('blur', () => { void saveTitle(); });
autosize(input);
2026-05-11 13:16:34 +08:00
updateVisibleTitle(input, readLastSavedTitle(), resolveTitleTarget(input).documentId);
setStatus(input, 'saved');
});
})();
</script>"#
}
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>"#
2026-04-29 14:36:24 +08:00
}
fn runtime_asset_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../spikes/leptos-tiptap-spike/generated/island")
}
fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
let asset_path = asset_path.trim();
if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') {
return None;
}
let mut resolved = runtime_asset_root();
for component in FsPath::new(asset_path).components() {
match component {
Component::Normal(part) => resolved.push(part),
_ => return None,
}
}
Some(resolved)
}
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
if asset_path.ends_with(".wasm") {
"application/wasm"
} else if asset_path.ends_with(".js") {
"application/javascript; charset=utf-8"
} else if asset_path.ends_with(".json") {
"application/json; charset=utf-8"
} else {
"application/octet-stream"
}
}
2026-05-02 06:25:26 +08:00
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"/>
<rect x="42" y="42" width="556" height="276" rx="14" fill="#ffffff" stroke="#d1d5db" stroke-width="2"/>
<circle cx="168" cy="132" r="34" fill="#93c5fd"/>
<path d="M98 278 246 174 340 242 410 192 542 278Z" fill="#86efac"/>
<path d="M98 278 246 174 340 242 410 192 542 278" fill="none" stroke="#16a34a" stroke-width="8" stroke-linejoin="round"/>
<text x="320" y="322" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#374151">E24 Image</text>
</svg>"##;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(SVG))
.unwrap_or_else(|_| Response::new(Body::empty()))
2026-05-24 21:03:33 +08:00
}
pub async fn resource_open_runtime_asset() -> Response {
// include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
const JS: &str = include_str!("../../browser/resource-open-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
2026-05-24 21:35:21 +08:00
}
2026-05-24 22:42:53 +08:00
pub async fn local_upload_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-upload-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_tree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
2026-05-25 22:53:15 +08:00
}
pub async fn sidebar_page_ai_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_settings_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-25 22:53:15 +08:00
pub async fn sidebar_shell_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-shell-runtime.js");
2026-05-26 01:31:28 +08:00
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_workspace_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-workspace-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 02:35:08 +08:00
pub async fn sidebar_page_tree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-tree-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_tree_live_apply_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-tree-live-apply-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_filetree_open_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-filetree-open-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_filetree_command_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-filetree-command-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_filetree_upload_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-filetree-upload-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_attachment_open_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-attachment-open-runtime.js");
2026-05-25 22:53:15 +08:00
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_keyboard_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-keyboard-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_dnd_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-dnd-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_context_menu_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-context-menu-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_selection_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-selection-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
2026-05-24 22:42:53 +08:00
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-24 21:35:21 +08:00
pub async fn tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
2026-05-25 00:03:12 +08:00
}
pub async fn tree_shell_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 03:51:06 +08:00
pub async fn tree_shell_render_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-render-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 02:42:07 +08:00
pub async fn tree_shell_page_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-page-runtime.js");
Response::builder()
.status(StatusCode::OK)
2026-05-26 02:48:30 +08:00
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn tree_shell_state_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-state-runtime.js");
Response::builder()
.status(StatusCode::OK)
2026-05-26 02:42:07 +08:00
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 02:46:02 +08:00
pub async fn tree_shell_icons_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-icons-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 02:53:12 +08:00
pub async fn tree_shell_filetree_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-filetree-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 03:35:10 +08:00
pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-filetree-menu-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 03:42:03 +08:00
pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 02:56:16 +08:00
pub async fn tree_shell_picker_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 03:02:15 +08:00
pub async fn tree_shell_dom_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-shell-dom-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn document_conflict_panel_runtime_asset() -> Response {
// include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
const JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
2026-05-02 06:25:26 +08:00
}
2026-05-26 00:47:51 +08:00
pub async fn document_pane_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-pane-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn document_mindmap_host_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-mindmap-host-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn document_resource_tab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-resource-tab-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-05-26 01:23:27 +08:00
pub async fn document_session_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-session-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn document_slash_position_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-slash-position-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn document_tiptap_conversion_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn document_editor_adapter_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/document-editor-adapter-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
2026-04-29 14:36:24 +08:00
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
"assetPaths": [
"mnote-leptos-tiptap-spike-island.js",
"mnote-leptos-tiptap-spike-island_bg.wasm"
],
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
});
let mut response = Json(manifest).into_response();
stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
response
}
pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
let Some(resolved) = resolve_runtime_asset_path(&asset_path) else {
return Err(WebError::bad_request_code(
"runtime_asset_path_invalid",
"leptos-tiptap runtime asset 路径非法",
));
};
let bytes = std::fs::read(&resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?;
let response = Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
runtime_asset_content_type(&asset_path),
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(bytes))
.map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
Ok(response)
}
2026-04-29 12:24:44 +08:00
pub async fn page_aggregate(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
2026-05-08 00:41:03 +08:00
query.source_kind.as_deref(),
query.root_uri.as_deref(),
2026-04-29 12:24:44 +08:00
)
.await?;
let projection_owner = aggregate.source_label();
2026-04-29 12:24:44 +08:00
let mut response = (
StatusCode::OK,
Json(json!({
"ok": true,
"owner": "mnote-web",
"schema": "mnote.page_aggregate.v1",
"result": aggregate,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.into_response();
stamp_shell_headers(response.headers_mut(), "page-aggregate");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
2026-04-29 12:24:44 +08:00
Ok(response)
}
pub(crate) async fn build_page_aggregate_snapshot(
2026-04-29 12:24:44 +08:00
state: &AppState,
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
2026-05-08 00:41:03 +08:00
source_kind: Option<&str>,
root_uri: Option<&str>,
2026-04-29 12:24:44 +08:00
) -> Result<PageAggregate, WebError> {
2026-05-08 00:41:03 +08:00
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
let root_uri = root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(state, context, root_uri)
.map_err(|error| error.with_context(context))?;
2026-05-08 00:41:03 +08:00
return resolve_local_markdown_page_aggregate(root_uri, document_id);
}
2026-04-29 12:24:44 +08:00
let meta = load_document_meta_result(
state,
context,
DocumentMetaQuery {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(str::to_string),
},
)
.await?;
let content = load_document_content_result(
state,
context,
DocumentContentQuery {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(str::to_string),
},
)
.await?;
let projection = execute_runtime_query_against_data(
context,
workspace_id,
RuntimeQueryEnvelopeWire {
name: "page.aggregate.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
},
json!({
"meta": meta,
"content": content,
}),
)?;
2026-04-29 12:24:44 +08:00
2026-04-30 06:58:17 +08:00
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
})
2026-04-29 12:24:44 +08:00
}
fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
let value = document_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
}
})
.collect::<String>();
if value.is_empty() {
return;
}
let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
headers.insert(name, HeaderValue::from_static(shell));
}
2026-05-08 00:41:03 +08:00
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
2026-04-29 12:24:44 +08:00
}
pub(crate) fn escape_html(value: &str) -> String {
2026-04-29 12:24:44 +08:00
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub(crate) fn escape_script_json(value: &str) -> String {
2026-04-29 12:24:44 +08:00
value.replace("</script", "<\\/script")
}
pub(crate) async fn load_workspace_shell_projection(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
default_workspace_name: &str,
) -> WorkspaceShellProjection {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::SidebarTree,
query: None,
max_results: None,
};
let dataset = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => snapshot.dataset,
Err(_) if config.allow_dev_fixtures => {
2026-04-29 12:24:44 +08:00
let documents = active_document_id
.map(|document_id| {
json!([{
"id": document_id,
"workspace_id": workspace_id,
"title": "个人",
"parent_id": null,
"sort_order": 0,
"is_starred": false
}])
})
.unwrap_or_else(|| json!([]));
json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": documents,
"dev_fixture": true
2026-04-29 12:24:44 +08:00
})
}
Err(_) => json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
"degraded": true,
"degraded_reason": "projection_unavailable"
}),
};
2026-04-29 12:24:44 +08:00
build_workspace_shell_projection(
&dataset,
workspace_id,
active_document_id,
default_workspace_name,
)
}
/// 加载侧栏页面树 HTMLSSR
///
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
pub(crate) async fn load_sidebar_tree_html(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
) -> Option<String> {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::SidebarTree,
query: None,
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some((snapshot.projection, false)),
2026-04-29 12:24:44 +08:00
Err(_) if config.allow_dev_fixtures => {
2026-04-29 14:36:24 +08:00
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
let documents = active_document_id
.map(|document_id| {
serde_json::json!([
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
])
})
.unwrap_or_else(|| {
serde_json::json!([
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
{ "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }
])
});
2026-04-29 12:24:44 +08:00
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
2026-04-29 14:36:24 +08:00
"documents": documents,
2026-04-29 12:24:44 +08:00
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
"mindmap_assets": [],
"trashed_mindmap_assets": [],
"table_assets": [],
"trashed_table_assets": [],
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
.ok()
.map(|projection| (projection, true))
2026-04-29 12:24:44 +08:00
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
2026-04-29 12:24:44 +08:00
let rows = collect_page_tree_render_rows(&projection);
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
2026-04-29 12:24:44 +08:00
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
});
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
2026-04-29 12:24:44 +08:00
})
}
2026-04-29 14:36:24 +08:00
/// 加载文件树 HTMLSSR
///
/// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。
pub(crate) async fn load_file_tree_html(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
2026-04-29 14:36:24 +08:00
) -> Option<String> {
let spec = ProjectionSnapshotSpec {
workspace_id,
root_node_id: None,
depth: Some(99),
projection: KernelProjectionKind::FileTree,
query: None,
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some((snapshot.projection, false)),
2026-04-29 14:36:24 +08:00
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
.map(|document_id| {
serde_json::json!([
{ "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
])
})
.unwrap_or_else(|| {
serde_json::json!([
{ "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
{ "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }
])
});
let dev_dataset = serde_json::json!({
"active_workspace_id": workspace_id,
"documents": documents,
"trashed_documents": [],
"media_assets": [],
"trashed_media_assets": [],
"mindmap_assets": [],
"trashed_mindmap_assets": [],
"table_assets": [],
"trashed_table_assets": [],
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
.ok()
.map(|projection| (projection, true))
2026-04-29 14:36:24 +08:00
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
mark_dev_fixture_html(html, dev_fixture, "file-tree")
2026-04-29 14:36:24 +08:00
})
}
fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
if !dev_fixture {
return html;
}
format!(
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
)
}
2026-05-08 00:41:03 +08:00
pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let rows = collect_page_tree_render_rows(&snapshot.projection);
Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
}))
}
pub(crate) fn render_local_file_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
2026-05-08 00:41:03 +08:00
) -> Result<String, WebError> {
let snapshot = 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);
2026-05-08 00:41:03 +08:00
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
2026-04-29 12:24:44 +08:00
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
2026-04-29 12:24:44 +08:00
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
2026-04-29 12:24:44 +08:00
use serde_json::Value;
use tower::util::ServiceExt;
const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
include_str!("../../browser/document-editor-adapter-runtime.js");
const DOCUMENT_MINDMAP_HOST_RUNTIME_JS: &str =
include_str!("../../browser/document-mindmap-host-runtime.js");
2026-05-26 00:47:51 +08:00
const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js");
const DOCUMENT_RESOURCE_TAB_RUNTIME_JS: &str =
include_str!("../../browser/document-resource-tab-runtime.js");
2026-05-26 01:23:27 +08:00
const DOCUMENT_SESSION_RUNTIME_JS: &str =
include_str!("../../browser/document-session-runtime.js");
const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str =
include_str!("../../browser/document-slash-position-runtime.js");
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
include_str!("../../browser/document-tiptap-conversion-runtime.js");
2026-04-29 12:24:44 +08:00
fn app() -> axum::Router {
build_app(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,
2026-04-29 12:24:44 +08:00
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: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"word_count": 42,
"character_count": 128,
"block_count": 3
},
"documents:getContent": {
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
"editorDocument": {
"documentId": "doc_1",
"rootBlockIds": ["editor_1"],
"blocks": [{
"blockId": "editor_1",
"blockType": "paragraph",
"contentNodes": [{
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
"attrs": {}
}],
"childBlockIds": []
}]
},
2026-04-29 12:24:44 +08:00
"revision": 7,
"conflict_detection_key": "doc_1:7",
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
}
}"#
.into(),
),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
.layer(axum::middleware::from_fn(inject_test_actor))
}
async fn inject_test_actor(
mut request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
request
.headers_mut()
.entry("x-mnote-actor-id")
.or_insert(HeaderValue::from_static("user_test"));
request
.headers_mut()
.entry("x-mnote-actor-type")
.or_insert(HeaderValue::from_static("user"));
next.run(request).await
}
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
actor_id,
&format!("file://{}", root.display()),
)
.expect("init local workspace");
2026-04-29 12:24:44 +08:00
}
fn request_context(actor_id: &str, actor_type: &str) -> RequestContext {
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", actor_id.parse().unwrap());
headers.insert("x-mnote-actor-type", actor_type.parse().unwrap());
RequestContext::from_http_parts(
&Method::GET,
&"/api/page-aggregate/local-md:test.md".parse().expect("uri"),
&headers,
)
}
fn grant_local_workspace_read_access(
state: &AppState,
actor_id: &str,
root_uri: &str,
root: &std::path::Path,
) {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(actor_id.into()),
email: Some(format!("{actor_id}@example.com")),
username: actor_id.into(),
display_name: actor_id.into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: actor_id.into(),
workspace_id: None,
root_uri: root_uri.into(),
root_path: root
.canonicalize()
.expect("canonical root")
.display()
.to_string(),
permission: "read".into(),
recursive: true,
capabilities: vec![],
source: "unit-test".into(),
created_by: None,
})
.expect("grant sqlite read");
}
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
build_app(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: None,
enable_legacy_next_compat: false,
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: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
allow_dev_fixtures: false,
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(),
}))
}
2026-04-29 12:24:44 +08:00
#[tokio::test]
async fn document_shell_returns_page_aggregate_snapshot() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-shell")
.and_then(|value| value.to_str().ok()),
Some("document")
);
assert!(response
.headers()
.get_all("set-cookie")
.iter()
.any(|value| value
.to_str()
.unwrap_or_default()
.contains("mnote_recent_page_id=doc_1")));
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS;
2026-04-29 12:24:44 +08:00
assert!(html.contains("data-testid=\"wolai-sidebar\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
2026-04-29 12:24:44 +08:00
assert!(html.contains("data-testid=\"wolai-topbar\""));
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
assert!(html.contains("星标置顶"));
assert!(html.contains("我的页面"));
assert!(html.contains("垃圾箱"));
assert!(html.contains("模板中心"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
2026-04-29 14:36:24 +08:00
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
2026-04-29 12:24:44 +08:00
assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
assert!(html.contains("aria-label=\"页面标题\""));
assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1"));
2026-05-11 13:16:34 +08:00
assert!(html.contains("const currentTarget = resolveTitleTarget(input);"));
assert!(html.contains("documentId: currentTarget.documentId"));
2026-05-06 21:44:20 +08:00
assert!(html.contains(
2026-05-25 23:34:03 +08:00
r#".tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title"#
2026-05-06 21:44:20 +08:00
));
assert!(html.contains("const fileTreePageTitle = (value) => {"));
assert!(html.contains(
"return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;"
));
assert!(html.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"#
));
assert!(runtime.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
assert!(!runtime.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
2026-04-30 06:58:17 +08:00
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
2026-05-06 21:44:20 +08:00
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
assert!(html.contains("data-mnote-action=\"open-page-settings\""));
assert!(html.contains("data-mnote-action=\"open-page-ai\""));
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
assert!(html.contains("data-testid=\"mnote-document-workspace\""));
assert!(html.contains("data-document-pane=\"true\""));
assert!(html.contains("data-pane-role=\"primary\""));
assert!(html.contains("data-document-pane-resizer=\"true\""));
2026-05-20 14:20:48 +08:00
assert!(html.contains("data-mnote-main-tab-strip"));
assert!(html.contains("class=\"mnote-main-tab-badge\""));
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
2026-05-21 05:40:06 +08:00
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
assert!(runtime.contains("pageTab.setAttribute('data-document-id', documentId);"));
assert!(html.contains("data-mnote-tab-badge-kind"));
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
assert!(runtime.contains("document-resource-tab-runtime.js"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
assert!(resource_runtime.contains("data-mnote-tab-strip-bound"));
assert!(runtime.contains("currentWebShellDocumentId"));
assert!(resource_runtime.contains(
"const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();"
));
assert!(resource_runtime.contains(
"if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;"
));
2026-05-21 09:04:13 +08:00
assert!(!html
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })"));
let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS;
assert!(slash_runtime.contains("positionSlashMenuForRoot"));
assert!(slash_runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');"));
assert!(runtime.contains("installGlobalSlashMenuPositioning();"));
assert!(slash_runtime.contains("data-mnote-slash-positioned', 'host'"));
assert!(slash_runtime.contains(
2026-05-21 05:40:06 +08:00
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
));
assert!(slash_runtime.contains("const setSlashMenuInactive = (menu, inactive) =>"));
assert!(slash_runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
assert!(slash_runtime.contains("data-mnote-slash-inactive"));
assert!(
slash_runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")
);
assert!(!runtime.contains(
2026-05-21 05:40:06 +08:00
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
));
assert!(slash_runtime.contains("data-mnote-side-target-unsupported') !== 'true'"));
assert!(
runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
);
assert!(html.contains("data-mnote-resource-tab-host"));
assert!(runtime.contains("openPrimaryMindmap"));
assert!(runtime.contains("openResourceInActiveTab"));
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;
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
assert!(mindmap_runtime.contains("data-mnote-object-identity"));
assert!(runtime.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
2026-05-25 23:34:03 +08:00
assert!(html.contains("/api/realtime/ws"));
2026-05-26 01:23:27 +08:00
let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
assert!(session_runtime.contains("syncPageAggregateScript(session, nextAggregate);"));
let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
assert!(conversion_runtime.contains(
2026-05-20 10:43:38 +08:00
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
));
assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
);
assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
assert!(conversion_runtime
.contains("class: mergeClassNames(mark.attrs.class, attachmentClass)"));
2026-05-26 01:23:27 +08:00
assert!(session_runtime
2026-05-20 10:43:38 +08:00
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
2026-05-26 01:23:27 +08:00
assert!(session_runtime.contains(
2026-05-20 10:43:38 +08:00
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
));
2026-05-26 01:23:27 +08:00
assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Data')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Any')"));
assert!(!runtime.contains("mnote-web-document-shell"));
2026-05-20 20:13:24 +08:00
// Resource open resolver contract
assert!(resource_runtime.contains("normalizeResourceTabKind"));
assert!(resource_runtime.contains("data-resource-tab-error"));
assert!(resource_runtime.contains("resourceTabCloseGuardReason"));
assert!(resource_runtime.contains("closeResourceTab"));
assert!(resource_runtime.contains("resourceTabRegistry.delete(key)"));
assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\""));
assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\""));
assert!(resource_runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)"));
assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'"));
assert!(resource_runtime.contains("data-mnote-editor-kind=\"resource\""));
assert!(resource_runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);"));
assert!(resource_runtime.contains("paneRole,"));
assert!(resource_runtime.contains("markIntendedSlashRoot(entry);"));
assert!(resource_runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);"));
assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
2026-05-21 05:40:06 +08:00
assert!(
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
2026-05-21 05:40:06 +08:00
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime
.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);"));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
2026-04-29 12:24:44 +08:00
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_snapshot_contract() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()),
Some("compat-join")
);
2026-05-08 00:41:03 +08:00
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
2026-04-29 12:24:44 +08:00
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["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
assert_eq!(payload["result"]["projectionVersion"], 1);
2026-04-29 12:24:44 +08:00
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
assert_eq!(
payload["result"]["body"]["projectionSource"],
"editorDocument"
);
assert_eq!(
payload["result"]["body"]["blockDocument"]["rootBlockIds"][0],
"editor_1"
);
assert_eq!(
payload["result"]["body"]["blockDocument"]["blocks"][0]["text"],
"来自 editorDocument 的正文"
);
2026-04-29 12:24:44 +08:00
}
2026-05-08 00:41:03 +08:00
#[tokio::test]
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
assert_eq!(
response
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
);
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["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(payload.get("schema").is_none());
assert!(payload.get("result").is_none());
}
#[tokio::test]
async fn document_shell_errors_without_convex_or_fixture() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
panic!(
"expected SERVICE_UNAVAILABLE, got {status}: {}",
String::from_utf8_lossy(&body)
);
}
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_unavailable")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
let payload: Value = serde_json::from_str(&text).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_unavailable");
assert!(!text.contains("mnote.page_aggregate.v1"));
assert!(!text.contains("data-mnote-dev-fixture"));
assert!(!text.contains("data-page-aggregate-snapshot"));
}
2026-05-08 00:41:03 +08:00
#[tokio::test]
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
let root =
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
2026-05-20 10:43:38 +08:00
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
2026-05-08 00:41:03 +08:00
std::fs::write(
2026-05-20 10:43:38 +08:00
root.join("Local Aggregate").join("Local Aggregate.md"),
"# Local Heading\n正文内容\n",
2026-05-08 00:41:03 +08:00
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
2026-05-08 00:41:03 +08:00
let response = app()
.oneshot(
Request::builder()
.uri(format!(
2026-05-20 10:43:38 +08:00
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
2026-05-08 00:41:03 +08:00
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
2026-05-08 00:41:03 +08:00
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
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["schema"], "mnote.page_aggregate.v1");
assert_eq!(
payload["result"]["identity"]["documentId"],
2026-05-20 10:43:38 +08:00
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
2026-05-08 00:41:03 +08:00
);
2026-05-24 01:49:51 +08:00
// 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容。
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
2026-05-08 00:41:03 +08:00
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
assert_eq!(payload["result"]["body"]["revision"], 0);
assert!(payload["result"]["body"]["content"]
.to_string()
.contains("Local Heading"));
}
#[tokio::test]
async fn page_aggregate_endpoint_allows_sqlite_granted_local_folder_read_access() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-grant-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Grant Root")).expect("create local page bundle");
std::fs::write(
root.join("Grant Root").join("Grant Root.md"),
"# Grant 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(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
let context = request_context("user_test", "user");
let aggregate = super::build_page_aggregate_snapshot(
&state,
&context,
"local-md:Grant~20Root~2FGrant~20Root.md",
None,
Some("local_folder"),
Some(&root_uri),
)
.await
.expect("sqlite read grant can open local page aggregate");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(
aggregate.identity.document_id,
"local-md:Grant~20Root~2FGrant~20Root.md"
);
2026-05-25 23:34:03 +08:00
assert_eq!(aggregate.head.title, "Grant Root");
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
}
2026-05-08 00:41:03 +08:00
#[tokio::test]
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
let root =
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
2026-05-20 10:43:38 +08:00
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");
2026-05-08 00:41:03 +08:00
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
2026-05-08 00:41:03 +08:00
let response = app()
.oneshot(
Request::builder()
.uri(format!(
2026-05-20 10:43:38 +08:00
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
2026-05-08 00:41:03 +08:00
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
2026-05-08 00:41:03 +08:00
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("Local Shell"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
2026-05-08 00:41:03 +08:00
assert!(html.contains("Child Page"));
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
2026-05-11 13:16:34 +08:00
assert!(html.contains("data-page-openable=\"false\""));
2026-05-25 23:34:03 +08:00
assert!(html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js"));
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
2026-05-08 00:41:03 +08:00
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
2026-05-26 01:23:27 +08:00
let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
assert!(session_runtime.contains("refreshSessionFromExternalFileChange"));
assert!(session_runtime.contains("refreshSessionFromExternalChange"));
assert!(session_runtime.contains("treeExternalConflictMessage"));
assert!(session_runtime.contains("tree:delta"));
assert!(session_runtime.contains("tree:resync"));
assert!(session_runtime.contains("mnote-web-tree-live"));
assert!(session_runtime.contains("refreshMindmapRuntimesFromTreePayload"));
assert!(session_runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__"));
assert!(session_runtime.contains("/api/local-folder/events"));
assert!(session_runtime.contains("localFolderEventChannelKey"));
assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
assert!(session_runtime.contains("if (!documentId) return;"));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
2026-05-26 01:23:27 +08:00
assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
assert!(session_runtime.contains("kind.includes('Modify(Name')"));
assert!(session_runtime.contains("targetSession.views.size === 0"));
assert!(session_runtime.contains("session.views.size === 0"));
assert!(session_runtime.contains("targetSession.saving"));
assert!(session_runtime.contains("lastSelfSaveSignalAt"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("clearSessionConflictSurface(view.session);"));
assert!(session_runtime.contains("command: 'replaceContent'"));
assert!(session_runtime.contains("external-change-conflict"));
assert!(session_runtime.contains("mnote-editor-conflict-panel"));
assert!(session_runtime.contains("mnote-conflict-accept-disk"));
assert!(session_runtime.contains("mnote-conflict-keep-current"));
assert!(session_runtime.contains("mnote-conflict-open-diff"));
assert!(session_runtime.contains("mnote-conflict-merge-text"));
assert!(session_runtime.contains("mnote-conflict-merge-save"));
assert!(session_runtime
.contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)"));
2026-05-26 01:23:27 +08:00
assert!(session_runtime.contains("agent run"));
assert!(!session_runtime.contains(
2026-05-09 06:24:50 +08:00
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
));
2026-05-08 00:41:03 +08:00
}
2026-05-25 01:35:19 +08:00
#[test]
fn document_conflict_panel_runtime_contains_dom_helpers() {
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
include_str!("../../browser/document-conflict-panel-runtime.js");
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function createSessionConflictPanel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-editor-conflict-panel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-accept-disk"));
assert!(
DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-open-diff")
);
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onAcceptDisk"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onKeepCurrent"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onOpenDiff"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("function populateSessionConflictDiffPanel"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-current-text"));
assert!(
DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-disk-text")
);
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-merge-use-current"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("data-testid', 'mnote-conflict-merge-use-disk"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("currentMergeText"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseCurrent"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseDisk"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onSaveMerge"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function mountSessionConflictPanel"));
2026-05-25 03:05:57 +08:00
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("runSessionConflictAction: runSessionConflictAction"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("keepCurrentEditorVersion: keepCurrentEditorVersion"));
assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
.contains("writeMergedConflictResult: writeMergedConflictResult"));
2026-05-25 01:35:19 +08:00
}
#[test]
fn document_editor_adapter_runtime_contains_host_contracts() {
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("PANES_BOOTSTRAP_ID"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("ROOT_SELECTOR"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("BRIDGE_PROTOCOL"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-mindmap-host-runtime.js"));
assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("replacePrimaryPaneMindmap"));
assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("openMindmapResourceTab"));
2026-05-26 00:47:51 +08:00
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js"));
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane"));
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-resource-tab-runtime.js"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip"));
2026-05-26 01:23:27 +08:00
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-session-runtime.js"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js"));
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition"));
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition"));
assert!(
DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
);
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("window.__mnoteEnhanceEditorAttachmentLinks"));
assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap"));
}
#[tokio::test]
async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
let config = 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: false,
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(),
};
let headers = HeaderMap::new();
let context = RequestContext::from_http_parts(
&Method::GET,
&"/documents/doc_1?workspaceId=ws_demo"
.parse::<Uri>()
.expect("uri"),
&headers,
);
let sidebar_html =
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
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(
&config,
&context,
"ws_demo",
Some("doc_1"),
"个人空间",
)
.await;
assert!(sidebar_html.is_none());
assert!(filetree_html.is_none());
assert!(workspace_projection.degraded);
assert!(workspace_projection.my_page_items.is_empty());
assert!(!workspace_projection.dev_fixture);
}
#[tokio::test]
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-media-{}",
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("docs").join("blocks.md"),
"---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("Spec"));
assert!(html.contains("assets/spec.pdf"));
2026-05-20 10:43:38 +08:00
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
}
#[tokio::test]
async fn document_shell_renders_secondary_pane_contract_when_query_present() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=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 html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("data-has-secondary-pane=\"true\""));
assert!(html.contains("data-pane-role=\"secondary\""));
assert!(html.contains("data-mnote-pane-close=\"secondary\""));
assert!(html.contains("__MNOTE_SECONDARY_PAGE_AGGREGATE__"));
assert!(html.contains("__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__"));
assert!(html.contains("\"paneRole\":\"secondary\""));
assert!(html.contains("\"secondaryRequested\":true"));
assert!(html.contains("\"secondaryInvalid\":false"));
}
2026-05-08 00:41:03 +08:00
#[tokio::test]
async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.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 html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
assert!(runtime.contains("legacyInlineContentToTiptap"));
assert!(runtime.contains("legacyStylesToTiptapMarks"));
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath"));
assert!(runtime.contains("marks.push({ type: 'bold' })"));
assert!(runtime.contains("marks.push({ type: 'italic' })"));
assert!(runtime.contains("marks.push({ type: 'underline' })"));
assert!(runtime.contains("marks.push({ type: 'strike' })"));
assert!(runtime.contains("marks.push({ type: 'code' })"));
assert!(runtime.contains("marks.push({ type: 'link', attrs: { href } })"));
assert!(runtime.contains("styles.link = href"));
assert!(runtime.contains("contentNodes.map((node) => {"));
assert!(runtime.contains("payload: { type: 'text', text"));
assert!(runtime.contains("typeof payload.text === 'string'"));
assert!(runtime.contains("payload.type === 'hard_break'"));
assert!(runtime.contains("typeof body?.fileVersion === 'string'"));
2026-05-26 01:23:27 +08:00
assert!(DOCUMENT_SESSION_RUNTIME_JS
.contains("expectedFileVersion: session.conflictDetectionKey"));
assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
assert!(runtime.contains("blockType: 'mindmap'"));
assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));
assert!(runtime.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)"));
assert!(runtime.contains("mnoteBlockType: 'mindmap'"));
assert!(runtime.contains("block.blockType === 'mindmap'"));
assert!(runtime.contains("content: block.blockType === 'mindmap'"));
assert!(runtime.contains("? ''"));
assert!(!runtime.contains(
2026-05-08 00:41:03 +08:00
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
2026-04-29 12:24:44 +08:00
}