feat: cut over rust web main shell
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user