feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user