chore: land tree view-state, vault, Pi module split, and repo hygiene

Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
+4
View File
@@ -18,6 +18,7 @@ use std::fs;
use std::sync::Arc;
#[cfg(not(test))]
use std::time::Duration;
use tower_http::compression::CompressionLayer;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
@@ -332,6 +333,9 @@ fn open_turso_synced_control_plane_store() -> Arc<dyn ControlPlaneStore> {
pub fn build_app(state: AppState) -> Router {
build_router(state)
// Outer layers run first on request / last on response. Compress HTML/JSON
// for large local-folder SSR shells (PageTree/FileTree).
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http().on_failure(()))
.layer(axum::middleware::from_fn(log_failed_response))
.layer(axum::middleware::from_fn(inject_request_context))
@@ -355,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 RAGFlowWeKnora/LightRAG 仅作为 legacy fallback",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGWeKnora/RAGFlow 仅作为 env 显式切换的备用 / 调试路径",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -405,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 RAGFlowWeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGWeKnora/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -38,7 +38,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "知识库问答",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 RAGFlow",
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
@@ -128,6 +128,26 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
],
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-vault",
title: "密码箱 / AI 密码本",
description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读取 .mnote/vault;凭证经 vault API / 共享到 AI 密码本。",
category: "security",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
// P1 vault tools (when registered); skill remains discoverable before tools land.
"mnote.vault.list",
"mnote.vault.get",
"mnote.vault.resolve",
"mnote.vault.login",
"mnote.vault.session",
],
content: include_str!("../../../../../skills/mnote-vault/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-chat-only",
title: "纯聊天",
@@ -361,6 +381,32 @@ mod tests {
.any(|name| name == "mnote.mindmap.create_from_outline"));
}
#[test]
fn skill_registry_exposes_vault_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-vault")
.expect("reasonix should see vault skill");
assert_eq!(skill["readOnly"], true);
assert_eq!(skill["category"], "security");
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.vault.resolve"));
assert!(hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-vault"));
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
let body = find_skill("mnote-vault", Some("hermes"))
.expect("hermes can read vault skill")
.content;
assert!(body.contains(".mnote/vault"));
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
}
#[test]
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
+169 -17
View File
@@ -510,16 +510,17 @@ pub async fn user_access_scopes(
/// `GET /api/ai-admin/access-scopes`
///
/// Admin-only variant. Validates that the current actor has admin
/// privileges via `is_local_access_policy_admin_context` before
/// returning directory grants.
/// privileges from the active control-plane session before
/// returning **all** directory grants (not only the admin actor's own).
/// Source of truth matches `/api/admin/access-policy` → `list_directory_grants()`.
pub async fn admin_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let _actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -528,8 +529,13 @@ pub async fn admin_access_scopes(
.with_context(&context));
}
// S3: admin SoT is the full grant table, not actor-scoped grants.
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
filter_directory_grants_to_access_scopes(grants, query.workspace_id.as_deref());
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
@@ -551,7 +557,7 @@ pub async fn admin_receipts(
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -629,7 +635,7 @@ pub async fn admin_directory_access_requests(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let requests = list_directory_access_requests(&state, None)?;
Ok(Json(json!({
"ok": true,
@@ -645,7 +651,7 @@ pub async fn approve_directory_access_request(
Json(_body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let request = find_pending_directory_access_request(&state, &request_id)?;
let user_id = request
.get("userId")
@@ -720,7 +726,7 @@ pub async fn reject_directory_access_request(
Json(body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let _ = find_pending_directory_access_request(&state, &request_id)?;
let metadata = json!({
"requestId": request_id,
@@ -901,7 +907,7 @@ pub async fn admin_get_settings(
Extension(context): Extension<RequestContext>,
) -> Result<Json<AdminAiSettingsResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let global_owner = global_policy_owner_id(&actor_id);
let policy = state
@@ -939,7 +945,7 @@ pub async fn admin_put_settings(
Json(body): Json<UpsertAiPolicyBody>,
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let global_owner = global_policy_owner_id(&actor_id);
// ── Validate provider secretRefs ──────────────────────────────────
@@ -1036,7 +1042,7 @@ pub async fn admin_list_users(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let users = state
.control_plane()
.list_users(500)
@@ -1049,7 +1055,7 @@ pub async fn admin_list_users(
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() },
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() },
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
@@ -1069,7 +1075,7 @@ pub async fn admin_get_user_settings(
Path(user_id): Path<String>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
let global_policy = load_policy_value(&state, &global_owner, None);
@@ -1092,7 +1098,7 @@ pub async fn admin_put_user_settings(
Json(body): Json<UserAiPolicyBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
if user_id == global_owner {
@@ -1567,12 +1573,30 @@ fn default_mcp_server_registry() -> HashMap<String, McpServerConfig> {
servers
}
fn policy_removed_default_ids(model_policy: &Value, key: &str) -> std::collections::HashSet<String> {
model_policy
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(|value| value.trim().to_string()))
.filter(|value| !value.is_empty())
.collect()
})
.unwrap_or_default()
}
fn effective_skill_registry(model_policy: &Value) -> HashMap<String, SkillConfig> {
let mut skills = default_skill_registry();
let configured = policy_map::<SkillConfig>(model_policy, "skills");
for (id, config) in configured {
skills.insert(id, config);
}
// S2: omit built-in defaults the admin explicitly deleted (tombstones).
for id in policy_removed_default_ids(model_policy, "removedDefaultSkills") {
skills.remove(&id);
}
skills
}
@@ -1582,6 +1606,9 @@ fn effective_mcp_registry(model_policy: &Value) -> HashMap<String, McpServerConf
for (id, config) in configured {
servers.insert(id, config);
}
for id in policy_removed_default_ids(model_policy, "removedDefaultMcpServers") {
servers.remove(&id);
}
servers
}
@@ -1594,6 +1621,9 @@ fn effective_pi_extension_registry(model_policy: &Value) -> HashMap<String, PiEx
}
extensions.insert(id, config);
}
for id in policy_removed_default_ids(model_policy, "removedDefaultPiExtensions") {
extensions.remove(&id);
}
extensions
}
@@ -1693,6 +1723,11 @@ fn is_configured_admin_user(user_id: &str) -> bool {
.unwrap_or(false)
}
fn is_admin_user_for_display(user_id: &str, stored_role: &str) -> bool {
local_folder_source::is_local_access_policy_admin_actor(user_id, stored_role)
|| is_configured_admin_user(user_id)
}
fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value {
load_model_policy_and_quota(state, actor_id, workspace_id).0
}
@@ -2238,8 +2273,8 @@ fn filter_directory_grants_to_access_scopes(
/// Ensures admin auth. Reuses the existing admin check from
/// `local_folder_source::is_local_access_policy_admin_context`.
fn ensure_admin(context: &RequestContext) -> Result<(), WebError> {
if !local_folder_source::is_local_access_policy_admin_context(context) {
fn ensure_admin(state: &AppState, context: &RequestContext) -> Result<(), WebError> {
if !crate::routes::gateway::current_actor_is_local_admin(state, context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -2634,13 +2669,22 @@ fn merge_policy_with_existing(
// The admin settings UI edits this registry as the desired full
// state. Re-merging deleted rows back from the persisted policy
// makes "删除" a no-op, so presence of this section means replace.
//
// S2: also write tombstones for built-in defaults omitted from the
// payload, so effective_*_registry does not rehydrate ghosts.
if let Some(ref skills) = body.skills {
let mut merged = serde_json::Map::new();
for (k, v) in skills {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_skill_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["skills"] = Value::Object(merged);
model_policy["removedDefaultSkills"] = json!(removed_defaults);
}
// ── Replace MCP servers map ─────────────────────────────────────
@@ -2653,7 +2697,13 @@ fn merge_policy_with_existing(
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_mcp_server_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["mcpServers"] = Value::Object(merged);
model_policy["removedDefaultMcpServers"] = json!(removed_defaults);
}
if let Some(ref extensions) = body.pi_extensions {
@@ -2662,7 +2712,13 @@ fn merge_policy_with_existing(
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_pi_extension_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["piExtensions"] = Value::Object(merged);
model_policy["removedDefaultPiExtensions"] = json!(removed_defaults);
}
let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into());
@@ -3253,6 +3309,79 @@ mod tests {
assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true);
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
assert!(parsed["piExtensions"].as_object().unwrap().is_empty());
// S2: omitting a built-in default from the replace payload tombs it.
let removed_skills = parsed["removedDefaultSkills"]
.as_array()
.expect("removedDefaultSkills");
assert!(
removed_skills.iter().any(|v| v.as_str() == Some("vpn")),
"vpn default skill should be tombstoned when omitted: {parsed}"
);
let removed_mcp = parsed["removedDefaultMcpServers"]
.as_array()
.expect("removedDefaultMcpServers");
assert!(
removed_mcp
.iter()
.any(|v| v.as_str() == Some("context7")),
"context7 default MCP should be tombstoned when omitted: {parsed}"
);
}
/// S2: deleted default skills/MCP must not reappear via effective registries.
#[test]
fn effective_registries_honor_removed_default_tombstones() {
let policy = json!({
"skills": {
"custom-only": {
"name": "Custom Only",
"enabled": true,
"description": "",
"source": "/tmp/custom/SKILL.md",
"riskLevel": "low",
"requiredScopes": []
}
},
"removedDefaultSkills": ["vpn", "chrome-bridge"],
"mcpServers": {
"custom-mcp": {
"name": "Custom MCP",
"enabled": true,
"url": "",
"transport": "stdio",
"command": "custom-mcp",
"networkPolicy": "deny-all",
"secretRefs": [],
"facadeOnly": true,
"sandbox": true,
"description": "",
"riskLevel": "medium",
"requiredScopes": []
}
},
"removedDefaultMcpServers": ["context7", "codegraph"]
});
let skills = effective_skill_registry(&policy);
assert!(skills.contains_key("custom-only"));
assert!(
!skills.contains_key("vpn"),
"tombstoned default skill must not rehydrate"
);
assert!(!skills.contains_key("chrome-bridge"));
// Untombstoned defaults still present.
assert!(skills.contains_key("context7") || skills.contains_key("searxng"));
let mcps = effective_mcp_registry(&policy);
assert!(mcps.contains_key("custom-mcp"));
assert!(!mcps.contains_key("context7"));
assert!(!mcps.contains_key("codegraph"));
// Admin projection must match effective (no ghost defaults).
let admin = project_admin_settings(&policy, &json!({}), None);
assert!(!admin.skills.contains_key("vpn"));
assert!(admin.skills.contains_key("custom-only"));
assert!(!admin.mcp_servers.contains_key("context7"));
assert!(admin.mcp_servers.contains_key("custom-mcp"));
}
// ─── Admin projection ────────────────────────────────────────────────
@@ -3479,4 +3608,27 @@ mod tests {
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
"mnote-ai-settings-display-admin-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&policy_root);
std::fs::create_dir_all(&policy_root).expect("create policy root");
let policy_file = policy_root.join("access-policy.json");
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
.expect("write access policy");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
assert!(is_admin_user_for_display("liaibo", "user"));
assert!(!is_admin_user_for_display("shujuan", "user"));
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
let _ = std::fs::remove_dir_all(&policy_root);
}
}
+551 -21
View File
@@ -4,9 +4,10 @@ use crate::error::WebError;
use crate::provider_identity_sync::sync_provider_identities;
use crate::routes::local_folder_source::{
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,
is_local_access_policy_admin_actor, 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_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
@@ -15,8 +16,8 @@ use crate::routes::web_shell::{
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_editor_runtime_preload_links, render_local_file_tree_html,
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot,
render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html,
render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
use crate::workspace_shell::{
@@ -188,7 +189,7 @@ pub async fn admin_access_policy_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_admin_required",
@@ -240,7 +241,7 @@ pub async fn admin_ai_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_required",
@@ -267,7 +268,7 @@ pub async fn settings_entry(
ai_management_response(
&state,
&context,
is_local_access_policy_admin_context(&context),
current_actor_is_local_admin(&state, &context),
)
}
@@ -410,10 +411,15 @@ pub async fn root_entry(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
// Reveal only the active page path; keep SSR PageTree shallow (Sidex-aligned).
let page_tree_snapshot = if let Some(scope) = file_tree_scope {
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
requested_page_id.as_deref(),
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())?
};
let workspace_id = page_tree_snapshot
.dataset
@@ -452,12 +458,19 @@ pub async fn root_entry(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let file_tree_html = render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?;
// Shell-first: when landing on PageTree (default), do not block home SSR on FileTree scan.
// treeView=filetree (or restore focus into file rows) still needs synchronous FileTree HTML.
let needs_sync_file_tree = requests_filetree_first || restore_focus_row_id.is_some();
let file_tree_html = if needs_sync_file_tree {
render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -480,7 +493,8 @@ pub async fn root_entry(
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
})?
.to_string();
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
let snapshot =
load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?;
let workspace_id = snapshot
.dataset
.get("workspace")
@@ -511,8 +525,12 @@ pub async fn root_entry(
&snapshot,
selected_active_page_id.as_deref(),
);
let file_tree_html =
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
// Local-first landing also prefers shell-first FileTree (hydrate after paint).
let file_tree_html = if requests_filetree_first {
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -593,7 +611,11 @@ pub async fn root_entry(
.active_page_title
.clone()
.unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
let breadcrumb_html = crate::workspace_shell::render_page_breadcrumb_html(
&workspace_projection,
Some(active_page_id.as_str()),
);
let show_admin_access_policy = current_actor_is_local_admin(&state, &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") {
@@ -618,6 +640,10 @@ pub async fn root_entry(
} else {
None
};
let vault_nav_href = vault_nav_href_for_context(
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
@@ -625,11 +651,13 @@ pub async fn root_entry(
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
breadcrumb_html={breadcrumb_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}
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
vault_nav_href={vault_nav_href.clone()}
/>
})
};
@@ -708,6 +736,7 @@ document.body.appendChild(s);
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={true}
vault_nav_href={vault_nav_href.clone()}
/>
});
let body_extra = format!(
@@ -1113,6 +1142,304 @@ fn query_escape(value: &str) -> String {
.collect()
}
/// SSR vault nav href with sourceKind+rootUri so first click keeps local_folder context.
pub(crate) fn vault_nav_href_for_context(source_kind: Option<&str>, root_uri: Option<&str>) -> String {
let root_uri = root_uri.map(str::trim).filter(|value| !value.is_empty());
let source_kind = source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| root_uri.map(|_| "local_folder"));
match (source_kind, root_uri) {
(Some(source_kind), Some(root_uri)) => format!(
"/vault?sourceKind={}&rootUri={}",
query_escape(source_kind),
query_escape(root_uri)
),
(Some(source_kind), None) => {
format!("/vault?sourceKind={}", query_escape(source_kind))
}
(None, Some(root_uri)) => format!(
"/vault?sourceKind=local_folder&rootUri={}",
query_escape(root_uri)
),
(None, None) => "/vault".to_string(),
}
}
/// GET /files → permanent product surface is /vault (left-rail password vault).
pub async fn files_redirect_to_vault(
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
let mut location = String::from("/vault");
let mut parts: Vec<String> = Vec::new();
if let Some(root_uri) = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("rootUri={}", query_escape(root_uri)));
}
if let Some(source_kind) = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("sourceKind={}", query_escape(source_kind)));
} else if !parts.is_empty() {
parts.push("sourceKind=local_folder".to_string());
}
if let Some(workspace_id) = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("workspaceId={}", query_escape(workspace_id)));
}
if !parts.is_empty() {
location.push('?');
location.push_str(&parts.join("&"));
}
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, location)
.body(Body::empty())
.map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
/// GET /vault — dedicated password vault workbench (local_folder only for P0).
pub async fn vault_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !crate::routes::vault::vault_feature_enabled() {
return Err(crate::routes::vault::vault_disabled_error().with_context(&context));
}
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
// Product path: local_folder workspace with rootUri.
if source_kind == Some("local_folder") || root_uri.is_some() {
let root_uri = root_uri.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
// Best-effort ensure vault dirs so first paint has a writable layout.
let _ = crate::routes::vault_store::ensure_vault_directories(&workspace_root);
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection =
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
let bootstrap = crate::routes::vault::bootstrap_list_json(
&workspace_root,
crate::routes::vault_store::VaultItemStatus::Active,
)
.unwrap_or_else(|_| {
json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
})
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, root_uri, &bootstrap);
let vault_nav_href = vault_nav_href_for_context(Some("local_folder"), Some(root_uri));
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
vault_nav_href={vault_nav_href.clone()}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}" data-mnote-page="vault">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(root_uri),
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
return Ok(response);
}
// Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap).
// Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503.
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let workspace_id = normalize_optional_id(query.workspace_id.as_deref())
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
.map(ToOwned::to_owned)
.unwrap_or_else(|| "local-folder".to_string());
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
&workspace_dataset,
&workspace_id,
None,
&default_workspace_name,
);
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(""),
Some(""),
None,
None,
);
let empty_bootstrap = json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
"needsLocalFolder": true,
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, "", &empty_bootstrap);
let workspace_name = workspace_projection.workspace_name.clone();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={String::new()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-page="vault" data-mnote-vault-needs-folder="1">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
Ok(response)
}
fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &Value) -> String {
let bootstrap_raw = bootstrap.to_string();
let bootstrap_json = escape_script_json(&bootstrap_raw);
let runtime_src = crate::routes::web_shell::mnote_browser_runtime_src("vault-workbench-runtime.js");
format!(
r#"<section class="mnote-vault-workbench" data-testid="mnote-vault-workbench" data-workspace-id="{workspace_id}" data-root-uri="{root_uri_esc}" data-status="active">
<header class="mnote-vault-header">
<div class="mnote-vault-header-main">
<h1>密码箱</h1>
<p class="mnote-vault-status" data-vault-status role="status" aria-live="polite"></p>
</div>
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create">新建</button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
<div class="mnote-vault-tabs" role="tablist">
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
<button type="button" role="tab" data-vault-tab="deleted" aria-selected="false">已删除</button>
</div>
</div>
</header>
<div class="mnote-vault-body">
<aside class="mnote-vault-list" data-vault-list data-testid="vault-list" aria-label="密码条目列表"></aside>
<main class="mnote-vault-detail" data-vault-detail data-testid="vault-detail" aria-label="条目详情"></main>
</div>
<script type="application/json" id="__MNOTE_VAULT_BOOTSTRAP__">{bootstrap_json}</script>
<script src="{runtime_src}" defer></script>
</section>"#,
workspace_id = escape_html(workspace_id),
root_uri_esc = escape_html(root_uri),
bootstrap_json = bootstrap_json,
runtime_src = runtime_src,
)
}
pub async fn trash_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1147,7 +1474,7 @@ pub async fn trash_entry(
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
@@ -2155,6 +2482,16 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
context.auth.actor_type.trim().to_string()
}
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
return is_local_access_policy_admin_actor(&resolved.user.id, &resolved.user.role);
}
}
is_local_access_policy_admin_context(context)
}
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
@@ -3232,6 +3569,104 @@ mod tests {
assert!(!html.contains("开发用户 的空间"));
}
#[tokio::test]
async fn control_plane_user_session_overrides_stale_admin_actor_cookies_for_settings() {
use control_plane::{session_token_hash, CreateSessionInput};
let app_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,
enable_page_ai_pi_lab: 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(),
});
app_state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("normal-user-session".into()),
email: Some("normal@example.com".into()),
username: "normal-user-session".into(),
display_name: "普通用户".into(),
role: Some("user".into()),
password_hash: None,
})
.expect("upsert control-plane user");
app_state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "normal-user-session".into(),
token_hash: session_token_hash("normal-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create control-plane session");
let app = build_app(app_state);
let stale_admin_cookie =
"mnote_session=normal-session-token; mnote_actor_id=stale-admin; mnote_actor_type=admin";
let settings_response = app
.clone()
.oneshot(
Request::builder()
.uri("/settings")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("settings response");
assert_eq!(settings_response.status(), StatusCode::OK);
let body = to_bytes(settings_response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-ai-admin-role=\"user\""));
assert!(!html.contains("data-ai-admin-role=\"admin\""));
assert!(!html.contains("href=\"#ai-admin-users\""));
let admin_page_response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/ai")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin page response");
assert_eq!(admin_page_response.status(), StatusCode::FORBIDDEN);
let admin_api_response = app
.oneshot(
Request::builder()
.uri("/api/ai-admin/users")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin api response");
assert_eq!(admin_api_response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_access_policy_entry_requires_admin_actor() {
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -3780,10 +4215,105 @@ mod tests {
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
// Vault left-rail should carry local_folder context in SSR href (no full refresh to bare /vault).
// Leptos HTML-escapes `&` as `&amp;` in attribute values — match fragments, not full attribute.
let vault_href_ok = html.contains(r#"data-testid="mnote-nav-vault""#)
&& html.contains("/vault?sourceKind=local_folder")
&& html.contains("rootUri=");
assert!(
vault_href_ok,
"vault nav SSR href must include sourceKind+rootUri: {}",
html.lines()
.find(|line| line.contains("mnote-nav-vault"))
.unwrap_or("(no vault nav line)")
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/vault")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(
response.status(),
StatusCode::OK,
"bare /vault must not 503 via retired cloud workspace bootstrap"
);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
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-page="vault""#)
|| html.contains(r#"data-testid="mnote-vault-workbench""#)
|| html.contains("密码箱"),
"bare vault should render password vault shell: {}",
&html[..html.len().min(500)]
);
assert!(
html.contains(r#"data-mnote-vault-needs-folder="1""#)
|| html.contains("needsLocalFolder"),
"bare vault should signal needs local folder"
);
}
#[tokio::test]
async fn root_entry_local_folder_accepts_gzip_encoding() {
let root = temp_root("mnote-root-local-folder-gzip");
std::fs::write(root.join("README.md"), "# Gzip\n").expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!("/?sourceKind=local_folder&rootUri={root_uri}"))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.header(header::ACCEPT_ENCODING, "gzip, br")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let encoding = response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
// CompressionLayer may skip tiny bodies; accept either gzip/br or uncompressed OK.
assert!(
encoding.is_empty() || encoding.contains("gzip") || encoding.contains("br"),
"unexpected content-encoding: {encoding}"
);
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
+18 -2
View File
@@ -4,7 +4,9 @@ use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot, load_local_folder_page_tree_snapshot_with_reveal,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
@@ -121,7 +123,21 @@ async fn project_projection(
load_local_folder_file_tree_snapshot(&root_uri)
}
} else if let Some(parent_relative_path) = parent_relative_path.as_deref() {
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
// Children fetch stays shallow; only reveal when root_node_id is present.
if root_node_id.is_some() {
load_local_folder_page_tree_scope_snapshot_with_reveal(
&root_uri,
parent_relative_path,
root_node_id.as_deref(),
)
} else {
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
}
} else if root_node_id.is_some() {
load_local_folder_page_tree_snapshot_with_reveal(
&root_uri,
root_node_id.as_deref(),
)
} else {
load_local_folder_page_tree_snapshot(&root_uri)
}
File diff suppressed because it is too large Load Diff
@@ -197,7 +197,21 @@ fn parse_markdown_attachment_link_with_paths(
{
return None;
}
let target_path = std::path::Path::new(target);
if page_reference_from_target(target, label).is_some()
&& !attachment_paths.contains(target)
&& !is_local_markdown_asset_href(target)
{
return None;
}
let target_without_fragment = target
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(target);
let target_path_part = target_without_fragment
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(target_without_fragment);
let target_path = std::path::Path::new(target_path_part);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
&& !attachment_paths.contains(target)
@@ -320,7 +334,40 @@ fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usiz
fn should_collect_attachment_href(raw_href: &str) -> bool {
let trimmed = raw_href.trim();
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("mailto:") {
return false;
}
page_reference_from_target(trimmed, "").is_none() || is_local_markdown_asset_href(trimmed)
}
fn is_local_markdown_asset_href(raw_href: &str) -> bool {
let trimmed = raw_href
.trim()
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_href.trim());
let without_fragment = trimmed
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(trimmed);
let without_query = without_fragment
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(without_fragment);
let normalized = without_query
.strip_prefix("./")
.unwrap_or(without_query)
.replace('\\', "/");
let extension = Path::new(&normalized)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
if !matches!(extension.as_deref(), Some("md" | "markdown")) {
return false;
}
normalized
.split('/')
.any(|segment| segment == ".assets" || segment.ends_with(".assets"))
}
fn build_attachment_ref(
@@ -686,6 +733,9 @@ fn append_ast_paragraph<'a>(
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
}
if append_paragraph_attachment_sequence(node, blocks, attachment_paths) {
return;
}
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
@@ -716,6 +766,9 @@ fn append_ast_list_item<'a>(
if children.next().is_none()
&& matches!(paragraph.data.borrow().value, NodeValue::Paragraph)
{
if append_paragraph_attachment_sequence(paragraph, blocks, attachment_paths) {
return;
}
if let Some((title, source_path)) = paragraph_page_reference(paragraph) {
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
@@ -724,13 +777,49 @@ fn append_ast_list_item<'a>(
}
}
let mut content = Vec::new();
let mut emitted_child_blocks = false;
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks, attachment_paths),
NodeValue::Paragraph => {
let mut paragraph_blocks = Vec::new();
if append_paragraph_attachment_sequence(
child,
&mut paragraph_blocks,
attachment_paths,
) {
if !content.is_empty() {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
content = Vec::new();
}
blocks.extend(paragraph_blocks);
emitted_child_blocks = true;
} else {
content.extend(collect_inline_children(child));
}
}
_ => {
if !content.is_empty() {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
content = Vec::new();
}
append_ast_block(child, blocks, attachment_paths);
emitted_child_blocks = true;
}
}
}
if !content.is_empty() || !emitted_child_blocks {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
}
}
fn push_list_item_content_block(
blocks: &mut Vec<MarkdownBlock>,
ordered: bool,
is_task: bool,
checked: bool,
content: Vec<MarkdownInline>,
) {
if is_task {
blocks.push(MarkdownBlock::Todo { checked, content });
} else if ordered {
@@ -876,6 +965,39 @@ fn paragraph_attachment_media<'a>(
link_attachment_media(first, attachment_paths)
}
fn append_paragraph_attachment_sequence<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) -> bool {
let mut parsed = Vec::<MarkdownBlock>::new();
for child in node.children() {
match &child.data.borrow().value {
NodeValue::SoftBreak | NodeValue::LineBreak => continue,
NodeValue::Text(text) if text.as_ref().trim().is_empty() => continue,
NodeValue::Image(link) => parsed.push(MarkdownBlock::Image {
alt: collect_plain_text(child).trim().to_string(),
source_path: link.url.clone(),
}),
NodeValue::Link(_) => {
if let Some((name, source_path)) = link_attachment_media(child, attachment_paths) {
parsed.push(MarkdownBlock::Media { name, source_path });
} else if let Some((title, source_path)) = link_page_reference(child) {
parsed.push(MarkdownBlock::PageReference { title, source_path });
} else {
return false;
}
}
_ => return false,
}
}
if parsed.is_empty() {
return false;
}
blocks.extend(parsed);
true
}
fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
@@ -982,6 +1104,9 @@ fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> {
let target = normalized_markdown_link_target(target)?;
if let Some(source_path) = local_markdown_relative_path_from_documents_target(target) {
return Some(page_reference_title_and_path(&source_path, label));
}
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
@@ -1004,20 +1129,59 @@ fn page_reference_from_target(target: &str, label: &str) -> Option<(String, Stri
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
let fallback_title = std::path::Path::new(path_part)
Some(page_reference_title_and_path(path_part, label))
}
fn page_reference_title_and_path(source_path: &str, label: &str) -> (String, String) {
let fallback_title = std::path::Path::new(source_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("页面")
.trim();
let title = label.trim();
Some((
(
if title.is_empty() {
fallback_title.to_string()
} else {
title.to_string()
},
path_part.to_string(),
))
source_path.to_string(),
)
}
fn local_markdown_relative_path_from_documents_target(target: &str) -> Option<String> {
let normalized = target.trim();
let path_and_query = if normalized.starts_with("/documents/") {
normalized
} else if let Some((_, rest)) = normalized.split_once("://") {
let slash = rest.find('/')?;
&rest[slash..]
} else {
return None;
};
let path_part = path_and_query
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(path_and_query)
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(path_and_query);
let segment = path_part.strip_prefix("/documents/")?;
let decoded_segment = percent_decode_lossy(segment);
let local_id = decoded_segment.strip_prefix("local-md:")?;
let relative_path = percent_decode_lossy(&local_id.replace('~', "%"))
.trim_start_matches('/')
.to_string();
if relative_path.is_empty() {
return None;
}
let extension = std::path::Path::new(&relative_path)
.extension()
.and_then(|value| value.to_str())?;
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
Some(relative_path)
}
fn normalized_markdown_link_target(target: &str) -> Option<&str> {
@@ -1363,6 +1527,28 @@ mod tests {
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_consecutive_attachment_links_parse_as_blocks() {
let blocks = markdown_to_blocks(
"[身份证](.assets/身份证_李爱波.pdf)\n[本科证书](.assets/本科证书证明.pdf)\n![照片](.assets/photo.png)\n",
);
let items = blocks.as_array().expect("blocks array");
assert_eq!(items.len(), 3);
assert_eq!(items[0]["type"].as_str(), Some("media"));
assert_eq!(
items[0]["props"]["sourcePath"].as_str(),
Some(".assets/身份证_李爱波.pdf")
);
assert_eq!(items[1]["type"].as_str(), Some("media"));
assert_eq!(
items[1]["props"]["sourcePath"].as_str(),
Some(".assets/本科证书证明.pdf")
);
assert_eq!(items[2]["type"].as_str(), Some("image"));
assert_eq!(items[2]["props"]["src"].as_str(), Some(".assets/photo.png"));
}
#[test]
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
@@ -1411,6 +1597,46 @@ mod tests {
assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md"));
}
#[test]
fn markdown_runtime_local_documents_url_parses_as_page_reference_block() {
let blocks = markdown_to_blocks(
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
);
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("page_reference"));
assert_eq!(first["props"]["title"].as_str(), Some("爱斯特完结项目"));
assert_eq!(
first["props"]["sourcePath"].as_str(),
Some("liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md")
);
}
#[test]
fn markdown_runtime_local_documents_url_is_not_attachment_ref() {
let root = std::env::temp_dir().join(format!(
"mnote-page-ref-attachment-filter-{}",
std::process::id()
));
let owner_dir = root.join("liaibo的个人空间/项目/完结项目");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
let owner = owner_dir.join("完结项目.md");
let refs = parse_markdown_attachment_refs(
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert!(
refs.is_empty(),
"内部页面链接不能进入 attachmentRefs: {refs:?}"
);
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
+68
View File
@@ -42,6 +42,9 @@ mod tree;
mod tree_view_state;
mod ui_debug;
pub(crate) mod ui_preferences;
mod vault;
mod vault_path;
mod vault_store;
pub(crate) mod web_shell;
mod ws;
@@ -75,6 +78,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/health", get(health::health))
.route("/", get(gateway::root_entry))
.route("/trash", get(gateway::trash_entry))
.route("/vault", get(gateway::vault_entry))
.route("/files", get(gateway::files_redirect_to_vault))
.route("/favicon.ico", get(gateway::favicon))
.route("/settings", get(gateway::settings_entry))
.route(
@@ -370,6 +375,64 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
get(web_shell::document_editor_adapter_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
get(web_shell::vault_workbench_runtime_asset),
)
.route("/api/vault/ensure", post(vault::ensure))
.route("/api/vault/reindex", post(vault::reindex))
.route("/api/vault/list", get(vault::list))
.route("/api/vault/items", post(vault::create_item))
.route(
"/api/vault/items/{id}",
get(vault::get_item)
.patch(vault::update_item)
.delete(vault::delete_item),
)
.route(
"/api/vault/items/{id}/restore",
post(vault::restore_item),
)
.route("/api/vault/items/{id}/purge", post(vault::purge_item))
.route(
"/api/vault/items/{id}/reveal",
post(vault::reveal_item),
)
.route(
"/api/vault/items/{id}/resolve",
post(vault::resolve_item),
)
.route(
"/api/vault/items/{id}/share-to-ai",
post(vault::share_to_ai),
)
.route(
"/api/vault/items/{id}/unshare-from-ai",
post(vault::unshare_from_ai),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
"/api/vault/ai/items/{id}/resolve",
post(vault::resolve_ai_item),
)
.route(
"/api/vault/ai/items/{id}/login",
post(vault::login_ai_item),
)
.route(
"/api/vault/ai/items/{id}/session",
post(vault::put_ai_session),
)
.route("/api/vault/cipher-book", get(vault::list_cipher_book))
.route(
"/api/vault/cipher-book/{key}",
put(vault::put_cipher_key).delete(vault::delete_cipher_key),
)
.route(
"/api/vault/cipher-book/{key}/reveal",
post(vault::reveal_cipher_key),
)
.route("/api/search/documents", post(search::documents))
.route(
"/api/search/local-index/refresh",
@@ -859,6 +922,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/documents/buffer-state/dirty",
post(documents::mark_buffer_dirty),
)
.route(
"/api/documents/buffer-state/dirty:0",
post(documents::mark_buffer_dirty),
)
.route("/api/documents/purge", post(documents::purge))
.route("/api/documents/empty-trash", post(documents::empty_trash))
.route("/api/documents/title", post(documents::title))
@@ -1624,6 +1691,7 @@ mod tests {
"/api/mnote-browser-runtime/document-slash-position-runtime.js",
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
] {
let response = app(false)
.oneshot(
@@ -0,0 +1,43 @@
//! Pi Page AI 常量与 schema 名(产品口径,非 spike)。
pub(super) const PI_LAB_VERSION: &str = "1.0.0";
pub(super) const PI_LAB_PROVIDER: &str = "pi";
pub(super) const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
pub(super) const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
pub(super) const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
pub(super) const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
pub(super) const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
pub(super) const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
pub(super) const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
pub(super) const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
pub(super) const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
pub(super) const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
pub(super) const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
pub(super) const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
pub(super) const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
pub(super) const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
pub(super) const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
pub(super) const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
pub(super) const PI_LAB_MAX_SESSIONS: usize = 16;
pub(super) const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
pub(super) const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
pub(super) const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
pub(super) const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
pub(super) const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
pub(super) const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
pub(super) const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
pub(super) const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
pub(super) const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
pub(super) const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
@@ -0,0 +1,12 @@
//! Pi Rust Page AI route module.
//!
//! - [`constants`]:版本 / schema / profile 常量
//! - [`runtime`]HTTP handlers、RPC、session 热缓存与桥接
//!
//! 会话元数据权威源:Turso/libSQL control-plane。
//! 进程内 `HashMap` 仅为热缓存;重启后由 `hydrate_session_from_control_plane` 恢复元数据。
mod constants;
mod runtime;
pub use runtime::*;
@@ -1,7 +1,8 @@
//! Pi-first Page AI Lab — MNote 托管的后端垂直切片
//! Pi Rust Page AI — MNote 托管的生产 Page AI 后端。
//!
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端OpenHub 与 Pi TS 已退役到 recycle 边界。
//! 默认启用`MNOTE_PAGE_AI_PI_LAB`OpenHub 与 Pi TS 已退役到 recycle 边界。
//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。
//! 会话元数据 / run / tool event 持久化到 Turso/libSQL control-plane;进程内表仅作热缓存。
use crate::app::AppState;
use crate::context::RequestContext;
@@ -35,52 +36,13 @@ use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex};
use tokio_stream::wrappers::BroadcastStream;
const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike";
const PI_LAB_PROVIDER: &str = "pi";
const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
const PI_LAB_MAX_SESSIONS: usize = 16;
const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
use super::constants::*;
static PI_LAB_SESSIONS: LazyLock<StdMutex<HashMap<String, PiLabSession>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_PROCESSES: LazyLock<StdMutex<HashMap<String, PiLabProcessHandle>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
/// 热缓存;权威查询走 control-plane `list_ai_tool_events` / `list_ai_file_patches`。
static PI_LAB_RECEIPT_STORE: LazyLock<StdMutex<Vec<PiLabToolReceipt>>> =
LazyLock::new(|| StdMutex::new(Vec::new()));
static PI_LAB_RATE_LIMITS: LazyLock<StdMutex<HashMap<String, Vec<u128>>>> =
@@ -297,6 +259,9 @@ pub struct PiLabSendRequest {
pub folder_path: Option<String>,
pub context_refs: Option<Vec<String>>,
pub selected_context: Option<Value>,
/// S7: optional agent target package (mnote.agent_target_package.v1) from Page AI host.
#[serde(default)]
pub target_package: Option<Value>,
}
#[derive(Debug, Deserialize)]
@@ -787,6 +752,8 @@ fn resolve_file_path(
} else {
path.to_string()
};
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(&relative_path)
.map_err(|error| error.with_context(context))?;
let target =
resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?;
return Ok((target, Some(root_uri.to_string()), Some(relative_path)));
@@ -800,6 +767,18 @@ fn resolve_file_path(
)
.with_context(context));
}
// Absolute-path tools: deny any path whose workspace-relative form hits vault.
let normalized_abs = path.replace('\\', "/");
if let Some(idx) = normalized_abs.find("/.mnote/vault") {
let tail = &normalized_abs[idx + 1..]; // drop leading '/'
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(tail)
.map_err(|error| error.with_context(context))?;
} else if crate::routes::vault_path::is_vault_sensitive_relative_path(&normalized_abs) {
return Err(crate::routes::vault_path::vault_path_denied_error(
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
)
.with_context(context));
}
let allowed = active_allowed_roots(state, context)?;
let target = canonical_or_parent(&requested);
let allowed_root = allowed
@@ -1545,11 +1524,19 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
| "mnote.reference.open"
| "mnote.vault.list"
| "mnote.vault.get"
| "mnote.tool_receipt.write" => "allow".into(),
// plan: no secret resolve/login
_ => "deny".into(),
},
Some("auto_edit") => match tool_name {
"mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(),
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
"mnote.codex_rescue.request" => "ask".into(),
_ => base_policy.into(),
},
@@ -1564,12 +1551,52 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
"mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => {
"ask".into()
}
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
_ => base_policy.into(),
},
_ => match tool_name {
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
_ => base_policy.into(),
},
_ => base_policy.into(),
}
}
/// Strip secret values before control-plane receipt persistence.
fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
if tool_name != "mnote.vault.resolve"
&& tool_name != "mnote.vault.login"
&& tool_name != "mnote.vault.session"
{
return payload.clone();
}
let mut safe = payload.clone();
if let Some(obj) = safe.as_object_mut() {
if obj.contains_key("value") {
obj.insert("value".into(), Value::String("[redacted]".into()));
}
if obj.contains_key("cookieHeader") {
obj.insert("cookieHeader".into(), Value::String("[redacted]".into()));
}
if let Some(secret) = obj.get_mut("secret") {
if let Some(s) = secret.as_object_mut() {
if s.contains_key("value") {
s.insert("value".into(), Value::String("[redacted]".into()));
}
}
}
obj.insert("receiptRedacted".into(), Value::Bool(true));
}
safe
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session
.runtime_policy_snapshot
@@ -2321,6 +2348,36 @@ fn pi_lab_tool_definitions() -> Vec<PiLabToolDefinition> {
label: "MNote Codex rescue",
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_list",
mnote_name: "mnote.vault.list",
label: "MNote AI vault list",
description: "List credentials in the shared AI password book (L0 metadata only, no secret plaintext). Multi-agent single credential pool. Optional status=active|deleted. Do NOT use generic file tools on .mnote/vault.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_get",
mnote_name: "mnote.vault.get",
label: "MNote AI vault get",
description: "Get one AI password-book item by id (secrets masked). Params: id. Use mnote.vault.resolve to obtain password/apikey/token plaintext for automation.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_resolve",
mnote_name: "mnote.vault.resolve",
label: "MNote AI vault resolve",
description: "Resolve a secret field from the shared AI password book with cipher-book expansion. Params: id, field=password|apikey|token. Prefer mnote.vault.login for site login (session reuse). Do not paste value into chat.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_login",
mnote_name: "mnote.vault.login",
label: "MNote AI vault login",
description: "ONE-SHOT multi-agent login for AI password book. Params: id, optional forceRefresh. Reuses saved session if fresh; otherwise api_first password login and saves session. If Cloudflare/captcha: returns human_required — human uses chrome-bridge/Paseo browser then mnote.vault.session. Prefer this over list+resolve+browser for logins. Do not paste cookieHeader/password into chat.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_session",
mnote_name: "mnote.vault.session",
label: "MNote AI vault session write-back",
description: "Write browser-captured cookies into AI password book after human Cloudflare/captcha login. Params: id, cookieHeader, optional expiresAt, source=human_bridge|browser. Next mnote.vault.login will reuse them.",
},
PiLabToolDefinition {
pi_name: "mnote_tool_receipt_write",
mnote_name: "mnote.tool_receipt.write",
@@ -3028,6 +3085,96 @@ fn publish_event(session_id: &str, kind: &str, payload: Value) {
let _ = PI_LAB_EVENT_TX.send(event);
}
fn status_from_persisted(status: &str) -> PiLabSessionStatus {
match status {
"turn_running" => PiLabSessionStatus::Idle,
"runtime_running" => PiLabSessionStatus::Idle,
"aborted" => PiLabSessionStatus::Aborted,
"error" => PiLabSessionStatus::Error,
_ => PiLabSessionStatus::Idle,
}
}
/// 从 control-plane 恢复会话元数据到热缓存(不自动拉起子进程)。
fn hydrate_session_from_control_plane(
state: &AppState,
user_id: &str,
session_id: &str,
) -> Result<Option<PiLabSession>, WebError> {
if session_id.trim().is_empty() || is_pi_lab_warmup_session_id(session_id) {
return Ok(None);
}
if get_session(session_id).is_some() {
return Ok(get_session(session_id));
}
let run_id = pi_run_id(session_id);
let Some(run) = state
.control_plane()
.find_ai_runtime_run(user_id, &run_id)
.map_err(|e| WebError::internal(format!("hydrate Pi session 失败: {e}")))?
else {
return Ok(None);
};
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Ok(None);
}
if run.user_id != user_id {
return Ok(None);
}
let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({}));
let str_field = |key: &str| -> Option<String> {
runtime
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let now = now_ms();
let session = PiLabSession {
session_id: run.session_id.clone(),
mnote_user_id: run.user_id.clone(),
bridge_token: generate_bridge_token(),
status: status_from_persisted(&run.status),
provider_session_id: str_field("providerSessionId")
.unwrap_or_else(|| run.session_id.clone()),
pi_session_dir: str_field("piSessionDir").unwrap_or_default(),
pi_session_file: str_field("piSessionFile"),
root_uri: str_field("rootUri"),
workspace_id: run
.workspace_id
.clone()
.or_else(|| str_field("workspaceId")),
page_path: run.document_id.clone().or_else(|| str_field("pagePath")),
page_title: run.title.clone().or_else(|| str_field("pageTitle")),
model_provider: str_field("modelProvider"),
model_id: str_field("modelId"),
thinking_level: str_field("thinkingLevel"),
allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(),
runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(),
runtime_pid: None,
runtime_mode: str_field("runtimeMode").unwrap_or_else(|| "real".to_string()),
runtime_error: None,
created_at_ms: now,
updated_at_ms: now,
message_count: runtime
.get("messageCount")
.and_then(Value::as_u64)
.unwrap_or(0),
};
if session.pi_session_dir.trim().is_empty() {
return Ok(None);
}
upsert_session(session.clone());
let _ = persist_append_event(
state,
&session,
"session_hydrated",
&json!({"source": "control_plane", "runId": run.run_id}),
);
Ok(Some(session))
}
fn upsert_session(session: PiLabSession) {
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
sessions.insert(session.session_id.clone(), session);
@@ -3219,11 +3366,25 @@ fn get_session_for_context(
context: &RequestContext,
session_id: &str,
) -> Result<PiLabSession, WebError> {
let session = get_session(session_id).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在")
})?;
ensure_session_owner(state, context, &session)?;
Ok(session)
if let Some(session) = get_session(session_id) {
ensure_session_owner(state, context, &session)?;
return Ok(session);
}
// S6: process restart / cold cache — hydrate metadata from control-plane
// so send/events/status can resume the same sessionId without a full start.
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
if let Ok(Some(session)) =
hydrate_session_from_control_plane(state, actor_id, session_id)
{
ensure_session_owner(state, context, &session)?;
return Ok(session);
}
}
Err(WebError::bad_request_code(
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在",
))
}
fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> {
@@ -4668,12 +4829,153 @@ impl PiLabToolFacade {
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
"mnote.reference.open",
"mnote.vault.list",
"mnote.vault.get",
"mnote.vault.resolve",
"mnote.vault.login",
"mnote.vault.session",
"mnote.codex_rescue.request",
"mnote.tool_receipt.write"
],
}))
}
fn vault_list(&self, params: Value) -> Result<Value, WebError> {
let status = params
.get("status")
.and_then(Value::as_str)
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
crate::routes::vault::list_ai_vault_items(status)
}
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
})?;
crate::routes::vault::get_ai_vault_item(&id)
}
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.resolve 需要 id",
)
})?;
let field = string_param(&params, "field").ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_field_required",
"mnote.vault.resolve 需要 field=password|apikey|token",
)
})?;
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 resolve 需要登录会话",
));
}
crate::routes::vault::resolve_ai_vault_secret(
&id,
&field,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn vault_login(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.login 需要 id",
)
})?;
let force = bool_param(&params, "forceRefresh")
.or_else(|| bool_param(&params, "force_refresh"))
.unwrap_or(false);
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 login 需要登录会话",
));
}
crate::routes::vault::login_ai_vault_credential(
&id,
force,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn vault_session(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.session 需要 id",
)
})?;
let cookie = string_param(&params, "cookieHeader")
.or_else(|| string_param(&params, "cookie_header"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_cookie_required",
"mnote.vault.session 需要 cookieHeader",
)
})?;
let expires = string_param(&params, "expiresAt")
.or_else(|| string_param(&params, "expires_at"));
let source = string_param(&params, "source").unwrap_or_else(|| "human_bridge".into());
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 session 需要登录会话",
));
}
crate::routes::vault::put_ai_vault_session(
&id,
&cookie,
expires.as_deref(),
&source,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) = resolve_file_path(
&self.state,
@@ -5205,6 +5507,11 @@ async fn execute_tool(
}
"mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await,
"mnote.reference.open" => facade.reference_open(params.clone()).await,
"mnote.vault.list" => facade.vault_list(params.clone()),
"mnote.vault.get" => facade.vault_get(params.clone()),
"mnote.vault.resolve" => facade.vault_resolve(params.clone()),
"mnote.vault.login" => facade.vault_login(params.clone()),
"mnote.vault.session" => facade.vault_session(params.clone()),
"mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await,
"mnote.tool_receipt.write" => Ok(json!({
"requestedReceipt": params,
@@ -5286,7 +5593,14 @@ async fn execute_tool(
before_file_version.clone(),
after_file_version.clone(),
);
let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count);
// Never persist vault secret values into control-plane receipts / journals.
let receipt_safe_payload = redact_vault_tool_payload(&tool_name, &payload);
let receipt_payload = write_receipt(
&facade.state,
receipt,
&receipt_safe_payload,
citation_count,
);
let elapsed_ms = now_ms().saturating_sub(started) as u64;
if let Some(session) = session.as_ref() {
let tool_event_id = receipt_payload
@@ -5467,6 +5781,17 @@ pub async fn status(
let runtime_error = current_session
.as_ref()
.and_then(|session| session.runtime_error.clone());
// S1: status defaults must match effective AI policy (management surface truth),
// not only env hardcodes. Frontend checkStatus previously overwrote effective
// defaults with these fields.
let effective_policy =
ai_settings::load_effective_ai_runtime_policy(&state, &actor_id, None);
let (status_default_provider, status_default_model_id) = match effective_policy
.resolve_requested_model(None, None)
{
Ok(resolved) => (resolved.provider, resolved.model_id),
Err(_) => (default_model_provider(), default_model_id()),
};
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATUS,
@@ -5480,8 +5805,9 @@ pub async fn status(
"runtimeAvailable": runtime_available,
"runtimeInstallHint": runtime_install_hint,
"runtimeError": runtime_error,
"defaultModelProvider": default_model_provider(),
"defaultModelId": default_model_id(),
"defaultModelProvider": status_default_provider,
"defaultModelId": status_default_model_id,
"defaultModel": format!("{status_default_provider}/{status_default_model_id}"),
"defaultThinkingLevel": default_thinking_level(),
"permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)),
"advancedRuntime": current_session.as_ref().map(pi_lab_effective_advanced_runtime_config).unwrap_or_else(PiLabAdvancedRuntimeConfig::empty),
@@ -5590,6 +5916,7 @@ pub async fn bootstrap(
folder_path: None,
context_refs: None,
selected_context: None,
target_package: None,
}),
)
.await?
@@ -5614,6 +5941,9 @@ pub async fn start(
let mut requested_session = requested_session;
requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
if let Some(existing_session_id) = request.session_id.as_deref() {
if get_session(existing_session_id).is_none() {
let _ = hydrate_session_from_control_plane(&state, &actor_id, existing_session_id)?;
}
if let Some(mut existing_session) = get_session(existing_session_id) {
ensure_session_owner(&state, &context, &existing_session)?;
if session_runtime_is_usable(&existing_session)
@@ -5888,6 +6218,8 @@ pub async fn send(
"folderPath": request.folder_path,
"contextRefs": request.context_refs,
"selectedContext": request.selected_context,
// S7: host-built agent target package (optional; client also enforces dirty gate).
"targetPackage": request.target_package,
},
});
if let Some(images) = request.images.clone().filter(|images| !images.is_empty()) {
@@ -9463,7 +9795,7 @@ mod tests {
#[test]
fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() {
let source = include_str!("page_ai_pi.rs");
let source = include_str!("runtime.rs");
assert!(!source.contains(".arg(\"--no-prompt-templates\")"));
assert!(!source.contains(".arg(\"--no-context-files\")"));
}
@@ -9836,6 +10168,129 @@ mod tests {
assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed");
}
/// S1: admin/user AI policy defaultModel must apply on the next Pi start when
/// the client does not pass modelProvider/modelId (management surface is truth).
#[tokio::test]
async fn start_without_model_uses_effective_default_and_picks_up_policy_change() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
let state = test_state();
let actor_id = "pi_default_model_s1_user";
let root = temp_root("mnote-pi-default-model-s1-root");
let root_uri = grant_directory(&state, actor_id, &root, "write");
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-fast",
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
}),
);
let app = build_app(state.clone());
let (status_a, payload_a) = request_json(
app.clone(),
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_s1_default_a",
"rootUri": root_uri,
}),
)
.await;
assert_eq!(status_a, StatusCode::OK, "start A: {payload_a}");
assert_eq!(payload_a["session"]["modelProvider"], "omniroute");
assert_eq!(payload_a["session"]["modelId"], "pi-fast");
assert_eq!(
payload_a["session"]["runtimePolicySnapshot"]["defaultModel"],
"omniroute/pi-fast"
);
// Simulate management-surface defaultModel change (same control-plane policy).
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-reason",
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
}),
);
let (status_b, payload_b) = request_json(
app,
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_s1_default_b",
"rootUri": root_uri,
}),
)
.await;
assert_eq!(status_b, StatusCode::OK, "start B: {payload_b}");
assert_eq!(payload_b["session"]["modelProvider"], "omniroute");
assert_eq!(
payload_b["session"]["modelId"],
"pi-reason",
"next start without explicit model must pick up new defaultModel"
);
assert_eq!(
payload_b["session"]["runtimePolicySnapshot"]["defaultModel"],
"omniroute/pi-reason"
);
}
/// S1: /status defaultModel* must mirror effective policy, not only env hardcodes.
#[tokio::test]
async fn status_default_model_matches_effective_ai_policy() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
// Env hardcode must not win over policy when policy is present.
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL", "env-stale-model");
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER", "omniroute");
let state = test_state();
let actor_id = "pi_status_default_s1_user";
// Policy FK requires the user row first (grant_directory also upserts user).
let _root = grant_directory(
&state,
actor_id,
&temp_root("mnote-pi-status-default-s1-root"),
"read",
);
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-reason",
"allowedModels": ["omniroute/pi-reason", "omniroute/pi-fast"]
}),
);
let app = build_app(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/status")
.header("x-mnote-actor-id", actor_id)
.header("x-mnote-actor-type", "user")
.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 payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["defaultModelProvider"], "omniroute");
assert_eq!(
payload["defaultModelId"], "pi-reason",
"status must not report env-stale default when policy exists: {payload}"
);
assert_eq!(payload["defaultModel"], "omniroute/pi-reason");
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL");
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER");
}
#[test]
fn hydrate_session_mcp_cache_copies_shared_cache() {
let root = temp_root("mnote-pi-shared-mcp-cache-root");
+78 -9
View File
@@ -421,6 +421,41 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
.unwrap_or_default()
}
/// Map a page-tree row's storage relative path to the directory scope used for lazy expand.
/// - Nested bundle `Folder/Folder.md` → `Folder`
/// - Sibling page `Folder.md` (+ `Folder/`) → `Folder`
/// - Page-group / directory row → itself
pub(crate) fn page_tree_expand_relative_path(relative_path: &str) -> String {
let rp = relative_path
.trim()
.trim_matches('/')
.replace('\\', "/");
if rp.is_empty() {
return String::new();
}
let lower = rp.to_ascii_lowercase();
if !lower.ends_with(".md") {
return rp;
}
let stem = &rp[..rp.len().saturating_sub(3)];
if let Some((parent, file)) = rp.rsplit_once('/') {
let file_stem = file
.strip_suffix(".md")
.or_else(|| file.strip_suffix(".MD"))
.or_else(|| file.strip_suffix(".Md"))
.unwrap_or(file);
let parent_name = parent.rsplit_once('/').map(|(_, name)| name).unwrap_or(parent);
if parent_name == file_stem {
// Nested bundle: markdown lives inside a same-named folder.
return parent.to_string();
}
// Sibling page under a parent directory.
return stem.to_string();
}
// Root-level sibling page.
stem.to_string()
}
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
let mut rows: Vec<PageTreeRenderRow> = projection
.get("items")
@@ -441,6 +476,47 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
if row_kind != "document" {
return None;
}
let expandable = item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
});
let relative_path = item
.get("expandRelativePath")
.or_else(|| item.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
item.get("resourceMeta")
.and_then(|meta| meta.get("workspacePath"))
.and_then(|path| path.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| {
item.get("resourceMeta")
.and_then(|meta| meta.pointer("/extra/source/relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
});
let expand_relative_path = if expandable {
relative_path
.as_deref()
.map(page_tree_expand_relative_path)
.filter(|value| !value.is_empty())
} else {
None
};
Some(PageTreeRenderRow {
node_id: node_id.to_string(),
parent_node_id: item
@@ -458,15 +534,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.unwrap_or("无标题")
.to_string(),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
}),
expandable,
expanded: item
.get("expandedByDefault")
.and_then(Value::as_bool)
@@ -480,6 +548,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.filter(|value| !value.is_empty())
.is_some()
|| !node_id.starts_with("local-dir:"),
expand_relative_path,
})
})
.collect()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
//! Shared vault path deny predicate for general file surfaces.
//!
//! Any workspace-relative path under `.mnote/vault` must be rejected on
//! open/stat/resource/local_file surfaces. Vault content is only reachable
//! via `/api/vault/*`.
use crate::error::WebError;
use axum::http::StatusCode;
/// Workspace-root-relative path that points at the password vault system space.
///
/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments
/// (`..`) are rejected before classification (callers usually already do this).
pub fn normalize_workspace_relative_path(rel: &str) -> String {
let mut value = rel.trim().replace('\\', "/");
while value.starts_with("./") {
value = value[2..].to_string();
}
value = value.trim_start_matches('/').to_string();
while value.contains("//") {
value = value.replace("//", "/");
}
if value.ends_with('/') && value != "/" {
value.pop();
}
value
}
/// Returns true when `rel` is `.mnote/vault` or any path under it.
pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
let n = normalize_workspace_relative_path(rel);
if n.is_empty() {
return false;
}
if n
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
// Escape / empty segments are not vault matches; callers reject escape.
return false;
}
n == ".mnote/vault" || n.starts_with(".mnote/vault/")
}
/// 403 with stable code for general file surfaces that hit vault paths.
pub fn vault_path_denied_error(message: impl Into<String>) -> WebError {
WebError::new(
StatusCode::FORBIDDEN,
"vault_path_denied",
message.into(),
)
}
/// Reject vault-relative paths before reading or writing general local files.
pub fn deny_if_vault_sensitive_relative_path(rel: &str) -> Result<(), WebError> {
if is_vault_sensitive_relative_path(rel) {
return Err(vault_path_denied_error(
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_vault_root_and_children() {
assert!(is_vault_sensitive_relative_path(".mnote/vault"));
assert!(is_vault_sensitive_relative_path(".mnote/vault/"));
assert!(is_vault_sensitive_relative_path(".mnote/vault/entries/x.md"));
assert!(is_vault_sensitive_relative_path(
"./.mnote/vault/entries/x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote\\vault\\entries\\x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/trash/entries/x.md"
));
}
#[test]
fn allows_non_vault_paths() {
assert!(!is_vault_sensitive_relative_path(""));
assert!(!is_vault_sensitive_relative_path("notes/a.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/trash/a.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/index/search-index.json"));
assert!(!is_vault_sensitive_relative_path("mnote/vault/x.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/vault-backup/x.md"));
assert!(!is_vault_sensitive_relative_path("个人/密码/a.md"));
}
#[test]
fn deny_helper_returns_stable_code() {
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/entries/x.md")
.expect_err("must deny");
assert_eq!(err.code(), "vault_path_denied");
assert_eq!(err.status(), StatusCode::FORBIDDEN);
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
}
}
File diff suppressed because it is too large Load Diff
+57 -10
View File
@@ -11,8 +11,9 @@ use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot_with_reveal,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
@@ -21,12 +22,12 @@ use crate::routes::snapshot_support::{
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput,
render_filetree_pending_shell_html, render_initial_filetree_html, FileTreeInitialRenderInput,
};
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
WorkspaceShellProjection,
apply_active_page, build_workspace_shell_projection, render_page_breadcrumb_html,
render_workspace_shell_sidebar_html, WorkspaceShellProjection,
};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
@@ -261,6 +262,7 @@ pub async fn document_page_shell(
file_tree_scope,
);
let workspace_name = workspace_projection.workspace_name.clone();
let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id));
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
@@ -300,6 +302,10 @@ pub async fn document_page_shell(
);
let pi_lab_loader_script =
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context(
primary_source_kind,
primary_root_uri,
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
@@ -308,6 +314,7 @@ pub async fn document_page_shell(
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
breadcrumb_html={breadcrumb_html}
page_subtree_json={page_subtree_json}
page_options_json={page_options_json}
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
@@ -318,6 +325,7 @@ pub async fn document_page_shell(
primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
vault_nav_href={vault_nav_href}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
@@ -766,6 +774,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
let saving = false;
const saveTitle = async () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
const title = input.value.trim() || '';
const currentTarget = resolveTitleTarget(input);
autosize(input);
@@ -820,6 +834,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
};
input.addEventListener('input', () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
autosize(input);
setStatus(input, (input.value.trim() || '') === readLastSavedTitle() ? 'saved' : 'dirty');
});
@@ -3103,6 +3123,20 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn vault_workbench_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/vault-workbench-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
@@ -3603,9 +3637,13 @@ pub(crate) fn render_local_sidebar_tree_html_scoped(
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
Ok(render_local_sidebar_tree_html_from_snapshot(
&snapshot,
@@ -3633,6 +3671,12 @@ pub(crate) fn render_local_file_tree_html(
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
}
/// Fast path for home SSR: keep FileTree shell structure without scanning the folder.
/// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`.
pub(crate) fn render_local_file_tree_pending_shell_html() -> String {
render_filetree_pending_shell_html()
}
pub(crate) fn render_local_file_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
@@ -5320,8 +5364,10 @@ mod tests {
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(session_runtime.contains("if (!response.ok) {"));
assert!(session_runtime.contains(
"if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))"
));
assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
assert!(session_runtime.contains("kind.includes('Modify(Name')"));
assert!(session_runtime.contains("targetSession.views.size === 0"));
@@ -5707,7 +5753,8 @@ mod tests {
assert!(runtime.contains("legacyInlineContentToTiptap"));
assert!(runtime.contains("legacyStylesToTiptapMarks"));
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath"));
assert!(runtime.contains("const sourcePath = firstNonEmptyText("));
assert!(runtime.contains("block?.props?.sourcePath"));
assert!(runtime.contains("marks.push({ type: 'bold' })"));
assert!(runtime.contains("marks.push({ type: 'italic' })"));
assert!(runtime.contains("marks.push({ type: 'underline' })"));
@@ -1365,8 +1365,9 @@ const AI_ADMIN_SCRIPT: &str = r#"
}
if (knowledgePanel) {
var lightrag = effectivePayload && (effectivePayload.lightragProvider || effectivePayload.lightrag_provider) || {};
// S5: hydrate read-only health + directory summary from /api/knowledge-rag/status
knowledgePanel.innerHTML =
'<div class="mnote-ai-admin-admin-list">' +
'<div class="mnote-ai-admin-admin-list" data-ai-admin-knowledge-live>' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>LightRAG</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag(' provider', 'green') + renderTag('MNote facade', 'blue') +
@@ -1377,7 +1378,11 @@ const AI_ADMIN_SCRIPT: &str = r#"
renderKv('', 'mnote.knowledge_rag.query') +
renderKv('', 'citation / open-reference') +
renderKv('Pi ', ' MNote knowledge facade ') +
renderKv('Health', '') +
renderKv('Pipeline', '') +
renderKv('Documents', '') +
'</div>' +
'<p class="mnote-ai-admin-mini-desc" data-ai-admin-knowledge-summary> /api/knowledge-rag/status</p>' +
'</div>' +
'</div>' +
'<div class="mnote-ai-admin-admin-card">' +
@@ -1385,10 +1390,60 @@ const AI_ADMIN_SCRIPT: &str = r#"
renderTag(' allowed roots', 'green') + renderTag(' RAG', 'orange') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<p class="mnote-ai-admin-mini-desc"> source/index/status /api/knowledge-rag/*;目录授权继续来自 MNote allowed roots,不在 AI 管理页维护第二套目录。</p>' +
'<p class="mnote-ai-admin-mini-desc"> MNote allowed rootsdirectory_grants LightRAG health / pipeline / documents </p>' +
'</div>' +
'</div>' +
'</div>';
requestJson('/api/knowledge-rag/status', { method: 'GET' }).then(function (statusPayload) {
if (!knowledgePanel) return;
var health = statusPayload && statusPayload.health || {};
var healthOk = health.ok === true || (health.health && health.health.ok === true);
var healthLabel = healthOk ? 'ok' : (health.message || health.code || 'unavailable');
var pipeline = statusPayload && statusPayload.pipeline || {};
var pipelineLabel = pipeline.ok === false
? (pipeline.message || pipeline.code || 'error')
: (pipeline.status || pipeline.phase || pipeline.state || (pipeline.ok === true ? 'ok' : JSON.stringify(pipeline).slice(0, 80)));
var docs = statusPayload && statusPayload.documents || {};
var docList = Array.isArray(docs.documents) ? docs.documents : [];
var groups = docs.rawStatusGroups || {};
var groupKeys = Object.keys(groups || {});
var groupSummary = groupKeys.length
? groupKeys.map(function (k) { return k + '=' + groups[k]; }).join(', ')
: (docList.length + ' docs');
var registry = statusPayload && statusPayload.registry || null;
var entryCount = registry && Array.isArray(registry.entries) ? registry.entries.length : 0;
var rootCount = registry && Array.isArray(registry.indexed_roots || registry.indexedRoots)
? (registry.indexed_roots || registry.indexedRoots).length
: 0;
var provider = (statusPayload && statusPayload.provider) || lightrag.provider || 'lightrag';
knowledgePanel.innerHTML =
'<div class="mnote-ai-admin-admin-list" data-ai-admin-knowledge-live>' +
'<div class="mnote-ai-admin-admin-card">' +
'<div class="mnote-ai-admin-admin-card-head"><h3>LightRAG</h3><span class="mnote-ai-admin-actions-bar">' +
renderTag(healthOk ? 'health ok' : 'health down', healthOk ? 'green' : 'orange') +
renderTag(String(provider), 'blue') +
'</span></div>' +
'<div class="mnote-ai-admin-admin-card-body">' +
'<div class="mnote-ai-admin-kv-grid">' +
renderKv('Provider', provider) +
renderKv('Endpoint', (statusPayload && statusPayload.endpoint) || '-') +
renderKv('Health', healthLabel) +
renderKv('Pipeline', String(pipelineLabel)) +
renderKv('Documents', groupSummary) +
renderKv('Registry entries', String(entryCount)) +
renderKv('Indexed roots', String(rootCount)) +
renderKv('', 'mnote.knowledge_rag.query') +
'</div>' +
'<p class="mnote-ai-admin-mini-desc" data-ai-admin-knowledge-summary> /api/knowledge-rag/status control-plane directory_grants / allowed roots</p>' +
'</div>' +
'</div>' +
'</div>';
}).catch(function (err) {
if (!knowledgePanel) return;
var msg = err && err.message ? err.message : 'status unavailable';
var summary = knowledgePanel.querySelector('[data-ai-admin-knowledge-summary]');
if (summary) summary.textContent = 'LightRAG status : ' + msg;
});
}
if (healthGrid) {
healthGrid.innerHTML = [
@@ -2747,10 +2802,14 @@ const AI_ADMIN_SCRIPT: &str = r#"
collectSkillsFromDOM().forEach(function(skill) {
var id = skill.id || skill.name;
if (!id) return;
// S2: preserve source/risk/scopes so delete/save does not soft-break skills.
body.skills[id] = {
name: skill.name,
enabled: skill.enabled,
description: skill.description || ''
description: skill.description || '',
source: skill.source || '',
riskLevel: skill.riskLevel || skill.risk_level || 'low',
requiredScopes: skill.requiredScopes || skill.required_scopes || []
};
});
}
@@ -2768,7 +2827,10 @@ const AI_ADMIN_SCRIPT: &str = r#"
networkPolicy: server.networkPolicy || 'deny-all',
secretRefs: server.secretRefs || [],
facadeOnly: server.facadeOnly !== false,
sandbox: true
sandbox: true,
description: server.description || '',
riskLevel: server.riskLevel || server.risk_level || 'medium',
requiredScopes: server.requiredScopes || server.required_scopes || []
};
});
}
@@ -97,8 +97,18 @@ pub(super) fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
data-pane-role={pane_role_attr_three}
data-title-endpoint="/api/documents/title"
rows="1"
readonly
data-document-user-readonly-mode="true"
>{model.title.clone()}</textarea>
</h1>
<button
type="button"
class="document-edit-mode-toggle"
data-document-edit-mode-toggle="true"
aria-pressed="false"
data-document-editing="false"
title="进入编辑模式"
>"开始编辑"</button>
</div>
<div class="document-shell-meta" aria-label="页面元信息">
<span data-page-title-current="true">{model.title.clone()}</span>
@@ -161,6 +171,9 @@ pub fn DocumentPage(
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// 顶栏页面祖先链 HTML(可选)
#[prop(optional)]
breadcrumb_html: Option<String>,
/// Page Aggregate 子树 JSON(可选)
#[prop(optional)]
page_subtree_json: Option<String>,
@@ -194,6 +207,9 @@ pub fn DocumentPage(
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
/// 密码箱导航 SSR href(含 local_folder 上下文)
#[prop(optional)]
vault_nav_href: Option<String>,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
@@ -293,7 +309,7 @@ pub fn DocumentPage(
hide_title_header: secondary_hide_title_header,
};
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} breadcrumb_html={breadcrumb_html.unwrap_or_default()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live} vault_nav_href={vault_nav_href.unwrap_or_default()}>
<div
class="document-workspace"
data-testid="mnote-document-workspace"
+7 -1
View File
@@ -21,6 +21,9 @@ pub fn HomePage(
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
/// 顶栏页面祖先链 HTML(可选)
#[prop(optional)]
breadcrumb_html: Option<String>,
/// 当前选中的页面 id(可选)
#[prop(optional)]
active_page_id: Option<String>,
@@ -36,6 +39,9 @@ pub fn HomePage(
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
/// 密码箱导航 SSR href(含 local_folder 上下文)
#[prop(optional)]
vault_nav_href: Option<String>,
) -> impl IntoView {
let active_page_id = active_page_id.unwrap_or_default();
let active_page_title = active_page_title
@@ -54,7 +60,7 @@ pub fn HomePage(
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} breadcrumb_html={breadcrumb_html.unwrap_or_default()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live} vault_nav_href={vault_nav_href.unwrap_or_default()}>
{move || if has_active_page {
view! {
<main class="document-shell document-shell--workspace-entry" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
+234 -7
View File
@@ -62,15 +62,25 @@ pub fn PageLayout(
/// 顶栏当前页面标题(可选)
#[prop(optional)]
topbar_title: Option<String>,
/// 顶栏页面祖先链 HTML(可选)
#[prop(optional)]
breadcrumb_html: Option<String>,
/// 是否显示管理员授权能力
#[prop(optional)]
show_admin_access_policy: bool,
/// 是否启用树实时流
#[prop(optional, default = true)]
enable_tree_live: bool,
/// 密码箱导航 SSR href(含 sourceKind/rootUri,避免首击全页丢失上下文)
#[prop(optional)]
vault_nav_href: Option<String>,
) -> impl IntoView {
let _ = show_admin_access_policy;
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
let vault_nav_href = vault_nav_href
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "/vault".to_string());
let ws_name = workspace_name
.map(|value| value.trim().to_string())
@@ -80,6 +90,15 @@ pub fn PageLayout(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人".to_string());
let breadcrumb_html = breadcrumb_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
format!(
r#"<span class="wolai-breadcrumb-current"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{}</span></span>"#,
crate::routes::web_shell::escape_html(&topbar_title),
)
});
let sidebar_sections_html = workspace_sidebar_html
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
@@ -135,7 +154,7 @@ pub fn PageLayout(
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
<button type="button" title="导航页" aria-label="导航页" data-mnote-action="open-navigation-page"><span class="material-symbols-outlined nav-icon" data-icon="home" aria-hidden="true"></span></button>
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
<a href={vault_nav_href.clone()} class:active={current_nav == "vault"} title="密码箱" aria-label="密码箱" data-testid="mnote-nav-vault" data-mnote-nav="vault"><span class="material-symbols-outlined nav-icon" data-icon="lock" aria-hidden="true"></span></a>
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
<button
type="button"
@@ -172,9 +191,9 @@ pub fn PageLayout(
<div class="wolai-topbar-left">
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏" data-state="closed" aria-haspopup="true" aria-expanded="false" aria-pressed="false" data-mnote-action="toggle-sidebar" data-testid="wolai-sidebar-toggle"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
<nav class="wolai-breadcrumb" aria-label="页面路径">
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
<a class="wolai-breadcrumb-root wolai-breadcrumb-link" href="/" aria-label="返回工作区首页">{ws_name.clone()}</a>
<span class="wolai-breadcrumb-separator" aria-hidden="true">""</span>
<span class="wolai-breadcrumb-current"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{topbar_title}</span></span>
<div class="wolai-breadcrumb-pages" data-breadcrumb-pages="true" inner_html={breadcrumb_html}></div>
</nav>
</div>
<div class="wolai-topbar-actions" aria-label="页面操作">
@@ -288,10 +307,12 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab"));
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains("applySearchSwitchState(overlay, { knowledge: false"));
assert!(!SIDEBAR_TREE_RUNTIME_JS
.contains("applySearchSwitchState(overlay, { knowledge: true"));
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("applySearchSwitchState(overlay, { knowledge: false")
);
assert!(
!SIDEBAR_TREE_RUNTIME_JS.contains("applySearchSwitchState(overlay, { knowledge: true")
);
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains("data-search-switch=\"knowledge\" role=\"switch\" aria-checked=\"false\""));
assert!(!SIDEBAR_TREE_RUNTIME_JS
@@ -400,6 +421,29 @@ mod tests {
.contains("var expanded = expandable && item.expandedByDefault !== false"),
"PageTree 不能只依赖 projection expandedByDefault 决定展开"
);
assert!(
render_page_rows.contains("item.expandedByDefault === true")
|| render_page_rows.contains("expandedByDefault === true"),
"无 user view-state 时 PageTree 只能按 expandedByDefault === true 展开(与 SSR 一致)"
);
// PageTree view-state scope must be stable "root" (Sidex workspace-scoped),
// not current fileTreeScope — otherwise refresh under a folder loses expands.
let view_scope =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewScope");
assert!(
view_scope.contains("pagetree") && view_scope.contains("'root'"),
"pagetree view-state scope must pin to root"
);
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("function restorePersistedPageTreeExpansionState"),
"runtime must restore pagetree expandedIds after refresh"
);
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function setPageTreeExpandPresentation"),
"runtime must own a single presentation write path for page expand"
);
let render_file_rows =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows");
@@ -407,6 +451,154 @@ mod tests {
assert!(render_file_rows.contains("expandedRelativePaths"));
}
#[test]
fn page_tree_first_click_expands_ssr_children_without_waiting_for_fetch() {
// Shallow SSR paints nested rows under a collapsed container without
// data-page-tree-children-loaded. First toggle must expand immediately.
let toggle_children =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "toggleChildren");
assert!(
toggle_children.contains("pageTreeRowIsOpen(row)"),
"toggleChildren must use visual open state, not aria-expanded alone"
);
assert!(
toggle_children.contains("children.children.length > 0"),
"toggleChildren must expand when SSR already rendered child rows"
);
assert!(
toggle_children.contains("data-page-tree-children-loaded"),
"toggleChildren must mark SSR children as loaded on first expand"
);
let load_page_children =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "loadPageTreeChildren");
assert!(
load_page_children.contains("children.children.length > 0"),
"loadPageTreeChildren must treat non-empty SSR children as already loaded"
);
assert!(
load_page_children.contains("Optimistic UI")
|| load_page_children.contains("children.classList.remove('tree-children--collapsed')"),
"lazy expand must open chevron/container before network"
);
// Late view-state GET must not snap shut an expand the user just did
// (privacy window / cold localStorage is the common repro).
let load_view_state =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "loadSidebarTreeViewState");
assert!(
load_view_state.contains("current.hasUserState"),
"async view-state load must merge with in-session expands"
);
assert!(
load_view_state.contains("current.expandedIds.forEach"),
"async view-state load must union expandedIds from the session"
);
let apply_page_state =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "applyPageTreeExpansionState");
assert!(
apply_page_state.contains("pageTreeRowIsOpen(row)"),
"applying view-state must preserve rows that are actually open this session"
);
// Boot must reconcile false "expanded" arrows before the first click,
// apply local view-state, and restore persisted expands (Sidex setInput).
assert!(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function syncPageTreeExpandVisualState"),
"runtime must ship syncPageTreeExpandVisualState"
);
let install = js_function_body(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS,
"installTreeLiveApplyEventListeners",
);
assert!(
install.contains("syncPageTreeExpandVisualState()"),
"install must run expand/chevron sync on boot"
);
assert!(
install.contains("applyPageTreeExpansionState")
|| install.contains("scheduleRestorePersistedPageTreeExpansionState"),
"install must apply/restore pagetree view-state on boot"
);
let boot_state =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewStateFor");
assert!(
boot_state.contains("applySidebarTreeViewState"),
"localStorage view-state must apply to DOM immediately (not wait for API)"
);
// PageTree view-state scope must stay stable "root" (not fileTreeScope),
// otherwise refresh/navigation loses expandedIds under a different key.
let scope_fn = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewScope");
assert!(
scope_fn.contains("pagetree") && scope_fn.contains("'root'"),
"pagetree view-state scope must be stable root, not current fileTreeScope"
);
let restore_fn = js_function_body(
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS,
"restorePersistedPageTreeExpansionState",
);
assert!(
!restore_fn.is_empty(),
"runtime must restore persisted page-tree expands after refresh"
);
assert!(
restore_fn.contains("loadPageTreeChildren") || restore_fn.contains("setTreeRowExpanded"),
"restore must open SSR children or hydrate empty bodies"
);
let presentation =
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "setPageTreeExpandPresentation");
assert!(
presentation.contains("data-expanded") && presentation.contains("aria-expanded"),
"single write path must set both aria-expanded and data-expanded"
);
let render_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows");
assert!(
render_rows.contains("expandedByDefault === true")
|| render_rows.contains("item.expandedByDefault === true"),
"client default expand must match SSR (only true, not !== false)"
);
assert!(
render_rows.contains("data-expanded="),
"client-rendered page rows must emit data-expanded for chevron CSS"
);
}
#[test]
fn sidebar_tree_toggle_uses_wolai_style_rotating_chevron() {
// Hide non-rotating SSR SVG; show solid triangle via ::before that rotates 90°.
const MAIN_CSS: &str = include_str!("../styles/components/main.css");
assert!(
MAIN_CSS.contains(".sidebar-tree .tree-toggle .tree-toggle-icon")
|| MAIN_CSS.contains(".tree-toggle > svg"),
"main.css must hide the static SVG chevron inside tree-toggle"
);
assert!(
MAIN_CSS.contains(".sidebar-tree .tree-toggle::before"),
"main.css must draw Wolai-style triangle via ::before"
);
assert!(
MAIN_CSS.contains("rotate(90deg)"),
"main.css must rotate chevron when expanded"
);
assert!(
MAIN_CSS.contains("data-expanded=\"true\"")
|| MAIN_CSS.contains("[data-expanded=\"true\"]"),
"main.css must key rotation off data-expanded (real fold state)"
);
// Icon must track real fold state (children container), not only aria.
assert!(
MAIN_CSS.contains("tree-children--collapsed")
&& MAIN_CSS.contains(":has(> .tree-children"),
"main.css must bind chevron rotation to actual children open/collapsed state"
);
}
#[test]
fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() {
let html = crate::ssr::render_view(leptos::view! {
@@ -650,6 +842,30 @@ mod tests {
assert!(!folder_branch.contains("window.location.assign"));
}
#[test]
fn vault_nav_soft_opens_without_replacing_sidebar_tree() {
// Left-rail 密码箱 must soft-swap .mnote-content so PageTree expand state survives.
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openVaultWorkbenchSoft"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.__mnoteOpenVaultWorkbench"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("void openVaultWorkbenchSoft(vaultUrl)"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildVaultWorkbenchShellHtml"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("article.mnote-content"));
// Must not full-assign when soft path is taken (vault click branch).
let vault_branch = SIDEBAR_TREE_RUNTIME_JS
.split("var vaultNavLink = closestAction(")
.nth(1)
.and_then(|rest| rest.split("var localFolderTrigger").next())
.expect("vault nav click branch exists");
assert!(
vault_branch.contains("openVaultWorkbenchSoft"),
"vault click must soft-open workbench"
);
assert!(
!vault_branch.contains("window.location.assign(nextHref)"),
"vault soft-open must not full-navigate on primary click"
);
}
#[test]
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
assert!(
@@ -1648,6 +1864,17 @@ mod tests {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
}
#[test]
fn sidebar_filetree_watch_fallback_keeps_refresh_scoped() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("data-mnote-local-folder-watch-batch-fallback-scoped"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
"addCommandRefreshParent(fallbackParents, parentRelativePathForPath(relativePath))"
));
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("fallbackResync === true || batch.requiresResync === true)) {\n document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync'));\n void refreshLocalFolderSidebarSnapshot();"));
}
#[test]
fn sidebar_filetree_runtime_has_view_state_namespace() {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {"));
+14 -3
View File
@@ -17,6 +17,7 @@
/// - `styles/components/search.css`: 搜索弹窗组件
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
/// - `styles/components/vault.css`: 密码箱 workbench
pub const MNOTE_CSS: &str = concat!(
include_str!("styles/tokens.css"),
"\n",
@@ -37,6 +38,8 @@ pub const MNOTE_CSS: &str = concat!(
include_str!("styles/components/page-ai.css"),
"\n",
include_str!("styles/components/ui-debug.css"),
"\n",
include_str!("styles/components/vault.css"),
);
#[cfg(test)]
@@ -215,8 +218,16 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装
// 仍保持在可审阅范围内
assert!(MNOTE_CSS.len() < 190000);
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包
assert!(MNOTE_CSS.len() < 220000);
}
#[test]
fn mnote_css_contains_vault_workbench_selectors() {
assert!(MNOTE_CSS.contains(".mnote-vault-workbench"));
assert!(MNOTE_CSS.contains(".mnote-vault-list-item"));
assert!(MNOTE_CSS.contains(".mnote-vault-secret-value"));
assert!(MNOTE_CSS.contains("[data-testid=\"mnote-nav-vault\"]") || MNOTE_CSS.contains(".mnote-vault-header"));
}
}
@@ -324,7 +324,7 @@
.sidebar-tree .tree-spacer {
width: 20px;
height: 24px;
color: #B8B5AF;
color: #9B968E;
font-size: 16px;
}
@@ -335,29 +335,60 @@
justify-content: center;
line-height: 1;
font-size: 0;
/* Wolai-style: one clear chevron via ::before; hide SSR/JS SVG that does not rotate. */
}
.sidebar-tree .tree-toggle .tree-toggle-icon,
.sidebar-tree .tree-toggle > svg {
display: none !important;
}
.sidebar-tree .tree-toggle:hover {
background: rgba(27, 28, 28, 0.06);
color: #6F6A62;
}
/* Collapsed: solid right-pointing triangle (Wolai ). Expanded: rotate to .
Prefer real fold state: aria-expanded / data-expanded must stay in lockstep
with .tree-children--collapsed (syncPageTreeExpandVisualState). */
.sidebar-tree .tree-toggle::before {
content: "";
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid currentColor;
border-style: solid;
border-width: 4.5px 0 4.5px 7px;
border-color: transparent transparent transparent currentColor;
display: block;
transform-origin: 50% 50%;
transform-origin: 40% 50%;
transition: transform 0.12s ease;
}
.sidebar-tree .tree-row[aria-expanded="true"] > .tree-toggle::before {
/* Prefer data-expanded (set with children visibility) so aria alone cannot
* leave the chevron open while .tree-children--collapsed is present. */
.sidebar-tree .tree-row[data-shell-mode="page"][data-expanded="true"] > .tree-toggle::before,
.sidebar-tree .tree-toggle[data-expanded="true"]::before,
.sidebar-tree .tree-row[aria-expanded="true"]:not([data-expanded="false"]) > .tree-toggle::before,
.sidebar-tree .tree-toggle[aria-expanded="true"]:not([data-expanded="false"])::before {
transform: rotate(90deg);
}
/* When children are visibly collapsed, never keep a rotated chevron. */
.sidebar-tree .tree-node:has(> .tree-children.tree-children--collapsed) > .tree-row > .tree-toggle::before {
transform: none !important;
}
/* Empty children body (lazy hydrate pending or failed): always ▶, never ▼. */
.sidebar-tree .tree-node:has(> .tree-children:empty) > .tree-row > .tree-toggle::before {
transform: none !important;
}
/* When children are open (not collapsed) AND non-empty, force rotated chevron. */
.sidebar-tree .tree-node:has(> .tree-children:not(.tree-children--collapsed):not(:empty)) > .tree-row > .tree-toggle::before {
transform: rotate(90deg) !important;
}
.sidebar-tree .tree-row[data-active="true"] .tree-toggle {
color: #8F8A84;
color: #6F6A62;
}
.sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] {
@@ -1058,6 +1089,32 @@
white-space: nowrap;
}
.wolai-breadcrumb-pages {
display: inline-flex;
align-items: center;
gap: 9px;
min-width: 0;
overflow: hidden;
}
.wolai-breadcrumb-link,
.wolai-breadcrumb-current {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wolai-breadcrumb-link {
color: #6D6A65;
text-decoration: none;
}
.wolai-breadcrumb-link:hover {
color: var(--atelier-text);
text-decoration: underline;
text-underline-offset: 3px;
}
.wolai-breadcrumb-separator {
color: #D0CDC8;
}
@@ -2069,6 +2126,32 @@
text-underline-offset: 6px;
}
.document-edit-mode-toggle {
min-width: 76px;
height: 32px;
margin-top: 8px;
border: 1px solid rgba(55, 53, 47, 0.12);
border-radius: 6px;
background: #FFFFFF;
color: #37352F;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.document-edit-mode-toggle:hover {
background: rgba(55, 53, 47, 0.06);
}
.document-edit-mode-toggle[data-document-editing="true"] {
border-color: rgba(35, 131, 226, 0.24);
color: #0F6CBD;
}
.document-title-input[readonly] {
cursor: default;
}
.document-pane-close {
width: 28px;
height: 28px;
@@ -0,0 +1,734 @@
/* Password vault workbench — dedicated /vault CRUD surface */
.mnote-vault-workbench {
display: flex;
flex-direction: column;
gap: 16px;
width: min(1120px, calc(100vw - 48px));
min-height: calc(100vh - 120px);
margin: 28px auto 40px;
color: var(--atelier-text, #1b1c1c);
}
.mnote-vault-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px 24px;
}
.mnote-vault-header-main {
min-width: 0;
flex: 1 1 280px;
}
.mnote-vault-header h1 {
margin: 0 0 6px;
font-size: 28px;
font-weight: 650;
line-height: 36px;
}
.mnote-vault-header-main > p {
margin: 0;
color: #6d6a65;
font-size: 13px;
line-height: 20px;
}
.mnote-vault-status {
margin: 8px 0 0;
min-height: 18px;
font-size: 12px;
line-height: 18px;
color: #8b8782;
}
.mnote-vault-status[data-type="error"] {
color: #c93a32;
}
.mnote-vault-status[data-type="success"] {
color: #23834d;
}
.mnote-vault-status[data-type="info"] {
color: #6d6a65;
}
.mnote-vault-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: 0 1 auto;
}
.mnote-vault-header-actions > button,
.mnote-vault-detail-actions button,
.mnote-vault-secret-actions button,
.mnote-vault-secret-edit button,
.mnote-vault-tabs button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: #37352f;
font: inherit;
font-size: 12px;
line-height: 28px;
cursor: pointer;
}
.mnote-vault-header-actions > button:hover,
.mnote-vault-detail-actions button:hover,
.mnote-vault-secret-actions button:hover,
.mnote-vault-secret-edit button:hover,
.mnote-vault-tabs button:hover {
background: #f7f7f6;
}
.mnote-vault-detail-actions button.is-danger {
color: #c93a32;
border-color: rgba(201, 58, 50, 0.28);
}
.mnote-vault-detail-actions button.is-danger:hover {
background: #fdf2f1;
}
.mnote-vault-header-actions input[type="search"] {
width: min(240px, 42vw);
height: 30px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: inherit;
font: inherit;
font-size: 13px;
}
.mnote-vault-tabs {
display: inline-flex;
gap: 0;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
overflow: hidden;
}
.mnote-vault-tabs button {
border: 0;
border-radius: 0;
background: #ffffff;
}
.mnote-vault-tabs button.is-active,
.mnote-vault-tabs button[aria-selected="true"] {
background: #f4f3f3;
font-weight: 600;
}
.mnote-vault-body {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 0;
min-height: 480px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 6px;
background: #ffffff;
overflow: hidden;
}
.mnote-vault-list {
min-height: 0;
max-height: calc(100vh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
border-right: 1px solid rgba(27, 28, 28, 0.08);
background: #fafaf9;
}
.mnote-vault-list-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
width: 100%;
min-height: 28px;
margin: 0;
padding: 4px 8px 4px 10px;
border: 0;
border-bottom: 1px solid rgba(27, 28, 28, 0.04);
background: transparent;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.mnote-vault-list-item:hover {
background: #f1f0ef;
}
.mnote-vault-list-item.is-active {
background: #ebe9e7;
box-shadow: inset 2px 0 0 #1b1c1c;
}
.mnote-vault-list-main {
display: flex;
flex: 1 1 auto;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.mnote-vault-list-title {
flex: 0 1 auto;
max-width: 62%;
overflow: hidden;
color: #1b1c1c;
font-size: 12px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-list-meta {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #9b9a97;
font-size: 11px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-list-icons {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
line-height: 1;
}
.mnote-vault-list-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 11px;
font-weight: 700;
line-height: 1;
opacity: 0.9;
}
.mnote-vault-list-icon.is-shared {
color: #2e7d32;
}
.mnote-vault-list-icon.is-ai {
color: #1565c0;
font-size: 9px;
}
.mnote-vault-list-tags {
display: none;
}
.mnote-vault-tag {
display: inline-block;
padding: 1px 6px;
border-radius: 999px;
background: rgba(27, 28, 28, 0.06);
color: #5a5a5a;
font-size: 11px;
line-height: 16px;
}
.mnote-vault-tag--shared {
background: rgba(46, 125, 50, 0.12);
color: #2e7d32;
}
.mnote-vault-tag--ai {
background: rgba(25, 118, 210, 0.12);
color: #1565c0;
}
.mnote-vault-role-badge {
display: inline-flex;
align-items: center;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 18px;
background: rgba(25, 118, 210, 0.12);
color: #1565c0;
}
.mnote-vault-detail-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.mnote-vault-detail-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-status-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
line-height: 16px;
letter-spacing: 0.01em;
}
.mnote-vault-status-chip.is-shared {
background: rgba(46, 125, 50, 0.12);
color: #2e7d32;
}
.mnote-vault-status-chip.is-ai {
background: rgba(25, 118, 210, 0.12);
color: #1565c0;
}
.mnote-vault-hint-mini {
margin: 4px 0 0;
color: #9b9a97;
font-size: 12px;
line-height: 18px;
}
.mnote-vault-linkish {
border: 0;
padding: 0;
background: none;
color: #1565c0;
font: inherit;
font-size: inherit;
text-decoration: underline;
cursor: pointer;
}
.mnote-vault-list-item.is-shared-ai {
box-shadow: inset 3px 0 0 #2e7d32;
}
.mnote-vault-list-item.is-ai-copy {
box-shadow: inset 3px 0 0 #1565c0;
}
.mnote-vault-workbench[data-vault-role="ai"] .mnote-vault-header h1 {
color: #1565c0;
}
.mnote-vault-detail {
min-width: 0;
min-height: 0;
max-height: calc(100vh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 18px 22px 28px;
}
.mnote-vault-empty {
padding: 28px 12px;
color: #8b8782;
font-size: 13px;
line-height: 20px;
}
.mnote-vault-detail-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.mnote-vault-detail-header h2 {
margin: 0;
min-width: 0;
font-size: 20px;
font-weight: 650;
line-height: 28px;
}
.mnote-vault-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mnote-vault-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.mnote-vault-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) auto;
align-items: start;
gap: 8px 12px;
padding: 8px 0;
border-top: 1px solid rgba(27, 28, 28, 0.06);
}
.mnote-vault-field-row > label {
padding-top: 4px;
color: #6d6a65;
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.mnote-vault-field-row > div,
.mnote-vault-field-row > pre {
min-width: 0;
margin: 0;
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.mnote-vault-field-row input[type="text"],
.mnote-vault-field-row input[type="password"],
.mnote-vault-field-row textarea {
width: 100%;
box-sizing: border-box;
min-height: 30px;
padding: 6px 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: inherit;
font: inherit;
font-size: 13px;
line-height: 18px;
}
.mnote-vault-field-row textarea {
min-height: 120px;
resize: vertical;
}
.mnote-vault-secret-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.04em;
}
.mnote-vault-secret-actions,
.mnote-vault-secret-edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mnote-vault-secret-edit {
grid-column: 2 / -1;
}
.mnote-vault-secret-edit input {
flex: 1 1 180px;
}
.mnote-vault-muted {
color: #9b9a97;
}
.mnote-vault-notes {
grid-template-columns: 96px minmax(0, 1fr);
}
.mnote-vault-notes pre {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
color: #37352f;
}
.mnote-vault-form .mnote-vault-field-row {
grid-template-columns: 120px minmax(0, 1fr);
}
.mnote-vault-folder-controls {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.mnote-vault-folder-controls select {
width: 100%;
box-sizing: border-box;
min-height: 30px;
padding: 6px 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: inherit;
font: inherit;
font-size: 13px;
line-height: 18px;
}
.mnote-vault-folder-controls input[type="text"] {
width: 100%;
box-sizing: border-box;
}
@media (max-width: 820px) {
.mnote-vault-workbench {
width: calc(100vw - 24px);
margin: 18px auto 28px;
}
.mnote-vault-body {
grid-template-columns: 1fr;
min-height: 0;
}
.mnote-vault-list {
max-height: 220px;
border-right: 0;
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
}
.mnote-vault-detail {
max-height: none;
}
.mnote-vault-field-row {
grid-template-columns: 1fr;
}
.mnote-vault-form .mnote-vault-field-row {
grid-template-columns: 1fr;
}
.mnote-vault-secret-edit {
grid-column: auto;
}
}
/* Folder tree list */
.mnote-vault-folder {
border-bottom: 1px solid rgba(27, 28, 28, 0.05);
}
.mnote-vault-site-group {
border-bottom: 0;
}
.mnote-vault-site-toggle .mnote-vault-folder-name {
font-weight: 500;
color: #5c5a56;
}
.mnote-vault-secret-input-plain {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
letter-spacing: 0.01em;
}
.mnote-vault-folder-label {
padding: 4px 10px 2px;
color: #8b8782;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.mnote-vault-folder-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
padding: 3px 8px 3px calc(8px + var(--vault-depth, 0) * 10px);
border: 0;
background: transparent;
color: #37352f;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 16px;
text-align: left;
cursor: pointer;
}
.mnote-vault-folder-toggle:hover {
background: #f1f0ef;
}
.mnote-vault-folder-chevron {
display: inline-block;
width: 12px;
color: #8b8782;
font-size: 11px;
}
.mnote-vault-folder-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-folder-count {
flex: 0 0 auto;
color: #8b8782;
font-size: 11px;
font-weight: 500;
}
.mnote-vault-list-item-wrap {
padding-left: calc(var(--vault-depth, 0) * 10px);
}
.mnote-vault-list-item-wrap .mnote-vault-list-item {
border-bottom-color: rgba(27, 28, 28, 0.04);
}
.mnote-vault-hint {
margin: 8px 0 0;
color: #8b8782;
font-size: 12px;
line-height: 18px;
}
.mnote-vault-hint code {
padding: 0 4px;
border-radius: 3px;
background: #f1f0ef;
font-size: 11px;
}
.mnote-vault-sibling-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mnote-vault-sibling {
height: 26px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: #37352f;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.mnote-vault-sibling:hover {
background: #f7f7f6;
}
/* Cipher book panel */
.mnote-vault-cipher-add {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0 16px;
}
.mnote-vault-cipher-add input {
flex: 1 1 120px;
min-width: 100px;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
font: inherit;
font-size: 13px;
}
.mnote-vault-cipher-add button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.mnote-vault-cipher-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(27, 28, 28, 0.06);
}
.mnote-vault-cipher-key {
font-size: 13px;
font-weight: 600;
}
.mnote-vault-cipher-value {
overflow: hidden;
color: #37352f;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-cipher-actions {
display: flex;
gap: 6px;
}
.mnote-vault-cipher-actions button {
height: 26px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.mnote-vault-cipher-actions button.is-danger {
color: #c93a32;
border-color: rgba(201, 58, 50, 0.28);
}
@media (max-width: 820px) {
.mnote-vault-cipher-row {
grid-template-columns: 1fr;
}
}
@@ -129,6 +129,14 @@ fn render_filetree_row(
html.push_str("</li>");
}
/// Placeholder FileTree shell for shell-first home SSR.
/// Client hydrates rows via `/api/tree/projections/file` without blocking first paint.
pub fn render_filetree_pending_shell_html() -> String {
String::from(
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="pending_shell_v1" data-filetree-ssr="pending" aria-busy="true"></ul>"#,
)
}
pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
@@ -306,4 +314,13 @@ mod tests {
assert!(html.contains(r#"data-index-status="indexed""#));
assert!(html.contains(r#"<button type="button" class="tree-link""#));
}
#[test]
fn filetree_pending_shell_marks_ssr_pending_for_client_hydrate() {
let html = super::render_filetree_pending_shell_html();
assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#));
assert!(html.contains(r#"data-filetree-ssr="pending""#));
assert!(html.contains(r#"aria-busy="true""#));
assert!(!html.contains("tree-row"));
}
}
@@ -10,6 +10,8 @@ pub struct PageTreeRenderRow {
pub expandable: bool,
pub expanded: bool,
pub openable: bool,
/// Local-folder relative path used for lazy children fetch (directory scope).
pub expand_relative_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -105,7 +107,7 @@ fn render_page_row(
.unwrap_or(false);
let toggle_html = if row.expandable {
format!(
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}" aria-expanded="{expanded_state}">{marker}</button>"#,
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}" aria-expanded="{expanded_state}" data-expanded="{expanded_state}">{marker}</button>"#,
node_id = escape_html(&row.node_id),
label = if expanded { "折叠" } else { "展开" },
title = escape_html(&row.title),
@@ -115,21 +117,29 @@ fn render_page_row(
} else {
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
};
let parent_attr = input
let source_row = input
.rows
.iter()
.find(|source| source.node_id == row.node_id)
.find(|source| source.node_id == row.node_id);
let parent_attr = source_row
.and_then(|source| source.parent_node_id.as_deref())
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
.unwrap_or_default();
let expand_path_attr = source_row
.and_then(|source| source.expand_relative_path.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|path| format!(r#" data-local-relative-path="{}""#, escape_html(path)))
.unwrap_or_default();
let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth);
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-page-openable="{openable}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}" data-page-openable="{openable}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr}{expand_path_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-page-openable="{openable}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}" data-page-openable="{openable}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = render_depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
test_id = row.test_id,
parent_attr = parent_attr,
expand_path_attr = expand_path_attr,
depth = render_depth,
active = active,
selected = active,
@@ -140,18 +150,22 @@ fn render_page_row(
toggle_html = toggle_html,
title = escape_html(&row.title),
));
// Always emit a children container for expandable rows so lazy expand can
// inject scope rows without a second full-tree snapshot (Sidex-style).
if row.expandable {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(if expanded {
r#"<ul class="tree-children">"#
} else {
r#"<ul class="tree-children tree-children--collapsed">"#
});
for child in children {
render_page_row(html, child, children_by_parent, input);
}
html.push_str("</ul>");
let children = children_by_parent
.get(&Some(row.node_id.clone()))
.map(Vec::as_slice)
.unwrap_or(&[]);
html.push_str(if expanded {
r#"<ul class="tree-children">"#
} else {
r#"<ul class="tree-children tree-children--collapsed">"#
});
for child in children {
render_page_row(html, child, children_by_parent, input);
}
html.push_str("</ul>");
}
html.push_str("</li>");
}
@@ -210,6 +224,7 @@ mod tests {
expandable: true,
expanded: false,
openable: true,
expand_relative_path: Some("home".into()),
},
PageTreeRenderRow {
node_id: "page_child".into(),
@@ -219,6 +234,7 @@ mod tests {
expandable: false,
expanded: false,
openable: true,
expand_relative_path: None,
},
]);
@@ -242,6 +258,7 @@ mod tests {
expandable: true,
expanded: true,
openable: true,
expand_relative_path: Some("home".into()),
},
PageTreeRenderRow {
node_id: "page_child".into(),
@@ -251,6 +268,7 @@ mod tests {
expandable: false,
expanded: false,
openable: true,
expand_relative_path: None,
},
],
active_node_id: Some("page_root".into()),
@@ -266,9 +284,31 @@ mod tests {
assert!(html.contains("data-rust-action=\"create\""));
assert!(html.contains("draggable=\"true\""));
assert!(html.contains("tree-children"));
assert!(html.contains(r#"data-local-relative-path="home""#));
assert!(html.contains("子页"));
assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-active=\"true\""));
assert!(html.contains("data-focused=\"true\""));
}
#[test]
fn tree_shell_page_renderer_emits_empty_children_container_for_lazy_expandable() {
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows: vec![PageTreeRenderRow {
node_id: "page_lazy".into(),
parent_node_id: None,
title: "可展开".into(),
depth: 0,
expandable: true,
expanded: false,
openable: true,
expand_relative_path: Some("docs".into()),
}],
active_node_id: None,
focused_node_id: None,
});
assert!(html.contains("tree-children--collapsed"));
assert!(html.contains(r#"data-local-relative-path="docs""#));
assert!(html.contains("data-rust-action=\"toggle\""));
}
}
+116 -1
View File
@@ -46,6 +46,64 @@ pub struct WorkspaceShellEntry {
pub icon: String,
}
/// 使用页面树投影生成顶栏页面祖先链。
pub fn render_page_breadcrumb_html(
projection: &WorkspaceShellProjection,
active_page_id: Option<&str>,
) -> String {
let Some(active_page_id) = active_page_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return String::new();
};
let by_id = projection
.my_page_items
.iter()
.map(|item| (item.id.as_str(), item))
.collect::<std::collections::HashMap<_, _>>();
let mut chain = Vec::new();
let mut cursor = Some(active_page_id);
let mut seen = std::collections::HashSet::new();
while let Some(id) = cursor {
if !seen.insert(id) {
break;
}
let Some(item) = by_id.get(id) else {
break;
};
chain.push(*item);
cursor = item.parent_id.as_deref();
if chain.len() >= 64 {
break;
}
}
chain.reverse();
chain
.iter()
.enumerate()
.map(|(index, item)| {
let separator = if index == 0 {
String::new()
} else {
r#"<span class="wolai-breadcrumb-separator" aria-hidden="true"></span>"#.to_string()
};
let title = escape_html(&item.title);
let id = escape_html(&item.id);
if index + 1 == chain.len() {
format!(
r#"{separator}<span class="wolai-breadcrumb-current" data-breadcrumb-document-id="{id}"><span class="material-symbols-outlined wolai-home-icon" data-icon="home" aria-hidden="true"></span><span data-page-title-current="true">{title}</span></span>"#
)
} else {
format!(
r#"{separator}<a class="wolai-breadcrumb-link" data-breadcrumb-document-id="{id}" href="{}">{title}</a>"#,
escape_html(&item.href)
)
}
})
.collect()
}
pub fn build_workspace_shell_projection(
dataset: &Value,
workspace_id: &str,
@@ -553,6 +611,32 @@ mod tests {
.any(|entry| entry.label == "模板中心"));
}
#[test]
fn page_breadcrumb_renders_all_ancestors_as_links() {
let projection = build_workspace_shell_projection(
&json!({
"workspaces": [{ "id": "ws_demo", "name": "我的空间" }],
"documents": [
{ "id": "root", "workspace_id": "ws_demo", "title": "个人", "parent_id": null },
{ "id": "parent", "workspace_id": "ws_demo", "title": "密码", "parent_id": "root" },
{ "id": "current", "workspace_id": "ws_demo", "title": "deepseek ai", "parent_id": "parent" }
]
}),
"ws_demo",
Some("current"),
"我的空间",
);
let html = render_page_breadcrumb_html(&projection, Some("current"));
assert!(html.contains(r#"data-breadcrumb-document-id="root""#));
assert!(html.contains(r#"data-breadcrumb-document-id="parent""#));
assert!(html.contains(r#"data-breadcrumb-document-id="current""#));
assert!(html.contains(r#"href="/documents/root?workspaceId=ws_demo""#));
assert!(html.contains(r#"href="/documents/parent?workspaceId=ws_demo""#));
assert!(html.contains("deepseek ai"));
assert_eq!(html.matches("wolai-breadcrumb-separator").count(), 2);
}
#[test]
fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() {
let dataset = json!({
@@ -652,6 +736,7 @@ mod tests {
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree" hidden"#));
assert!(html.contains(r#"id="sidebar-tree-root""#));
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(!html.contains(r#"data-filetree-ssr="pending""#));
assert!(!html.contains("wolai-file-tree-section"));
}
@@ -687,6 +772,27 @@ mod tests {
assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
}
#[test]
fn workspace_shell_sidebar_html_propagates_pending_filetree_shell_marker() {
let dataset = json!({
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": []
});
let projection =
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(
&projection,
Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
Some(r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="pending_shell_v1" data-filetree-ssr="pending" aria-busy="true"></ul>"#),
None,
None,
);
assert!(html.contains(r#"id="sidebar-file-tree-root""#));
assert!(html.contains(r#"data-filetree-ssr="pending""#));
assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#));
assert!(html.contains(r#"aria-busy="true""#));
}
#[test]
fn workspace_shell_sidebar_html_marks_filetree_scope() {
let dataset = json!({
@@ -805,8 +911,17 @@ pub fn render_workspace_shell_sidebar_html(
)
})
.unwrap_or_default();
// Propagate shell-first pending marker onto the FileTree root so the client
// can hydrate without scanning for nested placeholders.
let pending_attr = if html.contains("data-filetree-ssr=\"pending\"")
|| html.contains("data-rust-filetree-renderer=\"pending_shell_v1\"")
{
r#" data-filetree-ssr="pending" aria-busy="true""#
} else {
""
};
format!(
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}{pending_attr}>{html}</div></div>"#,
escape_html(&projection.workspace_id),
)
})