feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
+201 -23
View File
@@ -5,34 +5,37 @@ use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
};
use crate::tree_shell::picker_renderer::{
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
};
use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput,
};
use crate::tree_shell::runtime_api::{
TreeShellRuntimeRequest, TreeShellRuntimeResult,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
TreeShellRuntimeResult,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -90,6 +93,10 @@ pub enum TreeCommandRequest {
parent_id: Option<String>,
sort_order: i64,
},
Purge {
workspace_id: Option<String>,
document_id: String,
},
}
fn escape_html(input: &str) -> String {
@@ -201,7 +208,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
.unwrap_or_default()
}
fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
projection
.get("items")
.and_then(Value::as_array)
@@ -4467,9 +4474,98 @@ fn create_command_wire(
validate_only: false,
})
}
TreeCommandRequest::Purge {
workspace_id: _,
document_id,
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
Ok(RuntimeCommandEnvelopeWire {
name: "tree.node.purge".into(),
command_id: format!("tree_purge_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: bridge_runtime::RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
}),
preflight_data: None,
reason: Some("tree-shell purge".into()),
refs: vec!["mnote-web-tree".into()],
dry_run: false,
validate_only: false,
})
}
}
}
async fn resolve_tree_create_workspace_id(
state: &AppState,
context: &RequestContext,
requested_workspace_id: Option<&str>,
parent_id: Option<&str>,
) -> Result<String, WebError> {
if let Some(workspace_id) =
resolve_effective_workspace_id(context, requested_workspace_id, false)?
{
return Ok(workspace_id);
}
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
let parent_meta =
fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?;
if let Some(workspace_id) = parent_meta
.get("workspace_id")
.or_else(|| parent_meta.get("workspaceId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(workspace_id.to_string());
}
}
let bootstrap = execute_convex_mutation_by_name(
state.config(),
context,
"workspaces:ensureDefaultWorkspace",
json!({
"fallbackName": context.auth.actor_id,
"workspaceIdIfCreate": generate_tree_document_id(),
}),
None,
None,
"tree_command_workspace_bootstrap",
)
.await?;
bootstrap
.get("activeWorkspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
WebError::bad_gateway_code(
"workspace_bootstrap_bad_response",
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
)
.with_context(context)
.with_header("x-error-phase", "tree_command_workspace_bootstrap")
})
}
pub async fn tree_command(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -4504,6 +4600,10 @@ pub async fn tree_command(
parent_id: raw_request.parent_id,
sort_order: raw_request.sort_order.unwrap_or(-1),
},
"purge" => TreeCommandRequest::Purge {
workspace_id: raw_request.workspace_id,
document_id: raw_request.document_id.unwrap_or_default(),
},
other => {
return Err(WebError::bad_request_code(
"tree_command_validation",
@@ -4517,6 +4617,7 @@ pub async fn tree_command(
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(),
};
let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
match &request {
@@ -4553,10 +4654,27 @@ pub async fn tree_command(
None,
Some(*sort_order),
),
TreeCommandRequest::Purge { document_id, .. } => (
"purge",
document_id.clone(),
None,
None,
None,
),
};
let effective_workspace_id =
resolve_effective_workspace_id(&context, requested_workspace_id, true)?
.expect("workspace_required 已确保存在");
let effective_workspace_id = match &request {
TreeCommandRequest::Create { parent_id, .. } => {
resolve_tree_create_workspace_id(
&state,
&context,
requested_workspace_id,
parent_id.as_deref(),
)
.await?
}
_ => resolve_effective_workspace_id(&context, requested_workspace_id, true)?
.expect("workspace_required 已确保存在"),
};
let command_wire = create_command_wire(&context, &effective_workspace_id, request)?;
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
@@ -4607,8 +4725,8 @@ pub async fn reduce_tree_shell_runtime(
#[cfg(test)]
mod tests {
use super::{TreeCommandRequest, create_command_wire};
use crate::app::{AppConfig, AppState, build_app};
use super::{create_command_wire, TreeCommandRequest};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
@@ -4621,6 +4739,9 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
@@ -4628,7 +4749,7 @@ mod tests {
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
@@ -4795,9 +4916,8 @@ mod tests {
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(filetree_html.contains(
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""
));
assert!(filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
@@ -4825,9 +4945,8 @@ mod tests {
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(picker_html.contains(
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""
));
assert!(picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
}
#[tokio::test]
@@ -4991,6 +5110,65 @@ mod tests {
);
}
#[tokio::test]
async fn tree_command_create_uses_default_workspace_when_workspace_missing() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(r#"{"action":"create","title":"新页面"}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("create".into()));
assert_eq!(
payload["result"]["workspaceId"],
Value::String("ws_demo".into())
);
assert_eq!(
payload["result"]["documentId"],
Value::String("page_new".into())
);
}
#[tokio::test]
async fn tree_command_purge_uses_tree_command_protocol() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("purge".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(payload["result"]["execution"]["deletedCount"], Value::from(1));
}
#[tokio::test]
async fn tree_command_rejects_negative_sort_order() {
let response = app()