feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
+3 -3
View File
@@ -4,12 +4,12 @@ use crate::error::WebError;
use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -152,7 +152,7 @@ pub async fn trace(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
+4 -4
View File
@@ -2,15 +2,15 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use axum::Json;
use axum::extract::Query;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::Json;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde::Serialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -94,7 +94,7 @@ pub async fn next_sidebar(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -6,15 +6,15 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::fs;
use std::time::Duration;
@@ -500,8 +500,8 @@ pub async fn save(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
+6 -6
View File
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::documents::{
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
};
use axum::extract::{Extension, Json, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use serde_json::{Value, json};
use tower::util::ServiceExt;
fn app() -> axum::Router {
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::extract::{Extension, State};
use axum::Json;
use axum::extract::{Extension, State};
use serde::Serialize;
#[derive(Debug, Serialize)]
+4 -4
View File
@@ -1,15 +1,15 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::Json;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
execute_runtime_query, runtime_input_requests_result,
};
use serde::Serialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
+29 -11
View File
@@ -3,16 +3,16 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
subtree_query,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -188,8 +188,8 @@ pub async fn graph(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -386,11 +386,13 @@ mod tests {
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap"
);
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand"));
assert!(
item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand")
);
}
#[tokio::test]
@@ -417,6 +419,22 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]);
assert_eq!(
payload["result"]["meta"]["search"]["indexingVisibility"]["schema"],
"mnote.file_tree.indexing_visibility"
);
assert_eq!(
payload["result"]["meta"]["search"]["indexingVisibility"]["status"],
"visible"
);
assert_eq!(
payload["result"]["meta"]["search"]["indexingVisibility"]["visibleResourceKinds"]
.as_array()
.expect("visible resource kinds")
.iter()
.any(|value| value == "asset"),
true
);
assert_eq!(items[0]["expandedByDefault"], true);
assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table");
let edges = payload["result"]["edges"].as_array().expect("edges");
+5 -1
View File
@@ -14,8 +14,8 @@ mod tree;
mod ws;
use crate::app::AppState;
use axum::routing::{get, post};
use axum::Router;
use axum::routing::{get, post};
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
@@ -45,6 +45,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/kernel/edges", get(kernel::edges))
.route("/api/kernel/graph", get(kernel::graph))
.route("/api/tree/commands", post(tree::tree_command))
.route(
"/api/tree/runtime/reduce",
post(tree::reduce_tree_shell_runtime),
)
.route("/api/bridge/workspace", get(bridge::workspace))
.route("/api/bridge/request", get(bridge::request))
.route("/api/bridge/trace", get(bridge::trace))
@@ -3,13 +3,13 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_convex_query_plan;
use bridge_runtime::{
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
RuntimeSourceWire,
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input,
execute_runtime_query,
};
use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value;
use storage_convex_bridge::{build_query_request, BridgeContext};
use storage_convex_bridge::{BridgeContext, build_query_request};
pub fn resolve_effective_workspace_id(
context: &RequestContext,
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
};
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde_json::{json, Value};
use serde_json::{Value, json};
#[derive(Debug, Clone)]
pub struct ProjectionSnapshotSpec<'a> {
+4 -5
View File
@@ -2,9 +2,8 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
StreamSnapshotQuery,
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
};
use axum::extract::{Extension, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
@@ -138,8 +137,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use crate::app::{AppConfig, AppState, build_app};
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
subtree_query,
};
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save",
@@ -588,8 +588,8 @@ pub async fn load_stream_snapshot(
#[cfg(test)]
mod tests {
use super::{
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind,
StreamSnapshotQuery, StreamSnapshotScope,
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change,
resolve_stream_cursor, resolve_stream_scope,
};
use serde_json::json;
+413 -23
View File
@@ -21,6 +21,10 @@ 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,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
@@ -1200,6 +1204,14 @@ fn build_tree_shell_html(
typeof rendererInput.pageFocusKeyboardReducer === "object"
? rendererInput.pageFocusKeyboardReducer
: {};
const runtimeArtifact =
rendererInput.runtimeArtifact && typeof rendererInput.runtimeArtifact === "object"
? rendererInput.runtimeArtifact
: {};
const runtimeApi =
runtimeArtifact.runtimeApi && typeof runtimeArtifact.runtimeApi === "object"
? runtimeArtifact.runtimeApi
: {};
const normalizeStringArray = (value) =>
Array.isArray(value)
? value
@@ -1294,6 +1306,10 @@ fn build_tree_shell_html(
const trimmed = value.trim();
return trimmed || fallback;
};
const runtimeReduceEndpoint = normalizeText(
runtimeApi.reduceEndpoint,
"/api/tree/runtime/reduce",
);
const normalizeParent = (value) => {
const normalized = normalizeText(value);
@@ -2237,8 +2253,34 @@ fn build_tree_shell_html(
});
};
const toggleExpand = (nodeId) => {
const nextExpanded = !expanded.has(nodeId);
const commitPageExpandedIds = (expandedIds) => {
const nextExpanded = new Set(normalizeStringArray(expandedIds));
let changed = nextExpanded.size !== expanded.size;
if (!changed) {
changed = Array.from(nextExpanded).some((nodeId) => !expanded.has(nodeId));
}
if (!changed) return false;
expanded.clear();
nextExpanded.forEach((nodeId) => expanded.add(nodeId));
return true;
};
const patchPageTreeAfterRuntimeState = (changedNodeIds, focusedId) => {
if (mode !== "page") return;
if (usedRustInitialRenderer) {
const ids = normalizeStringArray(changedNodeIds);
ids.forEach((nodeId) => {
patchPageTreeExpansionDom(nodeId);
});
patchPageTreeActiveDom();
if (focusedId) focusRowElement(focusedId);
return;
}
renderTree();
if (focusedId) focusRowElement(focusedId);
};
const applyLocalPageExpansionFallback = (nodeId, nextExpanded) => {
if (nextExpanded) expanded.add(nodeId);
else expanded.delete(nodeId);
postPageExpandChange(nodeId, nextExpanded);
@@ -2249,6 +2291,10 @@ fn build_tree_shell_html(
renderTree();
};
const toggleExpand = (nodeId) => {
applyLocalPageExpansionFallback(nodeId, !expanded.has(nodeId));
};
const getVisiblePageItems = () => {
const visible = [];
const walk = (entries) => {
@@ -2423,7 +2469,167 @@ fn build_tree_shell_html(
focusRowElement(nodeId);
};
const applyPageKeyboardAction = (action, item, sourceElement) => {
const resolvePageActionItem = (action, item) => {
const actionNodeId = normalizeText(action?.nodeId);
if (actionNodeId && itemById.has(actionNodeId)) {
return itemById.get(actionNodeId);
}
if (item?.nodeId && itemById.has(item.nodeId)) {
return item;
}
return focusedNodeId && itemById.has(focusedNodeId)
? itemById.get(focusedNodeId)
: null;
};
const buildPageRuntimeAction = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
if (actionKind === "focus") {
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
return nodeId ? { kind: "focus", nodeId } : null;
}
if (actionKind === "move_next") return { kind: "moveNext" };
if (actionKind === "move_previous") return { kind: "movePrevious" };
if (actionKind === "move_home") return { kind: "moveHome" };
if (actionKind === "move_end") return { kind: "moveEnd" };
if (actionKind === "open") return { kind: "openFocused" };
if (actionKind === "context_menu") return { kind: "contextMenuFocused" };
if (actionKind === "expand" || actionKind === "collapse" || actionKind === "toggle") {
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
return nodeId ? { kind: actionKind, nodeId } : null;
}
return null;
};
const buildPageRuntimeEnvironment = () => ({
visibleNodeIds: getVisiblePageItems().map((entry) => entry.nodeId),
expandableNodeIds: normalizedItems
.filter((entry) => entry.childCount > 0 && getSiblings(entry.nodeId).length > 0)
.map((entry) => entry.nodeId),
});
const readPageRuntimeState = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
const focusedId =
(actionKind === "open" || actionKind === "context_menu") && itemById.has(nodeId)
? nodeId
: focusedNodeId || null;
return {
focusedId,
expandedIds: Array.from(expanded),
dropFeedback: null,
};
};
const reducePageActionWithRuntime = async (action, item) => {
if (mode !== "page" || !runtimeReduceEndpoint) {
return null;
}
const runtimeAction = buildPageRuntimeAction(action, item);
if (!runtimeAction) return null;
const response = await fetch(runtimeReduceEndpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode: "page",
requestId: `page-runtime-${Date.now()}`,
environment: buildPageRuntimeEnvironment(),
state: readPageRuntimeState(action, item),
action: runtimeAction,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return response.json();
};
const normalizePageRuntimeResult = (runtimeResult) => {
if (!runtimeResult || runtimeResult.mode !== "page") {
return null;
}
const stateSnapshot =
runtimeResult.state && runtimeResult.state.mode === "page"
? runtimeResult.state.state
: null;
const pagePatch = Array.isArray(runtimeResult.domPatches)
? runtimeResult.domPatches.find((patch) => patch?.kind === "pageState")
: null;
const focusedId =
typeof pagePatch?.focusedId === "string"
? pagePatch.focusedId
: typeof stateSnapshot?.focusedId === "string"
? stateSnapshot.focusedId
: "";
const expandedIds = Array.isArray(pagePatch?.expandedIds)
? pagePatch.expandedIds
: Array.isArray(stateSnapshot?.expandedIds)
? stateSnapshot.expandedIds
: null;
return {
focusedId: normalizeText(focusedId),
expandedIds: expandedIds ? normalizeStringArray(expandedIds) : null,
hostEvents: Array.isArray(runtimeResult.hostEvents) ? runtimeResult.hostEvents : [],
};
};
const replayPageRuntimeHostEvents = (runtimeResult, item, sourceElement) => {
const result = normalizePageRuntimeResult(runtimeResult);
if (!result) return false;
let replayed = false;
result.hostEvents.forEach((event) => {
if (!event || typeof event !== "object") return;
if (event.kind === "pageOpen") {
const nodeId = normalizeText(event.nodeId);
if (nodeId) {
handleNavigate(nodeId);
replayed = true;
}
return;
}
if (event.kind === "pageContextMenu") {
const nodeId = normalizeText(event.nodeId || item?.nodeId);
if (!nodeId) return;
const rect = sourceElement?.getBoundingClientRect?.();
openContextMenu(
nodeId,
rect ? rect.left + Math.min(rect.width - 12, 28) : 0,
rect ? rect.top + Math.min(rect.height - 12, 18) : 0,
);
replayed = true;
}
});
return replayed;
};
const reconcilePageRuntimeResult = (runtimeResult, item) => {
const result = normalizePageRuntimeResult(runtimeResult);
if (!result) return false;
const previousExpanded = new Set(expanded);
let shouldPatchTree = false;
if (Array.isArray(result.expandedIds)) {
shouldPatchTree = commitPageExpandedIds(result.expandedIds) || shouldPatchTree;
}
if (result.focusedId && result.focusedId !== focusedNodeId) {
focusedNodeId = result.focusedId;
postPageFocusChange(result.focusedId);
shouldPatchTree = true;
}
const itemNodeId = normalizeText(item?.nodeId);
if (itemNodeId && previousExpanded.has(itemNodeId) !== expanded.has(itemNodeId)) {
postPageExpandChange(itemNodeId, expanded.has(itemNodeId));
}
if (shouldPatchTree) {
patchPageTreeAfterRuntimeState(
Array.isArray(result.expandedIds) ? [itemNodeId, ...result.expandedIds] : [itemNodeId],
result.focusedId || focusedNodeId,
);
}
return shouldPatchTree;
};
const applyLocalPageActionFallback = (action, item, sourceElement) => {
if (mode !== "page") return;
const actionKind = normalizeText(action?.kind).toLowerCase();
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
@@ -2467,13 +2673,7 @@ fn build_tree_shell_html(
}
if (actionKind === "expand") {
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
expanded.add(item.nodeId);
postPageExpandChange(item.nodeId, true);
if (usedRustInitialRenderer) {
patchPageTreeExpansionDom(item.nodeId);
} else {
renderTree();
}
applyLocalPageExpansionFallback(item.nodeId, true);
focusRowElement(item.nodeId);
return;
}
@@ -2485,13 +2685,7 @@ fn build_tree_shell_html(
}
if (actionKind === "collapse") {
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
expanded.delete(item.nodeId);
postPageExpandChange(item.nodeId, false);
if (usedRustInitialRenderer) {
patchPageTreeExpansionDom(item.nodeId);
} else {
renderTree();
}
applyLocalPageExpansionFallback(item.nodeId, false);
focusRowElement(item.nodeId);
return;
}
@@ -2522,6 +2716,35 @@ fn build_tree_shell_html(
}
};
const applyPageKeyboardAction = (action, item, sourceElement) => {
if (mode !== "page") return;
const actionKind = normalizeText(action?.kind).toLowerCase();
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
return;
}
const runtimeItem = resolvePageActionItem(action, item);
const runtimeAction = buildPageRuntimeAction(action, runtimeItem);
if (!runtimeAction) {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
return;
}
void reducePageActionWithRuntime(action, runtimeItem)
.then((runtimeResult) => {
const replayedHostEvent = replayPageRuntimeHostEvents(
runtimeResult,
runtimeItem,
sourceElement,
);
const reconciledState = reconcilePageRuntimeResult(runtimeResult, runtimeItem);
if (!replayedHostEvent && !reconciledState) {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
}
})
.catch(() => {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
});
};
const postPickerFocusChange = (pickerItemKey) => {
if (mode !== "picker") return;
const normalizedItemKey = normalizeText(pickerItemKey);
@@ -2976,16 +3199,15 @@ fn build_tree_shell_html(
const action = normalizeText(element.dataset.rustAction);
if (action === "toggle") {
event.preventDefault();
toggleExpand(item.nodeId);
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, element);
} else if (action === "open") {
handleNavigate(item.nodeId);
applyPageKeyboardAction({ kind: "open", nodeId: item.nodeId }, item, element);
} else if (action === "create") {
void handleCreate(item.nodeId);
} else if (action === "rename") {
void handleRename(item.nodeId);
} else if (action === "menu") {
const center = getElementCenter(element);
openContextMenu(item.nodeId, center.x, center.y);
applyPageKeyboardAction({ kind: "context_menu", nodeId: item.nodeId }, item, element);
}
});
});
@@ -3524,7 +3746,7 @@ fn build_tree_shell_html(
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
toggleButton.addEventListener("click", (event) => {
event.stopPropagation();
toggleExpand(item.nodeId);
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);
});
row.appendChild(toggleButton);
} else {
@@ -4377,6 +4599,12 @@ pub async fn tree_command(
))
}
pub async fn reduce_tree_shell_runtime(
Json(body): Json<TreeShellRuntimeRequest>,
) -> Json<TreeShellRuntimeResult> {
Json(reduce_tree_shell_runtime_request(body))
}
#[cfg(test)]
mod tests {
use super::{TreeCommandRequest, create_command_wire};
@@ -4441,6 +4669,19 @@ mod tests {
assert!(html.contains("tree.shell.state.patch"));
assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\""));
assert!(html.contains("applyPageKeyboardAction"));
assert!(html.contains("reducePageActionWithRuntime"));
let apply_page_action_start = html
.find("const applyPageKeyboardAction = (action, item, sourceElement) => {")
.expect("applyPageKeyboardAction should be embedded");
let apply_page_action_end = html[apply_page_action_start..]
.find("\n const postPickerFocusChange")
.expect("applyPageKeyboardAction should end before picker focus handler");
let apply_page_action_body =
&html[apply_page_action_start..apply_page_action_start + apply_page_action_end];
assert!(
!apply_page_action_body.contains("toggleExpand("),
"page keyboard/expand should prefer runtime result instead of directly toggling local expansion state"
);
assert!(html.contains("patchPageTreeActiveDom"));
assert!(html.contains("patchPageTreeExpansionDom"));
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
@@ -4550,7 +4791,16 @@ mod tests {
assert!(filetree_html.contains("\"commandDispatcher\""));
assert!(filetree_html.contains("\"runtimeArtifact\""));
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
assert!(filetree_html.contains("\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"));
assert!(filetree_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
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(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
let picker_response = app()
.oneshot(
@@ -4571,6 +4821,146 @@ mod tests {
assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]"));
assert!(picker_html.contains("\"runtimeArtifact\""));
assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
assert!(picker_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
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\""
));
}
#[tokio::test]
async fn tree_runtime_reduce_endpoint_returns_filetree_runtime_result() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"fileTree","requestId":"req-filetree-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"selectRow","rowId":"asset:image","modifiers":{"shiftKey":false,"ctrlKey":false,"metaKey":false}}}"#,
))
.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["mode"], Value::String("fileTree".into()));
assert_eq!(
payload["requestId"],
Value::String("req-filetree-route".into())
);
assert_eq!(
payload["domPatches"][0]["kind"],
Value::String("fileTreeState".into())
);
assert_eq!(
payload["domPatches"][0]["selectedRowIds"][0],
Value::String("asset:image".into())
);
let drop_target_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"fileTree","requestId":"req-filetree-drop-target-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"updateDropTarget","rowId":"asset:image"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(drop_target_response.status(), StatusCode::OK);
let drop_target_body = axum::body::to_bytes(drop_target_response.into_body(), usize::MAX)
.await
.expect("body");
let drop_target_payload: Value = serde_json::from_slice(&drop_target_body).expect("json");
assert_eq!(
drop_target_payload["domPatches"][0]["dropTargetRowId"],
Value::String("asset:image".into())
);
}
#[tokio::test]
async fn tree_runtime_reduce_endpoint_returns_page_and_picker_runtime_results() {
let page_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"page","requestId":"req-page-route","environment":{"visibleNodeIds":["doc:root","doc:child"],"expandableNodeIds":["doc:root"]},"state":{"focusedId":"doc:root","expandedIds":[],"dropFeedback":null},"action":{"kind":"moveNext"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(page_response.status(), StatusCode::OK);
let page_body = axum::body::to_bytes(page_response.into_body(), usize::MAX)
.await
.expect("body");
let page_payload: Value = serde_json::from_slice(&page_body).expect("json");
assert_eq!(page_payload["mode"], Value::String("page".into()));
assert_eq!(
page_payload["requestId"],
Value::String("req-page-route".into())
);
assert_eq!(
page_payload["domPatches"][0]["kind"],
Value::String("pageState".into())
);
assert_eq!(
page_payload["domPatches"][0]["focusedId"],
Value::String("doc:child".into())
);
let picker_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"picker","requestId":"req-picker-route","environment":{"items":[{"itemKey":"doc:root","documentId":"doc:root","pickable":true},{"itemKey":"doc:child","documentId":"doc:child","pickable":true}],"excludedIds":[],"allowRootPick":false},"state":{"activeItemKey":"doc:child"},"action":{"kind":"pick"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(picker_response.status(), StatusCode::OK);
let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX)
.await
.expect("body");
let picker_payload: Value = serde_json::from_slice(&picker_body).expect("json");
assert_eq!(picker_payload["mode"], Value::String("picker".into()));
assert_eq!(
picker_payload["requestId"],
Value::String("req-picker-route".into())
);
assert_eq!(
picker_payload["hostEvents"][0]["kind"],
Value::String("pickerPickDocument".into())
);
assert_eq!(
picker_payload["hostEvents"][0]["documentId"],
Value::String("doc:child".into())
);
}
#[tokio::test]
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
use crate::routes::stream_support::{StreamSnapshotQuery, load_stream_snapshot};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::response::Response;
use futures_util::StreamExt;
use serde_json::{json, Value};
use serde_json::{Value, json};
pub async fn socket(
ws: WebSocketUpgrade,