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

2467 lines
102 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::document_buffer_store::{self};
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;
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, load_local_folder_page_tree_snapshot,
resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
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,
};
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;
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;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::json;
use std::fs;
use std::path::{Component, Path as FsPath, PathBuf};
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>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub secondary_document_id: Option<String>,
pub secondary_source_kind: Option<String>,
pub secondary_root_uri: Option<String>,
}
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());
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
primary_source_kind,
primary_root_uri,
)
.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();
let workspace_id = aggregate.identity.workspace_id.clone();
let requested_secondary_document_id =
normalize_optional_owned(query.secondary_document_id.as_deref());
let secondary_source_kind = normalize_source_kind(
query
.secondary_source_kind
.as_deref()
.or(primary_source_kind),
);
let secondary_root_uri =
normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri));
let mut secondary_requested = false;
let mut secondary_invalid = false;
let secondary_aggregate =
if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() {
secondary_requested = true;
match build_page_aggregate_snapshot(
&state,
&context,
secondary_document_id,
query.workspace_id.as_deref(),
secondary_source_kind,
secondary_root_uri,
)
.await
{
Ok(aggregate) => Some(aggregate),
Err(_) => {
secondary_invalid = true;
None
}
}
} else {
None
};
let default_workspace_name = default_workspace_name_for_context(&state, &context);
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));
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
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(),
)
} 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(),
)
};
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
);
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());
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.unwrap_or_else(|_| "null".to_string());
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json =
build_editor_bootstrap_json(&aggregate, &context, primary_source_kind, primary_root_uri);
let secondary_page_subtree_json = secondary_aggregate.as_ref().map(|aggregate| {
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string())
});
let secondary_page_options_json = secondary_aggregate.as_ref().map(|aggregate| {
serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string())
});
let 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,
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
document_id={document_id.clone()}
workspace_id={workspace_id.clone()}
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
page_subtree_json={page_subtree_json}
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)}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}
{}
{}
{}
{}
{}
</body>
</html>"#,
escape_html(title),
crate::ssr::MNOTE_CSS,
escape_html(&document_id),
escape_html(primary_source_kind.unwrap_or("convex_workspace")),
escape_html(primary_root_uri.unwrap_or("")),
secondary_requested,
secondary_invalid,
body_content,
escape_script_json(&snapshot_json),
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(),
render_editor_island_adapter_script(),
r#"<script type="module" src="/api/mnote-browser-runtime/document-conflict-panel-runtime.js"></script>"#.to_string(),
);
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,
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"
};
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,
"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",
"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())
}
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;
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);
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));
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';
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() || '无标题';
const currentTarget = resolveTitleTarget(input);
autosize(input);
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({
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}`);
}
const result = payload?.result || {};
const nextDocumentId = String(result.documentId || result.id || currentTarget.documentId || '').trim();
const previousDocumentId = currentTarget.documentId;
const nextTitle = String(result.title || title || '无标题').trim() || '无标题';
if (nextDocumentId) {
input.setAttribute('data-document-id', nextDocumentId);
}
writeLastSavedTitle(nextTitle);
updateVisibleTitle(input, nextTitle, nextDocumentId || currentTarget.documentId);
setStatus(input, 'saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', {
detail: {
documentId: 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);
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);
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>"#
}
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"
}
}
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()))
}
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()))
}
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()))
}
pub async fn sidebar_shell_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-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()))
}
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()))
}
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");
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))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
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()))
}
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()))
}
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)
.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)
.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_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()))
}
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()))
}
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()))
}
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()))
}
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)
}
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(),
query.source_kind.as_deref(),
query.root_uri.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
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);
}
}
Ok(response)
}
pub(crate) async fn build_page_aggregate_snapshot(
state: &AppState,
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<PageAggregate, WebError> {
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))?;
return resolve_local_markdown_page_aggregate(root_uri, document_id);
}
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,
}),
)?;
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
})
}
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));
}
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
pub(crate) fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub(crate) fn escape_script_json(value: &str) -> String {
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 => {
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
})
}
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"
}),
};
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)),
Err(_) if config.allow_dev_fixtures => {
// 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 }
])
});
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))
}
Err(_) => None,
};
result.map(|(projection, dev_fixture)| {
let rows = collect_page_tree_render_rows(&projection);
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
});
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
})
}
/// 加载文件树 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>,
) -> 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)),
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))
}
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")
})
}
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}"#
)
}
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>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
let rows =
collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use 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");
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");
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");
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,
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": []
}]
},
"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");
}
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(),
}))
}
#[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;
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\""));
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\""));
assert!(html.contains("data-testid=\"mnote-page-subtree\""));
assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
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"));
assert!(html.contains("const currentTarget = resolveTitleTarget(input);"));
assert!(html.contains("documentId: currentTarget.documentId"));
assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title"#
));
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}`"));
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
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\""));
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\""));
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)}`;"
));
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(
"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(
"|| 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"));
assert!(html.contains("/api/realtime/ws"));
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(
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
));
assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
assert!(
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
);
assert!(session_runtime
.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
assert!(session_runtime.contains(
"const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
));
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"));
// 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)"));
assert!(
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
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);"));
}
#[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")
);
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["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
assert_eq!(payload["result"]["projectionVersion"], 1);
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 的正文"
);
}
#[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"));
}
#[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);
std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
std::fs::write(
root.join("Local Aggregate").join("Local Aggregate.md"),
"# Local Heading\n正文内容\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!(
"/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.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"],
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
);
// 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容。
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
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"
);
assert_eq!(aggregate.head.title, "Grant Root");
assert!(aggregate.body.content.to_string().contains("Grant Heading"));
}
#[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);
std::fs::create_dir_all(root.join("Local Shell")).expect("create local page bundle");
std::fs::create_dir_all(root.join("docs").join("Child Page"))
.expect("create local child bundle");
std::fs::write(
root.join("Local Shell").join("Local Shell.md"),
"# Local Shell\n正文\n",
)
.expect("write root md");
std::fs::write(
root.join("docs").join("Child Page").join("Child Page.md"),
"# Child Page\n",
)
.expect("write child md");
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.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\""));
assert!(html.contains("Child Page"));
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-page-openable=\"false\""));
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>"#));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
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')"));
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)"));
assert!(session_runtime.contains("agent run"));
assert!(!session_runtime.contains(
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
));
}
#[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"));
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"));
}
#[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"));
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"));
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_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"));
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"));
}
#[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'"));
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(
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
}