feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
+680 -84
View File
@@ -4,6 +4,7 @@ use crate::error::WebError;
use crate::routes::local_folder_source::{
control_plane_db_path_display, create_default_local_workspace_for_actor,
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
load_local_trash_entries,
};
@@ -17,24 +18,24 @@ use crate::routes::web_shell::{
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::transport::convex::execute_retired_mutation_by_name;
use crate::workspace_shell::{
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
use axum::body::Body;
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri, header};
use axum::response::{Html, IntoResponse, Response};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use control_plane::{
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
UpsertUserInput,
AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash,
};
use futures_util::{SinkExt, StreamExt};
use leptos::prelude::InnerHtmlAttribute;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
@@ -73,6 +74,8 @@ pub(crate) struct RootEntryQuery {
file_tree_scope: Option<String>,
tree_view: Option<String>,
restore_focus_row_id: Option<String>,
route_guard: Option<String>,
missing_page: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
@@ -260,12 +263,23 @@ pub async fn user_access_policy_entry(
pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
uri: Uri,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.header(
header::LOCATION,
format!(
"/auth?next={}",
query_escape(
uri.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/")
)
),
)
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
@@ -350,21 +364,16 @@ pub async fn root_entry(
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
let mut workspace_projection = build_workspace_shell_projection(
&workspace_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(),
None,
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
if selected_active_page_id.is_none() {
apply_active_page(&mut workspace_projection, None);
}
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
&page_tree_snapshot,
selected_active_page_id.as_deref(),
@@ -419,21 +428,16 @@ pub async fn root_entry(
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
let mut workspace_projection = build_workspace_shell_projection(
&workspace_dataset,
&workspace_id,
requested_page_id.as_deref(),
&default_workspace_name,
);
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 selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
if selected_active_page_id.is_none() {
apply_active_page(&mut workspace_projection, None);
}
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
&snapshot,
selected_active_page_id.as_deref(),
@@ -521,6 +525,30 @@ pub async fn root_entry(
.clone()
.unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
let navigation_notice_html = render_navigation_guard_notice(&query);
let navigation_html = if active_page_id.trim().is_empty() {
if active_source_kind.as_deref() == Some("local_folder") {
active_root_uri
.as_deref()
.map(|root_uri| {
render_local_folder_navigation_page_html(
&state,
&context,
&actor_id,
root_uri,
file_tree_scope,
&navigation_notice_html,
)
})
.transpose()?
} else {
Some(render_workspace_navigation_page_html(
&navigation_notice_html,
))
}
} else {
None
};
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
@@ -530,28 +558,25 @@ pub async fn root_entry(
workspace_sidebar_html={workspace_sidebar_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy}
/>
})
};
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
let panes_bootstrap_json = serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": false,
"secondaryInvalid": false,
"panes": [],
}))
.unwrap_or_else(|_| "{}".to_string());
format!(
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
let panes_bootstrap_json = serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": false,
"secondaryInvalid": false,
"panes": [],
}))
.unwrap_or_else(|_| "{}".to_string());
let body_extra = format!(
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}"#,
escape_script_json(&panes_bootstrap_json),
render_editor_island_adapter_script(),
)
} else {
String::new()
};
escape_script_json(&panes_bootstrap_json),
render_editor_island_adapter_script(),
);
("MNOTE".to_string(), render_workspace_entry(), body_extra)
} else {
match build_page_aggregate_snapshot(
@@ -617,8 +642,7 @@ pub async fn root_entry(
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
}
};
let editor_runtime_preload_links = if body_extra.contains("document-editor-adapter-runtime.js")
{
let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") {
render_editor_runtime_preload_links()
} else {
""
@@ -651,6 +675,333 @@ pub async fn root_entry(
Ok(response)
}
fn render_workspace_navigation_page_html(notice_html: &str) -> String {
format!(
r#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="workspace" data-navigation-relative-path="">
<header class="mnote-navigation-page__header">
<h1>主页</h1>
</header>
{notice_html}
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
<h2>最近访问的文件夹</h2>
<p>暂无最近访问的文件夹。</p>
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
<h2>最近访问的页面</h2>
<p>暂无最近访问的页面。</p>
</section>
</section>"#
)
}
fn render_local_folder_navigation_page_html(
state: &AppState,
context: &RequestContext,
actor_id: &str,
root_uri: &str,
relative_path: Option<&str>,
notice_html: &str,
) -> Result<String, WebError> {
let relative_path = relative_path.map(str::trim).unwrap_or_default();
let snapshot = if relative_path.is_empty() {
load_local_folder_file_tree_snapshot(root_uri)?
} else {
load_local_folder_file_tree_children_snapshot(root_uri, relative_path)?
};
let items = snapshot
.projection
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let title = if relative_path.is_empty() {
"本地文件夹".to_string()
} else {
relative_path
.split('/')
.next_back()
.filter(|value| !value.trim().is_empty())
.unwrap_or(relative_path)
.to_string()
};
let mut folders = Vec::new();
let mut pages = Vec::new();
let mut resources = Vec::new();
for item in items {
let row_kind = item
.get("rowKind")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let title = item
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题");
let resource_meta = item.get("resourceMeta").and_then(Value::as_object);
let item_relative_path = item
.get("relativePath")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("workspacePath"))
.and_then(|workspace_path| workspace_path.get("relativePath"))
.and_then(Value::as_str)
})
.map(str::trim)
.unwrap_or_default();
let document_id = resource_meta
.and_then(|meta| meta.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
match row_kind {
"folder" | "directory" | "asset-folder" => folders.push(render_navigation_link(
title,
&navigation_folder_href(root_uri, item_relative_path),
"folder",
item_relative_path,
)),
"document" | "markdown" | "index" => {
if let Some(document_id) = document_id {
pages.push(render_navigation_link(
title,
&navigation_document_href(root_uri, relative_path, document_id),
"page",
item_relative_path,
));
}
}
_ => resources.push(render_navigation_link(
title,
"#",
"resource",
item_relative_path,
)),
}
}
record_navigation_recent_folder(state, actor_id, root_uri, relative_path, &title, None);
let recent_folders = crate::routes::navigation_recent::load_navigation_recent_records(
state,
context,
actor_id,
Some("folder"),
10,
);
let recent_pages = crate::routes::navigation_recent::load_navigation_recent_records(
state,
context,
actor_id,
Some("page"),
20,
);
Ok(format!(
r#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="folder" data-navigation-relative-path="{relative_path}">
<header class="mnote-navigation-page__header">
<h1>{title}</h1>
<p>{path_label}</p>
</header>
{notice_html}
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-folders">
<h2>文件夹</h2>
{folders}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-pages">
<h2>页面</h2>
{pages}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-resources">
<h2>资源</h2>
{resources}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
<h2>最近访问的文件夹</h2>
{recent_folders}
</section>
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
<h2>最近访问的页面</h2>
{recent_pages}
</section>
</section>"#,
relative_path = escape_html(relative_path),
title = escape_html(&title),
path_label = escape_html(if relative_path.is_empty() {
root_uri
} else {
relative_path
}),
notice_html = notice_html,
folders = if folders.is_empty() {
"<p>没有子文件夹。</p>".to_string()
} else {
folders.join("")
},
pages = if pages.is_empty() {
"<p>没有 Markdown 页面。</p>".to_string()
} else {
pages.join("")
},
resources = if resources.is_empty() {
"<p>没有资源。</p>".to_string()
} else {
resources.join("")
},
recent_folders = render_recent_navigation_items(&recent_folders),
recent_pages = render_recent_navigation_items(&recent_pages),
))
}
fn render_navigation_guard_notice(query: &RootEntryQuery) -> String {
let route_guard = query
.route_guard
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let missing_page = query
.missing_page
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(message) = route_guard.or(missing_page).map(|_| {
if route_guard == Some("local_workspace_access_denied") {
"当前账号无权访问原页面,已回到导航页。"
} else if route_guard == Some("local_folder_root_required") {
"原页面缺少本地文件夹信息,已回到导航页。"
} else {
"原页面已不存在,已回到当前文件夹导航页。"
}
}) else {
return String::new();
};
let target = missing_page.or(route_guard).unwrap_or_default();
format!(
r#"<div class="mnote-navigation-page__notice" data-testid="mnote-navigation-guard-notice" data-mnote-route-guard-target="{}">{}<span>{}</span></div>"#,
escape_html(target),
escape_html(message),
escape_html(target),
)
}
fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String {
if records.is_empty() {
return "<p>暂无最近访问。</p>".to_string();
}
records
.iter()
.map(|record| {
let href = if record.kind == "folder" {
navigation_folder_href(
&record.root_uri,
record.relative_path.as_deref().unwrap_or(""),
)
} else if let Some(document_id) = record.document_id.as_deref() {
navigation_document_href(
&record.root_uri,
parent_scope_from_relative_path(record.relative_path.as_deref().unwrap_or("")),
document_id,
)
} else {
"#".to_string()
};
render_navigation_link(
&record.title,
&href,
&record.kind,
record.relative_path.as_deref().unwrap_or(""),
)
})
.collect::<Vec<_>>()
.join("")
}
fn render_navigation_link(title: &str, href: &str, kind: &str, relative_path: &str) -> String {
format!(
r#"<a class="mnote-navigation-page__item" data-navigation-item-kind="{}" data-local-relative-path="{}" href="{}">{}</a>"#,
escape_html(kind),
escape_html(relative_path),
escape_html(href),
escape_html(title),
)
}
fn record_navigation_recent_folder(
state: &AppState,
actor_id: &str,
root_uri: &str,
relative_path: &str,
title: &str,
workspace_id: Option<&str>,
) {
if actor_id.trim().is_empty() || actor_id.trim() == "anonymous" || root_uri.trim().is_empty() {
return;
}
let _ = state
.control_plane()
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: actor_id.trim().to_string(),
workspace_id: workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: root_uri.trim().to_string(),
relative_path: Some(relative_path.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty()),
document_id: None,
title: title.trim().to_string(),
metadata_json: "{}".to_string(),
});
}
fn parent_scope_from_relative_path(relative_path: &str) -> &str {
relative_path
.trim()
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or("")
}
fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
let mut href = format!(
"/?sourceKind=local_folder&rootUri={}&treeView=filetree",
query_escape(root_uri)
);
if !relative_path.trim().is_empty() {
href.push_str("&fileTreeScope=");
href.push_str(&query_escape(relative_path));
}
href
}
fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id: &str) -> String {
let mut href = format!(
"/documents/{}?sourceKind=local_folder&rootUri={}&treeView=filetree",
query_escape(document_id),
query_escape(root_uri)
);
if !file_tree_scope.trim().is_empty() {
href.push_str("&fileTreeScope=");
href.push_str(&query_escape(file_tree_scope));
}
href
}
fn query_escape(value: &str) -> String {
value
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
vec![byte as char]
}
_ => format!("%{byte:02X}").chars().collect::<Vec<_>>(),
})
.collect()
}
pub async fn trash_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1606,7 +1957,7 @@ async fn resolve_root_workspace_id(
return Ok(workspace_id.to_string());
}
let bootstrap = execute_convex_mutation_by_name(
let bootstrap = execute_retired_mutation_by_name(
state.config(),
context,
"workspaces:ensureDefaultWorkspace",
@@ -2097,11 +2448,12 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
use tokio::net::TcpListener;
use tower::util::ServiceExt;
@@ -2334,7 +2686,7 @@ mod tests {
false,
None,
Some(
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.table","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
),
)
.oneshot(
@@ -2367,7 +2719,7 @@ mod tests {
assert!(html.contains(r#"data-resource-kind="table""#));
assert!(html.contains("已删附件.png"));
assert!(html.contains("已删思维导图.json"));
assert!(html.contains("已删表格.luckysheet"));
assert!(html.contains("已删表格.table"));
assert!(html.contains("new EventSource"));
assert!(html.contains("/api/tree/events"));
assert!(!html.contains("pollMs"));
@@ -2476,6 +2828,30 @@ mod tests {
assert_eq!(empty, None);
}
#[tokio::test]
async fn root_entry_unauthenticated_redirects_to_auth_with_next() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/?sourceKind=local_folder&rootUri=file:///tmp/mnote&treeView=filetree")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some(
"/auth?next=%2F%3FsourceKind%3Dlocal_folder%26rootUri%3Dfile%3A%2F%2F%2Ftmp%2Fmnote%26treeView%3Dfiletree"
)
);
}
#[tokio::test]
async fn root_entry_uses_recent_page_cookie_as_active_page() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -2580,8 +2956,10 @@ mod tests {
assert!(!html.contains("初始化的新页面"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
@@ -2641,7 +3019,7 @@ mod tests {
#[tokio::test]
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
let app_state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -2827,6 +3205,205 @@ mod tests {
assert!(!html.contains(r#"href="/tree"#));
}
#[tokio::test]
async fn root_entry_local_folder_without_page_renders_navigation_page_without_editor_bootstrap()
{
let root = temp_root("mnote-root-local-folder-navigation-page");
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");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init local workspace");
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("cookie", "mnote_recent_page_id=local-md:README.md")
.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-testid="mnote-navigation-page""#));
assert!(html.contains(r#"data-navigation-scope-kind="folder""#));
assert!(html.contains("README.md"));
assert!(html.contains("docs"));
assert!(html.contains("最近访问的文件夹"));
assert!(html.contains("最近访问的页面"));
assert!(!html.contains(r#"data-root-active-page-id="local-md:README.md""#));
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
assert!(!html.contains("__MNOTE_EDITOR_BOOTSTRAP__"));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(!html.contains("mnote-leptos-tiptap-spike-island"));
}
#[tokio::test]
async fn root_entry_local_folder_scope_renders_navigation_without_root_sibling_document() {
let root = temp_root("mnote-root-local-folder-scope-navigation-page");
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
.expect("create scoped tree");
std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page");
std::fs::write(root.join("design").join("Plan.md"), "# Plan\n").expect("write plan");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init local workspace");
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&fileTreeScope=design&missingPage=local-md%3Adesign%7E2FMissing.md"
))
.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-testid="mnote-navigation-page""#));
assert!(html.contains(r#"data-navigation-relative-path="design""#));
assert!(html.contains("Plan.md"));
assert!(html.contains("05-editor-mainline"));
assert!(html.contains(r#"data-testid="mnote-navigation-guard-notice""#));
assert!(html.contains("原页面已不存在"));
assert!(html.contains("local-md:design~2FMissing.md"));
assert!(!html.contains("Home.md"));
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
}
#[tokio::test]
async fn root_entry_local_folder_navigation_renders_user_recent_links() {
let root = temp_root("mnote-root-local-folder-navigation-recent");
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
std::fs::create_dir_all(root.join("archive")).expect("create archive");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init local workspace");
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: 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: 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(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("user_real".to_string()),
email: Some("user_real@example.com".to_string()),
username: "user_real".to_string(),
display_name: "user_real".to_string(),
role: None,
password_hash: None,
})
.expect("upsert user");
state
.control_plane()
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "user_real".to_string(),
workspace_id: None,
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: root_uri.clone(),
relative_path: Some("archive".to_string()),
document_id: None,
title: "archive".to_string(),
metadata_json: "{}".to_string(),
})
.expect("folder recent");
state
.control_plane()
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "user_real".to_string(),
workspace_id: None,
kind: "page".to_string(),
source_kind: "local_folder".to_string(),
root_uri: root_uri.clone(),
relative_path: Some("docs/Plan.md".to_string()),
document_id: Some("local-md:docs~2FPlan.md".to_string()),
title: "Plan".to_string(),
metadata_json: "{}".to_string(),
})
.expect("page recent");
let response = build_app(state)
.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-testid="mnote-navigation-recent-folders""#));
assert!(html.contains(r#"data-local-relative-path="archive""#));
assert!(html.contains("fileTreeScope=archive"));
assert!(html.contains(r#"data-testid="mnote-navigation-recent-pages""#));
assert!(html.contains(r#"href="/documents/local-md%3Adocs%7E2FPlan.md"#));
assert!(html.contains("fileTreeScope=docs"));
}
#[tokio::test]
async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() {
let root = temp_root("mnote-root-local-folder-page-tree-snapshot-once");
@@ -2859,7 +3436,9 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
crate::routes::local_folder_source::local_page_tree_snapshot_test_loads(),
crate::routes::local_folder_source::local_page_tree_snapshot_test_loads_for_root(
&root_uri
),
1,
"root entry should reuse the already loaded local page tree snapshot instead of scanning it twice",
);
@@ -2907,7 +3486,9 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
assert!(
html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)
);
assert!(
!html.contains(r#"data-node-id="local-md:Home.md""#),
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
@@ -2953,17 +3534,18 @@ mod tests {
assert!(html.contains(r#"data-row-id="local:folder:attachments""#));
assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
assert!(html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
assert!(!html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
assert!(html.contains(r#"data-pane-role="primary""#));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains(
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
));
assert!(html.contains(
assert!(!html.contains(
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
));
assert!(html.contains(
assert!(!html.contains(
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
));
assert!(
@@ -2991,7 +3573,7 @@ mod tests {
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/auth")
Some("/auth?next=%2F")
);
assert_eq!(
response
@@ -3090,12 +3672,14 @@ mod tests {
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert!(response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html"));
assert!(
response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html")
);
}
#[tokio::test]
@@ -3150,12 +3734,16 @@ mod tests {
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values.iter().any(|value| value.contains("mnote_session=")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_id=new-user")));
assert!(values
.iter()
.any(|value| value.contains("mnote_actor_type=user")));
assert!(
values
.iter()
.any(|value| value.contains("mnote_actor_id=new-user"))
);
assert!(
values
.iter()
.any(|value| value.contains("mnote_actor_type=user"))
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
@@ -3234,10 +3822,12 @@ mod tests {
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "auth_signup_email_required");
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱"));
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱")
);
}
#[tokio::test]
@@ -3261,9 +3851,11 @@ mod tests {
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
assert!(
values
.iter()
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))
);
}
#[tokio::test]
@@ -3369,12 +3961,16 @@ mod tests {
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(!values
.iter()
.any(|value| value.contains("__convexAuthJWT=")));
assert!(!values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=")));
assert!(
!values
.iter()
.any(|value| value.contains("__convexAuthJWT="))
);
assert!(
!values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken="))
);
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
}