checkpoint before gfm ast parser design

This commit is contained in:
lix-2026
2026-05-08 00:41:03 +08:00
parent e8ba12e461
commit c620b9e40c
41 changed files with 12270 additions and 263 deletions
@@ -31,6 +31,10 @@ pub fn runtime_context(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
+421 -1
View File
@@ -53,6 +53,21 @@ pub async fn next_ai_agent_run(
}
}
if let Some(provider) = explicit_agent_provider(&payload) {
if provider == "hermes" {
return run_direct_hermes_agent(&context, &payload).await;
}
return Err(WebError::bad_gateway_code(
"ai_provider_bridge_unavailable",
format!(
"{provider} provider 需要可用的 Next AI bridge,不能静默降级到本地页面工具 host。"
),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
}
if let Ok(response) = run_local_mnote_cli_ai_host(&state, &context, &payload).await {
return Ok(response);
}
@@ -118,6 +133,304 @@ pub async fn next_ai_agent_run(
build_ai_sse_proxy_response(upstream_response, &context)
}
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
let provider = payload
.get("options")
.and_then(|value| value.get("ai"))
.and_then(|value| value.get("provider"))
.and_then(Value::as_str)?
.trim()
.to_ascii_lowercase();
match provider.as_str() {
"hermes" => Some("hermes"),
"codex" => Some("codex"),
"claudecode" => Some("claudecode"),
_ => None,
}
}
async fn run_direct_hermes_agent(
context: &RequestContext,
payload: &Value,
) -> Result<Response, WebError> {
let base_url = resolve_hermes_api_base_url();
let api_key = read_env_or_dotenv("MNOTE_HERMES_API_KEY").ok_or_else(|| {
WebError::bad_gateway_code(
"hermes_bridge_unconfigured",
"未配置 MNOTE_HERMES_API_KEY,无法直连 Hermes API。",
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(180))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| WebError::internal(format!("Hermes HTTP 客户端创建失败: {error}")))?;
let start_url = reqwest::Url::parse(&format!("{}/v1/runs", base_url.trim_end_matches('/')))
.map_err(|error| WebError::internal(format!("Hermes runs URL 非法: {error}")))?;
let start_response = client
.post(start_url)
.bearer_auth(&api_key)
.json(&build_hermes_run_payload(payload))
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_start_error",
format!("Hermes run 启动请求失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
if !start_response.status().is_success() {
let status = start_response.status();
let body = start_response
.text()
.await
.unwrap_or_else(|error| format!("读取 Hermes 错误响应失败: {error}"));
return Err(WebError::bad_gateway_code(
"hermes_bridge_start_rejected",
format!("Hermes run 启动返回 HTTP {}: {}", status.as_u16(), body),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes"));
}
let started: Value = start_response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_bad_start_response",
format!("Hermes run 启动响应不是合法 JSON: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
let run_id = started
.get("run_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_gateway_code(
"hermes_bridge_missing_run_id",
"Hermes run 启动响应缺少 run_id。",
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
let mut events_url =
reqwest::Url::parse(&format!("{}/v1/runs/", base_url.trim_end_matches('/')))
.map_err(|error| WebError::internal(format!("Hermes events URL 非法: {error}")))?;
events_url
.path_segments_mut()
.map_err(|_| WebError::internal("Hermes events URL 不支持路径拼接"))?
.pop_if_empty()
.push(run_id)
.push("events");
let events_response = client
.get(events_url)
.bearer_auth(api_key)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_events_error",
format!("Hermes 事件流请求失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
if !events_response.status().is_success() {
let status = events_response.status();
let body = events_response
.text()
.await
.unwrap_or_else(|error| format!("读取 Hermes 事件流错误响应失败: {error}"));
return Err(WebError::bad_gateway_code(
"hermes_bridge_events_rejected",
format!("Hermes 事件流返回 HTTP {}: {}", status.as_u16(), body),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes"));
}
let events_text = events_response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_events_read_error",
format!("读取 Hermes 事件流失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
build_hermes_sse_response(context, parse_sse_data_json_values(&events_text))
}
fn resolve_hermes_api_base_url() -> String {
read_env_or_dotenv("MNOTE_HERMES_API_BASE_URL")
.unwrap_or_else(|| "http://127.0.0.1:8642".into())
.trim()
.trim_end_matches('/')
.to_string()
}
fn build_hermes_run_payload(payload: &Value) -> Value {
let messages = payload
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let input: Vec<Value> = messages
.iter()
.map(|message| {
json!({
"role": message.get("role").and_then(Value::as_str).unwrap_or("user"),
"content": message.get("content").and_then(Value::as_str).unwrap_or(""),
})
})
.collect();
let conversation_history = if input.len() > 1 {
input[..input.len() - 1].to_vec()
} else {
Vec::new()
};
let session_id = payload
.get("options")
.and_then(|value| value.get("ai"))
.and_then(|value| value.get("sessionId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
json!({
"input": input,
"conversation_history": conversation_history,
"session_id": session_id,
})
}
fn parse_sse_data_json_values(text: &str) -> Vec<Value> {
text.split("\n\n")
.filter_map(|frame| {
let data = frame
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim_start)
.collect::<Vec<_>>()
.join("\n");
if data.trim().is_empty() {
return None;
}
serde_json::from_str::<Value>(&data).ok()
})
.collect()
}
fn sse_frame(event: &str, data: Value) -> String {
format!("event: {event}\ndata: {}\n\n", data.to_string())
}
fn build_hermes_sse_response(
context: &RequestContext,
events: Vec<Value>,
) -> Result<Response, WebError> {
let mut body = String::new();
let mut assistant_text = String::new();
let mut completed = false;
body.push_str(&sse_frame(
"ready",
json!({"ok": true, "bridgeOwner": "hermes", "requestId": context.trace.request_id}),
));
for event in events {
let event_name = event.get("event").and_then(Value::as_str).unwrap_or("");
match event_name {
"message.delta" => {
if let Some(delta) = event.get("delta").and_then(Value::as_str) {
assistant_text.push_str(delta);
body.push_str(&sse_frame("assistant_delta", json!({"text": delta})));
}
}
"run.completed" => {
completed = true;
let output = event
.get("output")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| assistant_text.trim());
let text = if output.is_empty() {
"(无输出)"
} else {
output
};
body.push_str(&sse_frame("assistant_message", json!({"text": text})));
body.push_str(&sse_frame(
"completion",
json!({"ok": true, "text": text, "steps": 1}),
));
}
"run.failed" => {
completed = true;
let message = event
.get("error")
.and_then(Value::as_str)
.unwrap_or("Hermes 执行失败");
body.push_str(&sse_frame(
"error",
json!({"ok": false, "message": message}),
));
}
_ => {}
}
}
if !completed {
let text = assistant_text.trim();
let text = if text.is_empty() {
"(无输出)"
} else {
text
};
body.push_str(&sse_frame("assistant_message", json!({"text": text})));
body.push_str(&sse_frame(
"completion",
json!({"ok": true, "text": text, "steps": 1}),
));
}
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header(header::CONNECTION, "keep-alive")
.header("x-accel-buffering", "no")
.header("x-mnote-ai-execution-owner", "hermes")
.body(Body::from(body))
.map_err(|error| WebError::internal(format!("Hermes SSE 响应构造失败: {error}")))?;
stamp_owner_header(response.headers_mut());
Ok(response)
}
async fn proxy_ai_agent_run_to_next(
base_url: &str,
context: &RequestContext,
@@ -619,9 +932,14 @@ pub async fn next_sidebar(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::compat::{
build_hermes_run_payload, build_hermes_sse_response, parse_sse_data_json_values,
};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
use axum::response::IntoResponse;
use serde_json::json;
use tokio::net::TcpListener;
use tower::util::ServiceExt;
@@ -709,6 +1027,108 @@ mod tests {
assert!(!text.contains("legacy_next_compat_disabled"));
}
#[test]
fn hermes_payload_uses_page_messages_and_session_id() {
let payload = json!({
"messages": [
{"role": "system", "content": "系统约束"},
{"role": "user", "content": "总结当前页面"}
],
"options": {
"ai": {
"provider": "hermes",
"sessionId": "hermes-session-1"
}
}
});
let hermes_payload = build_hermes_run_payload(&payload);
assert_eq!(hermes_payload["session_id"], "hermes-session-1");
assert_eq!(hermes_payload["input"][1]["content"], "总结当前页面");
assert_eq!(hermes_payload["conversation_history"][0]["role"], "system");
}
#[tokio::test]
async fn hermes_sse_is_translated_to_page_ai_events() {
let events = parse_sse_data_json_values(
"data: {\"event\":\"message.delta\",\"delta\":\"Hel\"}\n\n\
data: {\"event\":\"message.delta\",\"delta\":\"lo\"}\n\n\
data: {\"event\":\"run.completed\",\"output\":\"Hello\"}\n\n",
);
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/ai-agent/run".parse::<Uri>().expect("uri"),
&HeaderMap::new(),
);
let response = build_hermes_sse_response(&context, events).expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("hermes")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: assistant_message"));
assert!(text.contains("event: completion"));
assert!(text.contains("Hello"));
}
#[tokio::test]
async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() {
let response = 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:9".into()),
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
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(),
}))
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai-agent/run")
.header("content-type", "application/json")
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("provider-bridge-unavailable")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("codex"));
assert!(!text.contains("mnote-cli"));
}
#[tokio::test]
async fn ai_agent_run_proxies_to_next_ai_route_when_legacy_next_compat_enabled() {
let next_app = axum::Router::new().route(
@@ -2,6 +2,9 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::local_folder_source::{
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
};
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id,
@@ -37,6 +40,8 @@ pub struct DocumentMetaQuery {
pub struct DocumentSaveRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub editor_document: Option<Value>,
@@ -51,6 +56,8 @@ pub struct DocumentSaveRequest {
pub struct DocumentTitleRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub title: String,
pub command_name: Option<String>,
}
@@ -60,6 +67,8 @@ pub struct DocumentTitleRequest {
pub struct DocumentOptionsRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
#[serde(default)]
pub options: Value,
pub command_name: Option<String>,
@@ -501,6 +510,19 @@ pub async fn save(
.with_context(&context),
);
}
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = save_local_markdown_page(root_uri, document_id, &body.content)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
@@ -520,6 +542,10 @@ pub async fn save(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
@@ -582,6 +608,10 @@ pub async fn purge(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: None,
@@ -620,6 +650,19 @@ pub async fn title(
WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context),
);
}
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = update_local_markdown_title(root_uri, document_id, title)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let command = RuntimeCommandEnvelopeWire {
@@ -634,6 +677,10 @@ pub async fn title(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
@@ -691,6 +738,19 @@ pub async fn options(
.with_context(&context),
);
}
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = update_local_page_options(root_uri, document_id, &body.options)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let command = RuntimeCommandEnvelopeWire {
@@ -705,6 +765,10 @@ pub async fn options(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
@@ -1028,4 +1092,114 @@ mod tests {
"page.layout.updateOptions"
);
}
#[tokio::test]
async fn local_folder_documents_save_title_and_options_write_to_disk() {
let root = std::env::temp_dir().join(format!(
"mnote-local-documents-write-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\nmnote_id: local-stable\ntitle: Old Title\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
let document_id = "local-mdid:local-stable";
let title_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/title")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"title": "New Local Title"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("title response");
assert_eq!(title_response.status(), StatusCode::OK);
let save_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 2 },
"content": [{ "type": "text", "text": "Saved Heading" }]
},
{
"id": "paragraph_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "Saved body" }]
}
],
"blockCount": 2
})
.to_string(),
))
.expect("request"),
)
.await
.expect("save response");
assert_eq!(save_response.status(), StatusCode::OK);
let options_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/options")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"options": {
"wideLayout": true,
"showToc": false
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("options response");
assert_eq!(options_response.status(), StatusCode::OK);
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("mnote_id: local-stable"));
assert!(markdown.contains("title: New Local Title"));
assert!(markdown.contains("## Saved Heading"));
assert!(markdown.contains("Saved body"));
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
.expect("read page options");
let options_json: Value = serde_json::from_str(&options).expect("options json");
assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
assert_eq!(options_json["pages"][document_id]["showToc"], false);
let _ = std::fs::remove_dir_all(&root);
}
}
+175 -39
View File
@@ -1,13 +1,17 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
use crate::routes::web_shell::{
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_local_file_tree_html, render_local_sidebar_tree_html,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::render_workspace_shell_sidebar_html;
use crate::workspace_shell::{
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
use axum::body::Body;
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
@@ -40,6 +44,8 @@ struct GatewayManifest {
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
workspace_id: Option<String>,
source_kind: Option<String>,
root_uri: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
@@ -156,47 +162,129 @@ pub async fn root_entry(
return Ok(response);
}
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
let requested_or_recent_page_id =
choose_root_entry_active_page_id(requested_page_id, recent_page_id, None, None);
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
&workspace_id,
requested_or_recent_page_id.as_deref(),
&default_workspace_name,
)
.await;
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id,
recent_page_id,
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = load_sidebar_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let file_tree_html = load_file_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let (
workspace_id,
workspace_projection,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
active_source_kind,
active_root_uri,
) = if is_local_folder {
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let workspace_id = snapshot
.dataset
.get("workspace")
.and_then(|workspace| workspace.get("id"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("local-folder")
.to_string();
let requested_or_recent_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
None,
None,
);
let workspace_projection = build_workspace_shell_projection(
&snapshot.dataset,
&workspace_id,
requested_or_recent_page_id.as_deref(),
"本地文件夹",
);
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html =
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
let file_tree_html =
render_local_file_tree_html(root_uri, selected_active_page_id.as_deref())?;
(
workspace_id,
workspace_projection,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
Some("local_folder".to_string()),
Some(root_uri.to_string()),
)
} else {
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let requested_or_recent_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
None,
None,
);
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
&workspace_id,
requested_or_recent_page_id.as_deref(),
&default_workspace_name,
)
.await;
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = load_sidebar_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let file_tree_html = load_file_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
(
workspace_id,
workspace_projection,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
None,
None,
)
};
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
@@ -228,6 +316,8 @@ pub async fn root_entry(
&context,
&active_page_id,
Some(workspace_id.as_str()),
active_source_kind.as_deref(),
active_root_uri.as_deref(),
)
.await
{
@@ -237,7 +327,12 @@ pub async fn root_entry(
.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);
let bootstrap_json = build_editor_bootstrap_json(
&aggregate,
&context,
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::document::DocumentPage
title={title.to_string()}
@@ -985,6 +1080,47 @@ mod tests {
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
}
#[tokio::test]
async fn root_entry_renders_local_folder_without_debug_tree_route() {
let root =
std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
let root_uri = format!("file://{}", root.display());
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!(
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("x-mnote-actor-id", "user_real")
.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("utf8");
assert!(html.contains(r#"data-mnote-shell="workspace""#));
assert!(html.contains("local_folder"));
assert!(html.contains("Local Root"));
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
assert!(!html.contains("legacy_next_compat_disabled"));
assert!(!html.contains(r#"href="/tree"#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
File diff suppressed because it is too large Load Diff
+5
View File
@@ -7,6 +7,7 @@ mod gateway;
mod health;
mod hermes;
mod kernel;
mod local_folder_source;
mod mindmap_shell;
mod query_support;
mod search;
@@ -91,6 +92,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/kernel/edges", get(kernel::edges))
.route("/api/kernel/graph", get(kernel::graph))
.route("/api/tree/commands", post(tree::tree_command))
.route(
"/api/tree/local-folder-watch",
get(tree::local_folder_watch),
)
.route(
"/api/tree/runtime/reduce",
post(tree::reduce_tree_shell_runtime),
@@ -70,6 +70,10 @@ pub fn runtime_context(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
File diff suppressed because it is too large Load Diff
+303 -17
View File
@@ -6,6 +6,10 @@ use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::local_folder_source::{
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,
@@ -39,6 +43,8 @@ const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -59,6 +65,8 @@ pub async fn document_page_shell(
&context,
&document_id,
query.workspace_id.as_deref(),
query.source_kind.as_deref(),
query.root_uri.as_deref(),
)
.await?;
let title = aggregate.head.title.as_str();
@@ -73,14 +81,28 @@ pub async fn document_page_shell(
)
.await;
apply_active_page(&mut workspace_projection, Some(&document_id));
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default();
let file_tree_html =
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default();
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)).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))
.await
.unwrap_or_default(),
)
};
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
@@ -92,7 +114,12 @@ pub async fn document_page_shell(
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.unwrap_or_else(|_| "null".to_string());
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
let bootstrap_json = build_editor_bootstrap_json(
&aggregate,
&context,
query.source_kind.as_deref(),
query.root_uri.as_deref(),
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
@@ -139,11 +166,21 @@ pub async fn document_page_shell(
pub(crate) fn build_editor_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
"workspaceId": aggregate.identity.workspace_id,
"sourceKind": source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("convex_workspace"),
"rootUri": root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(""),
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
"saveEndpoint": "/api/documents/save",
"titleEndpoint": "/api/documents/title",
@@ -165,7 +202,10 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
const query = new URLSearchParams(window.location.search);
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim();
const sourceKind = (query.get('sourceKind') || '').trim();
const rootUri = (query.get('rootUri') || '').trim();
let lastSavedTitle = input.value.trim() || '无标题';
let saving = false;
@@ -233,6 +273,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
body: JSON.stringify({
documentId,
workspaceId: workspaceId || null,
sourceKind: sourceKind || undefined,
rootUri: rootUri || undefined,
title,
commandName: 'page.head.updateTitle',
}),
@@ -317,6 +359,37 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return '';
};
const legacyStylesToTiptapMarks = (styles) => {
if (!styles || typeof styles !== 'object') return [];
const marks = [];
if (styles.bold) marks.push({ type: 'bold' });
if (styles.italic) marks.push({ type: 'italic' });
if (styles.underline) marks.push({ type: 'underline' });
if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' });
if (styles.code || styles.inlineCode) marks.push({ type: 'code' });
const href = typeof styles.link === 'string' && styles.link.trim()
? styles.link.trim()
: typeof styles.href === 'string' && styles.href.trim()
? styles.href.trim()
: '';
if (href) marks.push({ type: 'link', attrs: { href } });
return marks;
};
const legacyInlineContentToTiptap = (value) => {
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
if (value && typeof value === 'object') {
const text = typeof value.text === 'string' ? value.text : '';
if (text) {
const marks = legacyStylesToTiptapMarks(value.styles);
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
}
return legacyInlineContentToTiptap(value.content || value.contentNodes);
}
return [];
};
const legacyBlockToTiptap = (block, index = 0) => {
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
const blockId = typeof block?.id === 'string' && block.id.trim()
@@ -324,8 +397,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
: typeof block?.blockId === 'string' && block.blockId.trim()
? block.blockId.trim()
: `block-${index + 1}`;
const text = flattenText(block?.content);
const content = text ? [{ type: 'text', text }] : [];
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
const textAlign = typeof block?.props?.textAlign === 'string'
? block.props.textAlign
: typeof block?.props?.text_align === 'string'
@@ -337,10 +409,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
: [];
const withListChildren = (itemType, listType, attrs = {}) => ({
type: listType,
attrs: { blockId, ...attrs },
attrs: { blockId },
content: [{
type: itemType,
attrs: { blockId },
attrs: { blockId, ...attrs },
content: [
{ type: 'paragraph', attrs: { blockId }, content },
...nestedChildren,
@@ -458,7 +530,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return node.content.flatMap((child) => {
if (child?.type === 'text') {
const text = typeof child.text === 'string' ? child.text : '';
return text ? [{ type: 'text', text }] : [];
if (!text) return [];
const styles = {};
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
if (mark?.type === 'bold') styles.bold = true;
if (mark?.type === 'italic') styles.italic = true;
if (mark?.type === 'underline') styles.underline = true;
if (mark?.type === 'strike') styles.strike = true;
if (mark?.type === 'code') styles.code = true;
if (mark?.type === 'link') {
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
if (href) styles.link = href;
}
}
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
}
if (child?.type === 'hardBreak') {
return [{ type: 'text', text: '\n' }];
@@ -544,7 +629,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
? { ...(block.props || {}) }
: undefined,
content: Array.isArray(block.contentNodes)
? block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')
? block.contentNodes.map((node) => {
if (!node || typeof node !== 'object') return null;
const text = typeof node.text === 'string' ? node.text : '';
if (!text) return null;
return {
type: 'text',
text,
...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}),
};
}).filter(Boolean)
: '',
}));
@@ -577,6 +671,21 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
pageOptions: aggregate.layout?.pageOptions || {},
};
const clearEmbeddedLocalDraft = () => {
if (bootstrap.sourceKind !== 'local_folder') return;
try {
const storage = window.localStorage;
const base = 'mnote.leptos-tiptap-spike.document';
const keys = [
`${base}:${bootstrap.workspaceId}:${bootstrap.documentId}`,
`${base}:${bootstrap.documentId}`,
];
for (const key of keys) storage.removeItem(key);
} catch (error) {
console.warn('mnote local folder 草稿清理失败', error);
}
};
let saveTimer = 0;
let lastSavedSerialized = '';
const normalizeBridgeValue = (value) => {
@@ -623,6 +732,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
body: JSON.stringify({
documentId: bootstrap.documentId,
workspaceId: bootstrap.workspaceId,
sourceKind: bootstrap.sourceKind,
rootUri: bootstrap.rootUri,
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
editorDocument,
@@ -687,6 +798,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
throw new Error('island runtime 导出不完整');
}
await runtime.default(wasmUrl);
clearEmbeddedLocalDraft();
const mountId = runtime.mount(root, mountOptions);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
@@ -812,6 +924,8 @@ pub async fn documents_page_compat(
&context,
&document_id,
query.workspace_id.as_deref(),
None,
None,
)
.await?;
let projection_owner = aggregate.source_label();
@@ -847,6 +961,8 @@ pub async fn page_aggregate(
&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();
@@ -876,7 +992,19 @@ pub(crate) async fn build_page_aggregate_snapshot(
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")
})?;
return resolve_local_markdown_page_aggregate(root_uri, document_id);
}
let meta = load_document_meta_result(
state,
context,
@@ -944,6 +1072,7 @@ fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
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 {
@@ -1123,11 +1252,35 @@ pub(crate) async fn load_file_tree_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>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::http::{header, Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -1262,6 +1415,13 @@ mod tests {
.and_then(|value| value.to_str().ok()),
Some("rust-kernel")
);
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");
@@ -1273,4 +1433,130 @@ mod tests {
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
}
#[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).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
))
.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:README.md"
);
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 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("docs")).expect("create local docs");
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
.expect("write child md");
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.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("Child Page"));
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
}
#[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("legacyInlineContentToTiptap"));
assert!(html.contains("legacyStylesToTiptapMarks"));
assert!(html.contains("marks.push({ type: 'code' })"));
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
assert!(html.contains("styles.link = href"));
assert!(html.contains("contentNodes.map((node) => {"));
assert!(!html.contains(
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
}