chore: 收口 review 执行清单与 runtime 验证

- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录

- 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目

- 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑

- 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
lix-2026
2026-05-14 05:52:08 +08:00
parent b4a452a8b7
commit 96e03645f7
69 changed files with 4780 additions and 979 deletions
+59 -2
View File
@@ -102,6 +102,14 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
)
}
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
execution
.artifacts
.as_ref()
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
.unwrap_or(Value::Null)
}
fn stamp_documents_headers(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
@@ -711,13 +719,16 @@ pub async fn title(
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let result = execution.result;
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
Ok((
@@ -731,6 +742,8 @@ pub async fn title(
"meta": {
"commandName": "page.head.updateTitle",
"canonicalCommand": "page.head.updateTitle",
"artifacts": artifacts,
"artifactError": artifact_error,
},
"result": result,
})),
@@ -799,13 +812,16 @@ pub async fn options(
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let result = execution.result;
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
Ok((
@@ -819,6 +835,8 @@ pub async fn options(
"meta": {
"commandName": "page.layout.updateOptions",
"canonicalCommand": "page.layout.updateOptions",
"artifacts": artifacts,
"artifactError": artifact_error,
},
"result": result,
})),
@@ -907,6 +925,14 @@ mod tests {
"updated_at": "2026-04-18T09:45:00Z",
"revision": 8,
"conflict_detection_key": "doc_1:8"
},
"bridgeLogs:recordCommandLog": {
"ok": true,
"id": "clog_fixture"
},
"bridgeLogs:recordDomainEvent": {
"ok": true,
"id": "evt_fixture"
}
}"#
.into(),
@@ -1066,6 +1092,24 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["meta"]["commandName"], "page.head.updateTitle");
assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"page.head.updateTitle"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
"tree.node.renamed"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
"upsert_document"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
["title"],
"服务端页面(改名)"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
}
#[tokio::test]
@@ -1102,6 +1146,19 @@ mod tests {
payload["meta"]["canonicalCommand"],
"page.layout.updateOptions"
);
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"page.layout.updateOptions"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
"page.layout.options_updated"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
"resync_required"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
}
#[tokio::test]
@@ -402,4 +402,51 @@ mod tests {
})
);
}
#[tokio::test]
async fn mindmap_command_apply_returns_object_artifacts_without_tree_resync() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/mindmap/doc_1/mind_1")
.header("content-type", "application/json")
.body(Body::from(
json!({
"commandName": "mindmap.command.apply",
"commands": [
{
"type": "updateText",
"mindmapId": "mind_1",
"nodeId": "root",
"text": "KMIND 已更新"
}
],
"projectionRevision": 1
})
.to_string(),
))
.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["commandName"], "mindmap.command.apply");
assert_eq!(
payload["artifacts"]["domainEvent"]["eventType"],
"mindmap.content.updated"
);
assert_eq!(
payload["artifacts"]["domainEvent"]["payload"]["streamDelta"],
json!({
"op": "noop"
})
);
}
}
+284 -9
View File
@@ -1,9 +1,10 @@
use crate::app::AppState;
use crate::app::AppConfig;
use crate::error::WebError;
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, Method, Request, Uri};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
@@ -715,6 +716,8 @@ pub async fn proxy(
}
pub async fn callback(
State(state): State<AppState>,
uri: Uri,
Query(query): Query<OnlyOfficeCallbackQuery>,
Json(body): Json<Value>,
) -> Response {
@@ -728,10 +731,41 @@ pub async fn callback(
status,
"OnlyOffice callback received by mnote-web"
);
Json(json!({ "error": 0 })).into_response()
match proxy_legacy_onlyoffice_json(
state.config(),
"/api/onlyoffice/callback",
uri.query(),
Some(body),
)
.await
{
Ok(response) => response,
Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
axum::http::StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": 1,
"degraded": true,
"code": "onlyoffice_legacy_writeback_unavailable",
"message": error.message(),
})),
)
.into_response(),
Err(error) => (
axum::http::StatusCode::BAD_GATEWAY,
Json(json!({
"error": 1,
"degraded": true,
"code": "onlyoffice_legacy_writeback_failed",
"message": error.message(),
})),
)
.into_response(),
}
}
pub async fn forcesave(
State(state): State<AppState>,
uri: Uri,
Query(query): Query<OnlyOfficeForcesaveQuery>,
) -> Result<Response, WebError> {
let asset_id = query
@@ -750,13 +784,85 @@ pub async fn forcesave(
.ok_or_else(|| {
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
})?;
Ok(Json(json!({
"ok": true,
"via": "mnote-web-rust-noop",
"assetId": asset_id,
"key": key,
}))
.into_response())
let response = proxy_legacy_onlyoffice_json(
state.config(),
"/api/onlyoffice/forcesave",
uri.query(),
None,
)
.await
.map_err(|error| {
if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
return WebError::new(
axum::http::StatusCode::NOT_IMPLEMENTED,
"onlyoffice_legacy_writeback_unavailable",
format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"),
);
}
error
})?;
Ok(response)
}
fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
config
.legacy_next_base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_end_matches('/').to_string())
}
async fn proxy_legacy_onlyoffice_json(
config: &AppConfig,
path: &str,
query: Option<&str>,
body: Option<Value>,
) -> Result<Response, WebError> {
let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
WebError::new(
axum::http::StatusCode::NOT_IMPLEMENTED,
"onlyoffice_legacy_writeback_unavailable",
"OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链",
)
})?;
let target = append_path_and_query(&base, path, query);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| {
WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"))
})?;
let mut request = client
.post(target)
.header(header::CONTENT_TYPE, "application/json");
if let Some(body) = body {
request = request.json(&body);
} else {
request = request.body("{}");
}
let upstream = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"onlyoffice_legacy_writeback_failed",
format!("OnlyOffice legacy 写回请求失败: {error}"),
)
})?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(header::CONTENT_TYPE)
.cloned()
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
let bytes = upstream.bytes().await.map_err(|error| {
WebError::bad_gateway_code(
"onlyoffice_legacy_writeback_failed",
format!("OnlyOffice legacy 写回响应读取失败: {error}"),
)
})?;
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, content_type);
Ok(response)
}
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
@@ -987,6 +1093,13 @@ fn js_hash_abs(input: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{AppConfig, AppState};
use axum::extract::State;
use axum::http::StatusCode;
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
#[test]
fn stable_doc_key_uses_onlyoffice_safe_characters() {
@@ -1003,4 +1116,166 @@ mod tests {
let candidates = onlyoffice_internal_candidates();
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
}
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
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,
enable_legacy_next_compat: true,
enable_debug_shell_routes: 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: false,
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(),
})
}
async fn spawn_legacy_json_server(
response_body: &'static str,
) -> (String, oneshot::Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
let mut buffer = vec![0_u8; 8192];
let read = stream.read(&mut buffer).await.expect("read");
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
let _ = tx.send(request);
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream.write_all(response.as_bytes()).await.expect("write");
});
(format!("http://{addr}"), rx)
}
#[tokio::test]
async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
let response = callback(
State(test_state(None)),
"/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
}),
Json(json!({
"status": 2,
"url": "http://127.0.0.1:8082/cache/files/out.docx"
})),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
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["error"], 1);
assert_eq!(payload["degraded"], true);
}
#[tokio::test]
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
let response = callback(
State(test_state(Some(base_url))),
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": "http://127.0.0.1:8082/cache/files/out.docx"
})),
)
.await;
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let request = captured.await.expect("captured");
assert_eq!(payload["error"], 0);
assert!(request.starts_with(
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
));
assert!(request.contains(r#""status":2"#));
assert!(request.contains(r#""key":"doc_key""#));
}
#[tokio::test]
async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
let response = forcesave(
State(test_state(None)),
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeForcesaveQuery {
asset_id: Some("asset_1".into()),
key: Some("doc_key".into()),
}),
)
.await
.map(IntoResponse::into_response)
.unwrap_or_else(IntoResponse::into_response);
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
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["ok"], false);
assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
assert_ne!(payload["via"], "mnote-web-rust-noop");
}
#[tokio::test]
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
let (base_url, captured) =
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#)
.await;
let response = forcesave(
State(test_state(Some(base_url))),
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeForcesaveQuery {
asset_id: Some("asset_1".into()),
key: Some("doc_key".into()),
}),
)
.await
.expect("response");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let request = captured.await.expect("captured");
assert_eq!(payload["ok"], true);
assert_eq!(payload["via"], "forcesave");
assert!(request.starts_with(
"POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"
));
}
}
+65 -1
View File
@@ -175,6 +175,8 @@ pub async fn documents(
"owner": "mnote-web",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query",
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
},
@@ -239,12 +241,13 @@ async fn load_search_results_with_filters(
.await
{
Ok(value) => Ok(value),
Err(_) => execute_runtime_query_against_data(
Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data(
context,
Some(workspace_id),
runtime_query,
fallback_search_dataset(workspace_id),
),
Err(error) => Ok(degraded_empty_search_projection(error.message())),
}
}
@@ -256,6 +259,16 @@ fn empty_search_projection() -> Value {
})
}
fn degraded_empty_search_projection(reason: &str) -> Value {
json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
"results": [],
"degraded": true,
"degradedReason": reason,
})
}
fn fallback_search_dataset(workspace_id: &str) -> Value {
json!({
"documents": [
@@ -413,6 +426,8 @@ mod tests {
assert!(html.contains("search.documents.query"));
assert!(html.contains("search-result"));
assert!(html.contains("Rust Web 搜索结果"));
assert!(html.contains("data-mnote-dev-fixture=\"true\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
}
#[tokio::test]
@@ -467,4 +482,53 @@ mod tests {
assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
assert_eq!(payload["projectionOwner"], "rust-kernel");
}
#[tokio::test]
async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() {
let app = build_app(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: true,
enable_debug_shell_routes: 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: false,
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(),
}));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"query": "Hermes"
})
.to_string(),
))
.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["results"], json!([]));
assert_eq!(payload["meta"]["degraded"], true);
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
}
}
+125 -2
View File
@@ -2814,13 +2814,17 @@ fn build_tree_shell_html(
}
if (sourceKind === "convex_workspace") {
for (const item of deletableItems) {
const documentId = getFileTreeRowDocumentId(item);
await sendCommand({
action: "delete",
workspaceId,
documentId: getFileTreeRowDocumentId(item),
documentId,
});
applyRemovedDocumentLocally(documentId);
}
if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
scheduleRefresh();
}
scheduleRefresh();
return true;
}
postToHost("tree.filetree.delete", {
@@ -2986,6 +2990,113 @@ fn build_tree_shell_html(
}, 80);
};
const addTreeItemLocally = (item) => {
if (!item?.nodeId || itemById.has(item.nodeId)) return false;
normalizedItems.push(item);
itemById.set(item.nodeId, item);
fileTreeRowById.set(item.rowId, item);
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
if (parentId) {
const bucket = childrenByParentId.get(parentId) || [];
bucket.push(item);
bucket.sort(compareItems);
childrenByParentId.set(parentId, bucket);
const parentItem = itemById.get(parentId);
if (parentItem) parentItem.childCount = Math.max(parentItem.childCount || 0, bucket.length);
expanded.add(parentId);
} else {
roots.push(item);
roots.sort(compareItems);
}
return true;
};
const applyCreatedDocumentLocally = (result, parentId, title) => {
const documentId =
typeof result?.documentId === "string" && result.documentId.trim()
? result.documentId.trim()
: typeof result?.id === "string" && result.id.trim()
? result.id.trim()
: "";
if (!documentId) return false;
const createdAt = normalizeText(result?.updatedAt || result?.execution?.updated_at);
const parentNodeId = parentId && itemById.has(parentId) ? parentId : null;
const item = {
rowId: `doc:${documentId}`,
rowKind: "document",
nodeId: documentId,
parentNodeId,
title: normalizeText(result?.title, title || "无标题"),
depth: parentNodeId ? normalizeNumber(itemById.get(parentNodeId)?.depth, 0) + 1 : 0,
childCount: 0,
position: normalizeNumber(result?.sortOrder ?? result?.execution?.sort_order ?? Date.now()),
expandedByDefault: true,
iconHint: "page",
capabilities: ["open", "rename", "delete", "move", "create"],
resourceMeta: {
resourceKind: "document",
documentId,
assetId: "",
assetKind: "",
objectIdentity: { objectKind: "page", documentId, blockId: null, assetId: null },
blockAssetRelation: null,
},
updatedAt: createdAt,
};
if (!addTreeItemLocally(item)) return false;
currentFocusedDocumentId = documentId;
focusedNodeId = documentId;
currentActiveDocumentId = documentId;
renderTree();
return true;
};
const removeTreeItemEverywhere = (item) => {
if (!item) return;
const itemIndex = normalizedItems.indexOf(item);
if (itemIndex >= 0) normalizedItems.splice(itemIndex, 1);
itemById.delete(item.nodeId);
fileTreeRowById.delete(item.rowId);
const rootIndex = roots.indexOf(item);
if (rootIndex >= 0) roots.splice(rootIndex, 1);
const bucket = item.parentNodeId ? childrenByParentId.get(item.parentNodeId) : null;
if (bucket) {
const bucketIndex = bucket.indexOf(item);
if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
if (bucket.length === 0) childrenByParentId.delete(item.parentNodeId);
}
};
const applyRemovedDocumentLocally = (documentId) => {
const normalizedDocumentId = normalizeText(documentId);
if (!normalizedDocumentId) return false;
const removedNodeIds = new Set([normalizedDocumentId]);
let changed = false;
let expandedDuringScan = true;
while (expandedDuringScan) {
expandedDuringScan = false;
normalizedItems.forEach((item) => {
if (item.parentNodeId && removedNodeIds.has(item.parentNodeId) && !removedNodeIds.has(item.nodeId)) {
removedNodeIds.add(item.nodeId);
expandedDuringScan = true;
}
});
}
normalizedItems.slice().forEach((item) => {
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
removeTreeItemEverywhere(item);
changed = true;
}
});
if (!changed) return false;
if (currentActiveDocumentId === normalizedDocumentId) currentActiveDocumentId = roots[0]?.nodeId || "";
if (currentFocusedDocumentId === normalizedDocumentId) currentFocusedDocumentId = currentActiveDocumentId;
focusedNodeId = resolveFocusedNodeIdFromHostState();
renderTree();
return true;
};
if (sourceKind === "local_folder" && rootUri) {
let localWatchRevision = initialLocalWatchRevision;
let localWatchRefreshTimer = 0;
@@ -4335,6 +4446,14 @@ fn build_tree_shell_html(
payload: { documentId },
});
}
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
if (mode === "filetree") {
beginInlineRename("filetree", `doc:${documentId}`);
} else {
beginInlineRename("page", documentId);
}
return;
}
scheduleRefresh({
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
});
@@ -6688,6 +6807,10 @@ mod tests {
assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(html.contains("hydrateInitialPageTree"));
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
assert!(html.contains("applyCreatedDocumentLocally"));
assert!(html.contains("applyRemovedDocumentLocally"));
assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
assert!(html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
assert!(html.contains("application/x-mnote-page-tree-node"));
assert!(html.contains("页面已拖放到"));
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
+96 -18
View File
@@ -2437,10 +2437,9 @@ pub(crate) async fn load_workspace_shell_projection(
query: None,
max_results: None,
};
let dataset = load_projection_snapshot(config, context, &spec)
.await
.map(|snapshot| snapshot.dataset)
.unwrap_or_else(|_| {
let dataset = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => snapshot.dataset,
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
.map(|document_id| {
json!([{
@@ -2457,9 +2456,19 @@ pub(crate) async fn load_workspace_shell_projection(
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": documents
"documents": documents,
"dev_fixture": true
})
});
}
Err(_) => json!({
"active_workspace_id": workspace_id,
"active_page_id": active_document_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
"degraded": true,
"degraded_reason": "projection_unavailable"
}),
};
build_workspace_shell_projection(
&dataset,
@@ -2489,7 +2498,7 @@ pub(crate) async fn load_sidebar_tree_html(
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some(snapshot.projection),
Ok(snapshot) => Some((snapshot.projection, false)),
Err(_) if config.allow_dev_fixtures => {
// Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。
let documents = active_document_id
@@ -2518,17 +2527,20 @@ pub(crate) async fn load_sidebar_tree_html(
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
.ok()
.map(|projection| (projection, true))
}
Err(_) => None,
};
result.map(|projection| {
result.map(|(projection, dev_fixture)| {
let rows = collect_page_tree_render_rows(&projection);
render_initial_page_tree_html(&PageTreeInitialRenderInput {
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
})
});
mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
})
}
@@ -2550,7 +2562,7 @@ pub(crate) async fn load_file_tree_html(
max_results: None,
};
let result = match load_projection_snapshot(config, context, &spec).await {
Ok(snapshot) => Some(snapshot.projection),
Ok(snapshot) => Some((snapshot.projection, false)),
Err(_) if config.allow_dev_fixtures => {
let documents = active_document_id
.map(|document_id| {
@@ -2577,16 +2589,28 @@ pub(crate) async fn load_file_tree_html(
"mindmap_docs": [],
"mindmap_asset_children": {}
});
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok()
execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
.ok()
.map(|projection| (projection, true))
}
Err(_) => None,
};
result.map(|projection| {
result.map(|(projection, dev_fixture)| {
let rows = collect_filetree_render_rows(&projection, active_document_id);
render_initial_filetree_html(&FileTreeInitialRenderInput { rows })
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
mark_dev_fixture_html(html, dev_fixture, "file-tree")
})
}
fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
if !dev_fixture {
return html;
}
format!(
r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
)
}
pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
@@ -2614,8 +2638,9 @@ pub(crate) fn render_local_file_tree_html(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{header, Request, StatusCode};
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -2701,6 +2726,9 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("data-testid=\"wolai-sidebar\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
assert!(html.contains("data-testid=\"wolai-topbar\""));
assert!(html.contains("data-testid=\"wolai-floating-ai\""));
assert!(html.contains("星标置顶"));
@@ -2755,7 +2783,7 @@ mod tests {
.headers()
.get("x-mnote-page-aggregate-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-kernel")
Some("compat-join")
);
assert_eq!(
response
@@ -2770,7 +2798,7 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["owner"], "mnote-web");
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
assert_eq!(payload["result"]["source"], "KernelProjection");
assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
assert_eq!(payload["result"]["projectionVersion"], 1);
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
@@ -2893,6 +2921,56 @@ mod tests {
));
}
#[tokio::test]
async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
let config = 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: true,
enable_debug_shell_routes: 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: false,
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(),
};
let headers = HeaderMap::new();
let context = RequestContext::from_http_parts(
&Method::GET,
&"/documents/doc_1?workspaceId=ws_demo"
.parse::<Uri>()
.expect("uri"),
&headers,
);
let sidebar_html =
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
let filetree_html =
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
let workspace_projection = super::load_workspace_shell_projection(
&config,
&context,
"ws_demo",
Some("doc_1"),
"个人空间",
)
.await;
assert!(sidebar_html.is_none());
assert!(filetree_html.is_none());
assert!(workspace_projection.degraded);
assert!(workspace_projection.my_page_items.is_empty());
assert!(!workspace_projection.dev_fixture);
}
#[tokio::test]
async fn document_shell_renders_local_markdown_attachment_name_in_html() {
let root = std::env::temp_dir().join(format!(