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,
@@ -1,4 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellDragEffect {
Copy,
Move,
@@ -0,0 +1,556 @@
use super::drag_drop_state::{resolve_drag_effect, TreeShellDragEffect};
use super::filetree_selection::{FileTreeSelectionModifiers, FileTreeSelectionState};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeEnvironment {
pub visible_row_ids: Vec<String>,
pub rows: Vec<FileTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeRow {
pub row_id: String,
pub row_kind: String,
pub document_id: Option<String>,
pub asset_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
pub selection: FileTreeSelectionState,
pub drag_row_ids: Vec<String>,
pub drag_effect: Option<TreeShellDragEffect>,
pub drop_target_row_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRuntimeTransition {
pub state: FileTreeRuntimeState,
pub outputs: BTreeSet<FileTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeRuntimeAction {
SelectRow {
row_id: String,
modifiers: FileTreeSelectionModifiers,
},
SelectContextRow {
row_id: String,
},
NormalizeVisibleRows,
ClearSelection,
ResolveDragRows {
row_id: String,
has_external_files: bool,
alt_key: bool,
},
UpdateDropTarget {
row_id: Option<String>,
},
DispatchInternalDrop {
target_row_id: Option<String>,
row_ids: Vec<String>,
copy: bool,
},
DispatchExternalDrop {
target_row_id: Option<String>,
file_count: u32,
},
OpenRow {
row_id: String,
},
ContextMenuRow {
row_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeRuntimeOutput {
DomPatch,
Intent(FileTreeIntentEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeIntentEvent {
Open {
target: FileTreeOpenTarget,
},
ContextMenu {
row_id: String,
target: FileTreeOpenTarget,
},
InternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
ExternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
file_count: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeOpenTarget {
Document {
document_id: String,
},
Index {
document_id: String,
},
AssetFolder {
document_id: String,
asset_id: String,
},
Asset {
document_id: String,
asset_id: String,
},
}
impl FileTreeRuntimeState {
pub fn reduce(
&self,
env: &FileTreeRuntimeEnvironment,
action: FileTreeRuntimeAction,
) -> FileTreeRuntimeTransition {
match action {
FileTreeRuntimeAction::SelectRow { row_id, modifiers } => {
let mut state = self.clone();
state.selection =
state
.selection
.select_row(&row_id, &env.visible_row_ids, modifiers);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::SelectContextRow { row_id } => {
let mut state = self.clone();
state.selection = state.selection.select_context_row(&row_id);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::NormalizeVisibleRows => {
let mut state = self.clone();
state.selection = state
.selection
.normalize_for_visible_rows(&env.visible_row_ids);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ClearSelection => {
let mut state = self.clone();
state.selection = state.selection.clear();
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ResolveDragRows {
row_id,
has_external_files,
alt_key,
} => {
let mut state = self.clone();
state.drag_row_ids = state
.selection
.resolve_drag_row_ids_for_visible_rows(&row_id, &env.visible_row_ids);
state.drag_effect = Some(resolve_drag_effect(has_external_files, alt_key));
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::UpdateDropTarget { row_id } => {
let mut state = self.clone();
state.drop_target_row_id = row_id;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id,
row_ids,
copy,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
let row_ids = row_ids
.into_iter()
.filter(|row_id| !row_id.is_empty())
.collect::<Vec<_>>();
if row_ids.is_empty() {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target: Some(target),
row_ids,
copy,
}),
],
)
}
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id,
file_count,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
if file_count == 0 {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target: Some(target),
file_count,
}),
],
)
}
FileTreeRuntimeAction::OpenRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open {
target,
})],
)
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu { row_id, target },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
}
fn resolve_open_target(
env: &FileTreeRuntimeEnvironment,
row_id: &str,
) -> Option<FileTreeOpenTarget> {
let row_by_id = env
.rows
.iter()
.map(|row| (row.row_id.as_str(), row))
.collect::<BTreeMap<_, _>>();
let row = row_by_id.get(row_id)?;
match row.row_kind.as_str() {
"doc" | "document" => Some(FileTreeOpenTarget::Document {
document_id: row.document_id.clone()?,
}),
"index" => Some(FileTreeOpenTarget::Index {
document_id: row.document_id.clone()?,
}),
"asset-folder" | "asset_folder" => Some(FileTreeOpenTarget::AssetFolder {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
"asset" => Some(FileTreeOpenTarget::Asset {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
_ => None,
}
}
fn transition<const N: usize>(
state: FileTreeRuntimeState,
outputs: [FileTreeRuntimeOutput; N],
) -> FileTreeRuntimeTransition {
FileTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
FileTreeIntentEvent, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment,
FileTreeRuntimeOutput, FileTreeRuntimeRow, FileTreeRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn env() -> FileTreeRuntimeEnvironment {
FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "index:root".into(),
row_kind: "index".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset-folder:mind".into(),
row_kind: "asset-folder".into(),
document_id: Some("root".into()),
asset_id: Some("mind".into()),
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
}
}
#[test]
fn filetree_runtime_reducer_covers_selection_drag_drop_and_open_menu_intents() {
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "doc:root".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("doc:root"));
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers {
shift_key: true,
..FileTreeSelectionModifiers::default()
},
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("index:root"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset-folder:mind"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset:image"));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: true,
},
);
assert_eq!(
transition.state.drag_row_ids,
ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"])
);
assert_eq!(
transition.state.drag_effect,
Some(TreeShellDragEffect::Copy)
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset-folder:mind".into()),
},
);
assert_eq!(
transition.state.drop_target_row_id.as_deref(),
Some("asset-folder:mind")
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "doc:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Document {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "index:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "asset-folder:mind".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ContextMenuRow {
row_id: "asset:image".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu {
row_id: "asset:image".into(),
target: FileTreeOpenTarget::Asset {
document_id: "root".into(),
asset_id: "image".into(),
},
},
)));
}
#[test]
fn filetree_runtime_dispatches_drop_intents_and_clears_drag_state() {
let state = FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("asset-folder:mind".into()),
..FileTreeRuntimeState::default()
};
let transition = state.reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("asset-folder:mind".into()),
row_ids: ids(&["asset:image"]),
copy: true,
},
);
assert!(transition.state.drag_row_ids.is_empty());
assert_eq!(transition.state.drag_effect, None);
assert_eq!(transition.state.drop_target_row_id, None);
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::InternalDrop {
target_row_id: Some("asset-folder:mind".into()),
target: Some(FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
}),
row_ids: ids(&["asset:image"]),
copy: true,
},
)));
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
},
)));
let rejected = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("missing:target".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
);
assert!(rejected
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(!rejected.outputs.iter().any(|output| matches!(
output,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
)));
}
}
@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1";
@@ -25,7 +25,7 @@ impl Default for FileTreeSelectionReducerContract {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionModifiers {
pub shift_key: bool,
@@ -33,7 +33,7 @@ pub struct FileTreeSelectionModifiers {
pub meta_key: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionState {
pub selected_row_ids: BTreeSet<String>,
@@ -148,6 +148,26 @@ impl FileTreeSelectionState {
}
vec![row_id.to_string()]
}
pub fn resolve_drag_row_ids_for_visible_rows(
&self,
row_id: &str,
visible_row_ids: &[String],
) -> Vec<String> {
if !self.selected_row_ids.contains(row_id) {
return vec![row_id.to_string()];
}
let ordered = visible_row_ids
.iter()
.filter(|visible_row_id| self.selected_row_ids.contains(*visible_row_id))
.cloned()
.collect::<Vec<_>>();
if ordered.is_empty() {
self.selected_row_ids.iter().cloned().collect()
} else {
ordered
}
}
}
fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> {
@@ -177,12 +197,11 @@ mod tests {
let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]);
let mut state = FileTreeSelectionState::default();
state = state.select_row(
"doc:b",
&visible,
FileTreeSelectionModifiers::default(),
state = state.select_row("doc:b", &visible, FileTreeSelectionModifiers::default());
assert_eq!(
state.selected_row_ids,
ids(&["doc:b"]).into_iter().collect()
);
assert_eq!(state.selected_row_ids, ids(&["doc:b"]).into_iter().collect());
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:b"));
@@ -211,7 +230,9 @@ mod tests {
);
assert_eq!(
state.selected_row_ids,
ids(&["doc:a", "doc:b", "asset:c", "asset:d"]).into_iter().collect()
ids(&["doc:a", "doc:b", "asset:c", "asset:d"])
.into_iter()
.collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:a"));
@@ -224,12 +245,18 @@ mod tests {
state.focused_row_id = Some("asset:missing".into());
state = state.normalize_for_visible_rows(&visible);
assert_eq!(state.selected_row_ids, ids(&["doc:a"]).into_iter().collect());
assert_eq!(
state.selected_row_ids,
ids(&["doc:a"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id, None);
state = state.select_context_row("asset:c");
assert_eq!(state.selected_row_ids, ids(&["asset:c"]).into_iter().collect());
assert_eq!(
state.selected_row_ids,
ids(&["asset:c"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c"));
assert_eq!(state.focused_row_id.as_deref(), Some("asset:c"));
@@ -1,8 +1,7 @@
use serde::Serialize;
use std::collections::BTreeSet;
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str =
"rust_page_focus_keyboard_reducer_v1";
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = "rust_page_focus_keyboard_reducer_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -24,6 +23,7 @@ impl Default for PageFocusKeyboardReducerContract {
"move_end",
"expand",
"collapse",
"toggle",
"open",
"context_menu",
]),
@@ -77,8 +77,8 @@ impl TreeShellFocusState {
.as_deref()
.and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id))
.unwrap_or(0);
let next_index = (current_index as isize + offset)
.clamp(0, (visible_ids.len() - 1) as isize) as usize;
let next_index =
(current_index as isize + offset).clamp(0, (visible_ids.len() - 1) as isize) as usize;
Self {
focused_id: Some(visible_ids[next_index].clone()),
}
@@ -105,14 +105,23 @@ mod tests {
.normalize(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:a"));
let state = state.move_next(&visible_ids).move_next(&visible_ids).move_next(&visible_ids);
let state = state
.move_next(&visible_ids)
.move_next(&visible_ids)
.move_next(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:c"));
assert_eq!(
state.move_previous(&visible_ids).focused_id.as_deref(),
Some("doc:b")
);
assert_eq!(state.move_home(&visible_ids).focused_id.as_deref(), Some("doc:a"));
assert_eq!(state.move_end(&visible_ids).focused_id.as_deref(), Some("doc:c"));
assert_eq!(
state.move_home(&visible_ids).focused_id.as_deref(),
Some("doc:a")
);
assert_eq!(
state.move_end(&visible_ids).focused_id.as_deref(),
Some("doc:c")
);
}
#[test]
@@ -139,6 +148,7 @@ mod tests {
assert!(contract.actions.contains("move_end"));
assert!(contract.actions.contains("expand"));
assert!(contract.actions.contains("collapse"));
assert!(contract.actions.contains("toggle"));
assert!(contract.actions.contains("open"));
assert!(contract.actions.contains("context_menu"));
}
+2 -1
View File
@@ -1,6 +1,6 @@
pub mod action_registry;
pub mod drag_drop_state;
pub mod dispatcher;
pub mod drag_drop_state;
pub mod expansion_state;
pub mod filetree_renderer;
pub mod filetree_runtime;
@@ -15,6 +15,7 @@ pub mod picker_runtime;
pub mod picker_state;
pub mod protocol;
pub mod renderer_input;
pub mod runtime_api;
pub mod state;
use leptos::prelude::*;
@@ -115,9 +115,8 @@ fn render_page_row(
}
pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String {
let mut html = String::from(
r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#,
);
let mut html =
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
@@ -0,0 +1,589 @@
use super::focus_state::TreeShellFocusState;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeEnvironment {
pub visible_node_ids: Vec<String>,
pub expandable_node_ids: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rows: Vec<PageTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub position: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeState {
pub focused_id: Option<String>,
pub expanded_ids: BTreeSet<String>,
pub drop_feedback: Option<PageTreeDropFeedback>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRuntimeTransition {
pub state: PageTreeRuntimeState,
pub outputs: BTreeSet<PageTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PageTreeRuntimeAction {
Normalize,
Focus {
node_id: String,
},
MoveNext,
MovePrevious,
MoveHome,
MoveEnd,
Expand {
node_id: String,
},
Collapse {
node_id: String,
},
Toggle {
node_id: String,
},
OpenFocused,
ContextMenuFocused,
DispatchCreate {
parent_node_id: Option<String>,
},
DispatchRename {
node_id: String,
title: String,
},
UpdateDropFeedback {
feedback: Option<PageTreeDropFeedback>,
},
UpdateDropFeedbackForTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
DispatchMove {
source_node_id: String,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
},
DispatchMoveToTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeRuntimeOutput {
DomPatch,
Intent(PageTreeIntentEvent),
CommandDispatch(PageTreeCommandDispatchEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeIntentEvent {
Open { node_id: String },
ContextMenu { node_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeCommandDispatchEvent {
CreateNode {
command_name: &'static str,
parent_node_id: Option<String>,
},
RenameNode {
command_name: &'static str,
node_id: String,
title: String,
},
MoveSubtree {
command_name: &'static str,
source_node_id: String,
target_node_id: Option<String>,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
sort_order: Option<i64>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeDropFeedback {
pub source_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_node_id: Option<String>,
pub target_parent_id: Option<String>,
pub position: PageTreeDropPosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PageTreeDropPosition {
Before,
Inside,
After,
}
impl PageTreeRuntimeState {
pub fn reduce(
&self,
env: &PageTreeRuntimeEnvironment,
action: PageTreeRuntimeAction,
) -> PageTreeRuntimeTransition {
match action {
PageTreeRuntimeAction::Normalize => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.normalize(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::Focus { node_id } => {
let focused_id = env
.visible_node_ids
.iter()
.any(|visible_id| visible_id == &node_id)
.then_some(node_id);
self.with_focus(focused_id)
}
PageTreeRuntimeAction::MoveNext => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_next(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MovePrevious => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_previous(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MoveHome => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_home(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MoveEnd => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_end(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::Expand { node_id } => self.with_expansion(env, node_id, true),
PageTreeRuntimeAction::Collapse { node_id } => self.with_expansion(env, node_id, false),
PageTreeRuntimeAction::Toggle { node_id } => {
let expanded = !self.expanded_ids.contains(&node_id);
self.with_expansion(env, node_id, expanded)
}
PageTreeRuntimeAction::OpenFocused => {
self.with_focused_intent(PageTreeIntentEvent::Open {
node_id: self.focused_id.clone().unwrap_or_default(),
})
}
PageTreeRuntimeAction::ContextMenuFocused => {
self.with_focused_intent(PageTreeIntentEvent::ContextMenu {
node_id: self.focused_id.clone().unwrap_or_default(),
})
}
PageTreeRuntimeAction::DispatchCreate { parent_node_id } => transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name: "tree.node.create",
parent_node_id,
},
)],
),
PageTreeRuntimeAction::DispatchRename { node_id, title } => {
let title = title.trim().to_string();
if node_id.trim().is_empty() || title.is_empty() {
return transition(self.clone(), []);
}
transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name: "tree.node.rename",
node_id,
title,
},
)],
)
}
PageTreeRuntimeAction::UpdateDropFeedback { feedback } => {
let mut state = self.clone();
state.drop_feedback = feedback;
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
source_node_id,
target_node_id,
position,
} => {
let mut state = self.clone();
state.drop_feedback =
resolve_drop_feedback(env, &source_node_id, &target_node_id, position);
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
PageTreeRuntimeAction::DispatchMove {
source_node_id,
target_parent_id,
position,
} => transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id,
target_node_id: None,
target_parent_id,
position,
sort_order: None,
},
)],
),
PageTreeRuntimeAction::DispatchMoveToTarget {
source_node_id,
target_node_id,
position,
} => {
let Some(resolved_drop) =
resolve_drop_target(env, &source_node_id, &target_node_id, position)
else {
return transition(self.clone(), []);
};
transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id,
target_node_id: Some(target_node_id),
target_parent_id: resolved_drop.target_parent_id,
position,
sort_order: Some(resolved_drop.sort_order),
},
)],
)
}
}
}
fn with_focus(&self, focused_id: Option<String>) -> PageTreeRuntimeTransition {
let mut state = self.clone();
state.focused_id = focused_id;
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
fn with_expansion(
&self,
env: &PageTreeRuntimeEnvironment,
node_id: String,
expanded: bool,
) -> PageTreeRuntimeTransition {
let mut state = self.clone();
if env.expandable_node_ids.contains(&node_id) {
if expanded {
state.expanded_ids.insert(node_id);
} else {
state.expanded_ids.remove(&node_id);
}
}
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
fn with_focused_intent(&self, intent: PageTreeIntentEvent) -> PageTreeRuntimeTransition {
let has_focus = !match &intent {
PageTreeIntentEvent::Open { node_id } => node_id,
PageTreeIntentEvent::ContextMenu { node_id } => node_id,
}
.is_empty();
if has_focus {
transition(self.clone(), [PageTreeRuntimeOutput::Intent(intent)])
} else {
transition(self.clone(), [])
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedPageDropTarget {
target_parent_id: Option<String>,
sort_order: i64,
}
fn resolve_drop_feedback(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<PageTreeDropFeedback> {
let resolved = resolve_drop_target(env, source_node_id, target_node_id, position)?;
Some(PageTreeDropFeedback {
source_node_id: source_node_id.to_string(),
target_node_id: Some(target_node_id.to_string()),
target_parent_id: resolved.target_parent_id,
position,
})
}
fn resolve_drop_target(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<ResolvedPageDropTarget> {
let source_node_id = source_node_id.trim();
let target_node_id = target_node_id.trim();
if source_node_id.is_empty() || target_node_id.is_empty() || source_node_id == target_node_id {
return None;
}
let source_row = env.rows.iter().find(|row| row.node_id == source_node_id)?;
let target_row = env.rows.iter().find(|row| row.node_id == target_node_id)?;
if source_row.parent_node_id != target_row.parent_node_id {
return None;
}
let mut siblings = env
.rows
.iter()
.filter(|row| row.parent_node_id == target_row.parent_node_id)
.collect::<Vec<_>>();
siblings.sort_by(|left, right| {
left.position
.cmp(&right.position)
.then_with(|| left.node_id.cmp(&right.node_id))
});
let target_index = siblings
.iter()
.position(|row| row.node_id == target_row.node_id)? as i64;
let sort_order = match position {
PageTreeDropPosition::Before | PageTreeDropPosition::Inside => target_index,
PageTreeDropPosition::After => target_index + 1,
};
Some(ResolvedPageDropTarget {
target_parent_id: target_row.parent_node_id.clone(),
sort_order,
})
}
fn transition<const N: usize>(
state: PageTreeRuntimeState,
outputs: [PageTreeRuntimeOutput; N],
) -> PageTreeRuntimeTransition {
PageTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn page_tree_runtime_reducer_normalizes_focus_expansion_intents_and_drag_move() {
let env = PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:root", "doc:child", "doc:sibling"]),
expandable_node_ids: ids(&["doc:root"]).into_iter().collect(),
rows: vec![
PageTreeRuntimeRow {
node_id: "doc:root".into(),
parent_node_id: None,
position: 0,
},
PageTreeRuntimeRow {
node_id: "doc:child".into(),
parent_node_id: Some("doc:root".into()),
position: 0,
},
PageTreeRuntimeRow {
node_id: "doc:sibling".into(),
parent_node_id: Some("doc:root".into()),
position: 1,
},
],
};
let state = PageTreeRuntimeState {
focused_id: Some("doc:missing".into()),
..PageTreeRuntimeState::default()
};
let transition = state.reduce(&env, PageTreeRuntimeAction::Normalize);
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:root"));
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::DomPatch));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::MoveNext);
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:child"));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::Expand {
node_id: "doc:root".into(),
},
);
assert!(transition.state.expanded_ids.contains("doc:root"));
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::DomPatch));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::OpenFocused);
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
PageTreeIntentEvent::Open {
node_id: "doc:child".into(),
},
)));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::ContextMenuFocused);
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
PageTreeIntentEvent::ContextMenu {
node_id: "doc:child".into(),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::UpdateDropFeedback {
feedback: Some(PageTreeDropFeedback {
source_node_id: "doc:child".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::Inside,
}),
},
);
assert_eq!(
transition
.state
.drop_feedback
.as_ref()
.map(|feedback| feedback.position),
Some(PageTreeDropPosition::Inside)
);
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
source_node_id: "doc:child".into(),
target_node_id: "doc:sibling".into(),
position: PageTreeDropPosition::Before,
},
);
assert_eq!(
transition
.state
.drop_feedback
.as_ref()
.and_then(|feedback| feedback.target_node_id.as_deref()),
Some("doc:sibling")
);
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchMove {
source_node_id: "doc:child".into(),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::After,
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id: "doc:child".into(),
target_node_id: None,
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::After,
sort_order: None,
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchMoveToTarget {
source_node_id: "doc:child".into(),
target_node_id: "doc:sibling".into(),
position: PageTreeDropPosition::Before,
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id: "doc:child".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::Before,
sort_order: Some(1),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchCreate {
parent_node_id: Some("doc:root".into()),
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name: "tree.node.create",
parent_node_id: Some("doc:root".into()),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchRename {
node_id: "doc:child".into(),
title: "新标题".into(),
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name: "tree.node.rename",
node_id: "doc:child".into(),
title: "新标题".into(),
},
)));
}
}
@@ -174,23 +174,26 @@ mod tests {
let html = render_initial_picker_html(&PickerInitialRenderInput {
allow_root_pick: true,
root_active: false,
rows: vec![PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: false,
expanded: false,
active: true,
}, PickerRenderRow {
node_id: "page_other".into(),
parent_node_id: None,
title: "其他页面".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
}],
rows: vec![
PickerRenderRow {
node_id: "page_root".into(),
parent_node_id: None,
title: "首页 <安全>".into(),
depth: 0,
expandable: false,
expanded: false,
active: true,
},
PickerRenderRow {
node_id: "page_other".into(),
parent_node_id: None,
title: "其他页面".into(),
depth: 0,
expandable: false,
expanded: false,
active: false,
},
],
});
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
@@ -0,0 +1,277 @@
use super::picker_state::{PickerItem, PickerState};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeEnvironment {
pub items: Vec<PickerRuntimeItem>,
pub excluded_ids: BTreeSet<String>,
pub allow_root_pick: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeItem {
pub item_key: String,
pub document_id: Option<String>,
pub pickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeState {
pub active_item_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRuntimeTransition {
pub state: PickerRuntimeState,
pub outputs: BTreeSet<PickerRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PickerRuntimeAction {
Normalize,
Focus { item_key: String, focus_dom: bool },
Next,
Previous,
Home,
End,
Pick,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PickerRuntimeOutput {
DomPatch { focus_dom: bool },
Pick(PickerRuntimePickTarget),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PickerRuntimePickTarget {
Root,
Document { document_id: String },
}
impl PickerRuntimeState {
pub fn reduce(
&self,
env: &PickerRuntimeEnvironment,
action: PickerRuntimeAction,
) -> PickerRuntimeTransition {
match action {
PickerRuntimeAction::Normalize => self.with_picker_state(
env,
to_picker_state(self).normalize(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Focus {
item_key,
focus_dom,
} => {
if is_pickable_item_key(env, &item_key) {
let mut state = self.clone();
state.active_item_key = Some(item_key);
transition(state, [PickerRuntimeOutput::DomPatch { focus_dom }])
} else {
transition(self.clone(), [])
}
}
PickerRuntimeAction::Next => self.with_picker_state(
env,
to_picker_state(self).move_next(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Previous => self.with_picker_state(
env,
to_picker_state(self).move_previous(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Home => self.with_picker_state(
env,
to_picker_state(self).move_home(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::End => self.with_picker_state(
env,
to_picker_state(self).move_end(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Pick => {
if self.active_item_key.as_deref() == Some("__root__") && env.allow_root_pick {
return transition(
self.clone(),
[PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root)],
);
}
let items = picker_items(env);
if let Some(document_id) = to_picker_state(self).pick(&items, &env.excluded_ids) {
transition(
self.clone(),
[PickerRuntimeOutput::Pick(
PickerRuntimePickTarget::Document { document_id },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
fn with_picker_state(
&self,
_env: &PickerRuntimeEnvironment,
picker_state: PickerState,
focus_dom: bool,
) -> PickerRuntimeTransition {
transition(
PickerRuntimeState {
active_item_key: picker_state.active_item_key,
},
[PickerRuntimeOutput::DomPatch { focus_dom }],
)
}
}
fn picker_items(env: &PickerRuntimeEnvironment) -> Vec<PickerItem> {
env.items
.iter()
.filter(|item| item.item_key != "__root__" || env.allow_root_pick)
.map(|item| PickerItem {
item_key: item.item_key.clone(),
document_id: item.document_id.clone(),
pickable: item.pickable,
})
.collect()
}
fn to_picker_state(state: &PickerRuntimeState) -> PickerState {
PickerState {
active_item_key: state.active_item_key.clone(),
}
}
fn is_pickable_item_key(env: &PickerRuntimeEnvironment, item_key: &str) -> bool {
picker_items(env).iter().any(|item| {
item.pickable
&& item.item_key == item_key
&& item
.document_id
.as_ref()
.is_none_or(|document_id| !env.excluded_ids.contains(document_id))
})
}
fn transition<const N: usize>(
state: PickerRuntimeState,
outputs: [PickerRuntimeOutput; N],
) -> PickerRuntimeTransition {
PickerRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeOutput,
PickerRuntimePickTarget, PickerRuntimeState,
};
use std::collections::BTreeSet;
fn excluded(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn item(item_key: &str, document_id: Option<&str>, pickable: bool) -> PickerRuntimeItem {
PickerRuntimeItem {
item_key: item_key.into(),
document_id: document_id.map(ToOwned::to_owned),
pickable,
}
}
fn env() -> PickerRuntimeEnvironment {
PickerRuntimeEnvironment {
items: vec![
item("__root__", None, true),
item("doc:a", Some("doc:a"), true),
item("doc:hidden", Some("doc:hidden"), true),
item("doc:c", Some("doc:c"), true),
],
excluded_ids: excluded(&["doc:hidden"]),
allow_root_pick: true,
}
}
#[test]
fn picker_runtime_reducer_covers_navigation_pick_and_search_focus_boundary() {
let state = PickerRuntimeState {
active_item_key: Some("doc:hidden".into()),
};
let transition = state.reduce(&env(), PickerRuntimeAction::Normalize);
assert_eq!(
transition.state.active_item_key.as_deref(),
Some("__root__")
);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: false }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Next);
assert_eq!(transition.state.active_item_key.as_deref(), Some("doc:a"));
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: false }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::End);
assert_eq!(transition.state.active_item_key.as_deref(), Some("doc:c"));
let transition = transition.state.reduce(
&env(),
PickerRuntimeAction::Focus {
item_key: "__root__".into(),
focus_dom: true,
},
);
assert_eq!(
transition.state.active_item_key.as_deref(),
Some("__root__")
);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: true }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Pick);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root,)));
let transition = transition.state.reduce(
&env(),
PickerRuntimeAction::Focus {
item_key: "doc:c".into(),
focus_dom: true,
},
);
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Pick);
assert!(transition.outputs.contains(&PickerRuntimeOutput::Pick(
PickerRuntimePickTarget::Document {
document_id: "doc:c".into(),
},
)));
}
}
@@ -67,7 +67,9 @@ impl PickerState {
pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
Self {
active_item_key: pickable_items(items, excluded_ids).last().map(|item| item.item_key.clone()),
active_item_key: pickable_items(items, excluded_ids)
.last()
.map(|item| item.item_key.clone()),
}
}
@@ -78,7 +80,12 @@ impl PickerState {
.and_then(|item| item.document_id.clone())
}
fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>, offset: isize) -> Self {
fn move_by(
&self,
items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
offset: isize,
) -> Self {
let pickable = pickable_items(items, excluded_ids).collect::<Vec<_>>();
if pickable.is_empty() {
return Self::default();
@@ -88,8 +95,8 @@ impl PickerState {
.as_deref()
.and_then(|active| pickable.iter().position(|item| item.item_key == active))
.unwrap_or(0);
let next_index = (current_index as isize + offset)
.clamp(0, (pickable.len() - 1) as isize) as usize;
let next_index =
(current_index as isize + offset).clamp(0, (pickable.len() - 1) as isize) as usize;
Self {
active_item_key: Some(pickable[next_index].item_key.clone()),
}
@@ -111,8 +118,13 @@ fn pickable_items<'a>(
.filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids))
}
fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone())
fn first_pickable_item_key(
items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
) -> Option<String> {
pickable_items(items, excluded_ids)
.next()
.map(|item| item.item_key.clone())
}
fn is_pickable_item_key(
@@ -158,7 +170,9 @@ mod tests {
.normalize(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("root"));
let state = state.move_next(&items, &excluded_ids).move_next(&items, &excluded_ids);
let state = state
.move_next(&items, &excluded_ids)
.move_next(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c"));
@@ -177,7 +191,10 @@ mod tests {
let state = PickerState::default().move_end(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(
state.move_home(&items, &excluded_ids).active_item_key.as_deref(),
state
.move_home(&items, &excluded_ids)
.active_item_key
.as_deref(),
Some("doc:a")
);
@@ -40,15 +40,40 @@ pub struct TreeShellCommandDispatcher {
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeArtifactBoundary {
pub contract_name: &'static str,
pub family: &'static str,
pub version: u8,
pub execution_strategy: &'static str,
pub browser_bridge: &'static str,
pub wasm_module_url: Option<&'static str>,
pub js_glue_url: Option<&'static str>,
pub input_fields: BTreeSet<&'static str>,
pub output_channels: BTreeSet<&'static str>,
pub event_kinds: BTreeSet<&'static str>,
pub runtime_api: TreeShellRuntimeApiBoundary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeApiBoundary {
pub request_contract: &'static str,
pub result_contract: &'static str,
pub reduce_endpoint: &'static str,
pub state_snapshots: BTreeSet<&'static str>,
pub dom_patch_kinds: BTreeSet<&'static str>,
pub host_event_kinds: BTreeSet<&'static str>,
pub command_event_kinds: BTreeSet<&'static str>,
}
impl Default for TreeShellRuntimeArtifactBoundary {
fn default() -> Self {
Self {
contract_name: "rust_tree_shell_runtime_artifact_v1",
family: "rust_family",
version: 1,
execution_strategy: "browser_bridge",
browser_bridge: "iframe_srcdoc",
wasm_module_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"),
js_glue_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js"),
input_fields: BTreeSet::from([
"rendererInput",
"projectionItems",
@@ -67,6 +92,31 @@ impl Default for TreeShellRuntimeArtifactBoundary {
"dragDrop",
"pick",
]),
runtime_api: TreeShellRuntimeApiBoundary {
request_contract: "TreeShellRuntimeRequest",
result_contract: "TreeShellRuntimeResult",
reduce_endpoint: "/api/tree/runtime/reduce",
state_snapshots: BTreeSet::from(["page", "fileTree", "picker"]),
dom_patch_kinds: BTreeSet::from(["pageState", "fileTreeState", "pickerState"]),
host_event_kinds: BTreeSet::from([
"pageOpen",
"pageContextMenu",
"fileTreeOpen",
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"pickerPickRoot",
"pickerPickDocument",
]),
command_event_kinds: BTreeSet::from([
"createNode",
"renameNode",
"moveSubtree",
"copyResource",
"moveResource",
"uploadResource",
]),
},
}
}
}
@@ -197,10 +247,12 @@ mod tests {
});
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!(filetree
.filetree_selection
.selected_row_ids
.contains("asset:a"));
assert!(
filetree
.filetree_selection
.selected_row_ids
.contains("asset:a")
);
assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!(
filetree
@@ -245,6 +297,18 @@ mod tests {
artifact.contract_name,
"rust_tree_shell_runtime_artifact_v1"
);
assert_eq!(artifact.family, "rust_family");
assert_eq!(artifact.version, 1);
assert_eq!(artifact.execution_strategy, "browser_bridge");
assert_eq!(artifact.browser_bridge, "iframe_srcdoc");
assert_eq!(
artifact.wasm_module_url,
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm")
);
assert_eq!(
artifact.js_glue_url,
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js")
);
assert!(artifact.input_fields.contains("rendererInput"));
assert!(artifact.input_fields.contains("projectionItems"));
assert!(artifact.input_fields.contains("expandedIds"));
@@ -254,6 +318,90 @@ mod tests {
assert!(artifact.output_channels.contains("domPatch"));
assert!(artifact.output_channels.contains("intentEvent"));
assert!(artifact.output_channels.contains("commandDispatchEvent"));
assert_eq!(
artifact.runtime_api.request_contract,
"TreeShellRuntimeRequest"
);
assert_eq!(
artifact.runtime_api.result_contract,
"TreeShellRuntimeResult"
);
assert_eq!(
artifact.runtime_api.reduce_endpoint,
"/api/tree/runtime/reduce"
);
assert!(artifact.runtime_api.state_snapshots.contains("page"));
assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
assert!(artifact.runtime_api.state_snapshots.contains("picker"));
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
assert!(
artifact
.runtime_api
.dom_patch_kinds
.contains("fileTreeState")
);
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeOpen")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeInternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeExternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("pickerPickDocument")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("createNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("renameNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveSubtree")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("copyResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("uploadResource")
);
assert!(artifact.event_kinds.contains("focus"));
assert!(artifact.event_kinds.contains("keyboard"));
assert!(artifact.event_kinds.contains("expandCollapse"));
@@ -0,0 +1,880 @@
use super::drag_drop_state::TreeShellDragEffect;
use super::filetree_runtime::{
FileTreeIntentEvent, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeOutput,
FileTreeRuntimeState,
};
use super::page_runtime::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
use super::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget,
PickerRuntimeState,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellRuntimeMode {
Page,
FileTree,
Picker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "mode",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellRuntimeRequest {
Page {
request_id: String,
environment: PageTreeRuntimeEnvironment,
state: PageTreeRuntimeState,
action: PageTreeRuntimeAction,
},
FileTree {
request_id: String,
environment: FileTreeRuntimeEnvironment,
state: FileTreeRuntimeState,
action: FileTreeRuntimeAction,
},
Picker {
request_id: String,
environment: PickerRuntimeEnvironment,
state: PickerRuntimeState,
action: PickerRuntimeAction,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeResult {
pub request_id: String,
pub mode: TreeShellRuntimeMode,
pub state: TreeShellRuntimeStateSnapshot,
pub dom_patches: Vec<TreeShellDomPatch>,
pub host_events: Vec<TreeShellHostEvent>,
pub command_events: Vec<TreeShellCommandEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", content = "state", rename_all = "camelCase")]
pub enum TreeShellRuntimeStateSnapshot {
Page(PageTreeRuntimeState),
FileTree(FileTreeRuntimeState),
Picker(PickerRuntimeState),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellDomPatch {
PageState {
focused_id: Option<String>,
expanded_ids: BTreeSet<String>,
drop_feedback: Option<PageTreeDropFeedback>,
},
FileTreeState {
selected_row_ids: BTreeSet<String>,
anchor_row_id: Option<String>,
focused_row_id: Option<String>,
drag_row_ids: Vec<String>,
drag_effect: Option<TreeShellDragEffect>,
drop_target_row_id: Option<String>,
},
PickerState {
active_item_key: Option<String>,
focus_dom: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellHostEvent {
PageOpen {
node_id: String,
},
PageContextMenu {
node_id: String,
},
FileTreeOpen {
target: super::filetree_runtime::FileTreeOpenTarget,
},
FileTreeContextMenu {
row_id: String,
target: super::filetree_runtime::FileTreeOpenTarget,
},
FileTreeInternalDrop {
target_row_id: Option<String>,
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
FileTreeExternalDrop {
target_row_id: Option<String>,
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
file_count: u32,
},
PickerPickRoot,
PickerPickDocument {
document_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellCommandEvent {
CreateNode {
command_name: String,
parent_node_id: Option<String>,
},
RenameNode {
command_name: String,
node_id: String,
title: String,
},
MoveSubtree {
command_name: String,
source_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_parent_id: Option<String>,
position: PageTreeDropPosition,
#[serde(default, skip_serializing_if = "Option::is_none")]
sort_order: Option<i64>,
},
CopyResource {
command_name: String,
source_asset_ids: Vec<String>,
target_document_id: String,
},
MoveResource {
command_name: String,
source_asset_ids: Vec<String>,
target_document_id: String,
},
UploadResource {
command_name: String,
target_document_id: String,
file_count: u32,
},
}
pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellRuntimeResult {
match request {
TreeShellRuntimeRequest::Page {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::PageState {
focused_id: transition.state.focused_id.clone(),
expanded_ids: transition.state.expanded_ids.clone(),
drop_feedback: transition.state.drop_feedback.clone(),
}),
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::Intent(PageTreeIntentEvent::Open { node_id }) => {
Some(TreeShellHostEvent::PageOpen {
node_id: node_id.clone(),
})
}
PageTreeRuntimeOutput::Intent(PageTreeIntentEvent::ContextMenu { node_id }) => {
Some(TreeShellHostEvent::PageContextMenu {
node_id: node_id.clone(),
})
}
_ => None,
})
.collect();
let command_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name,
parent_node_id,
},
) => Some(TreeShellCommandEvent::CreateNode {
command_name: command_name.to_string(),
parent_node_id: parent_node_id.clone(),
}),
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name,
node_id,
title,
},
) => Some(TreeShellCommandEvent::RenameNode {
command_name: command_name.to_string(),
node_id: node_id.clone(),
title: title.clone(),
}),
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name,
source_node_id,
target_node_id,
target_parent_id,
position,
sort_order,
},
) => Some(TreeShellCommandEvent::MoveSubtree {
command_name: command_name.to_string(),
source_node_id: source_node_id.clone(),
target_node_id: target_node_id.clone(),
target_parent_id: target_parent_id.clone(),
position: *position,
sort_order: *sort_order,
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::Page,
state: TreeShellRuntimeStateSnapshot::Page(transition.state),
dom_patches,
host_events,
command_events,
}
}
TreeShellRuntimeRequest::FileTree {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::FileTreeState {
selected_row_ids: transition.state.selection.selected_row_ids.clone(),
anchor_row_id: transition.state.selection.anchor_row_id.clone(),
focused_row_id: transition.state.selection.focused_row_id.clone(),
drag_row_ids: transition.state.drag_row_ids.clone(),
drag_effect: transition.state.drag_effect,
drop_target_row_id: transition.state.drop_target_row_id.clone(),
}),
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }) => {
Some(TreeShellHostEvent::FileTreeOpen {
target: target.clone(),
})
}
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ContextMenu {
row_id,
target,
}) => Some(TreeShellHostEvent::FileTreeContextMenu {
row_id: row_id.clone(),
target: target.clone(),
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target,
row_ids,
copy,
}) => Some(TreeShellHostEvent::FileTreeInternalDrop {
target_row_id: target_row_id.clone(),
target: target.clone(),
row_ids: row_ids.clone(),
copy: *copy,
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target,
file_count,
}) => Some(TreeShellHostEvent::FileTreeExternalDrop {
target_row_id: target_row_id.clone(),
target: target.clone(),
file_count: *file_count,
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::FileTree,
state: TreeShellRuntimeStateSnapshot::FileTree(transition.state),
dom_patches,
host_events,
command_events: Vec::new(),
}
}
TreeShellRuntimeRequest::Picker {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
PickerRuntimeOutput::DomPatch { focus_dom } => {
Some(TreeShellDomPatch::PickerState {
active_item_key: transition.state.active_item_key.clone(),
focus_dom: *focus_dom,
})
}
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root) => {
Some(TreeShellHostEvent::PickerPickRoot)
}
PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Document {
document_id,
}) => Some(TreeShellHostEvent::PickerPickDocument {
document_id: document_id.clone(),
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::Picker,
state: TreeShellRuntimeStateSnapshot::Picker(transition.state),
dom_patches,
host_events,
command_events: Vec::new(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent,
TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot,
};
use crate::tree_shell::filetree_runtime::{
FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow,
FileTreeRuntimeState,
};
use crate::tree_shell::filetree_selection::{
FileTreeSelectionModifiers, FileTreeSelectionState,
};
use crate::tree_shell::page_runtime::{
PageTreeDropPosition, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeState,
};
use crate::tree_shell::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use serde_json::json;
use std::collections::BTreeSet;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn set(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn runtime_request_result_are_wire_safe_and_camel_case() {
let request = TreeShellRuntimeRequest::Page {
request_id: "req-page".into(),
environment: PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:a", "doc:b"]),
expandable_node_ids: set(&["doc:a"]),
rows: Vec::new(),
},
state: PageTreeRuntimeState {
focused_id: Some("doc:a".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
},
action: PageTreeRuntimeAction::MoveNext,
};
let encoded = serde_json::to_value(&request).expect("runtime request should serialize");
assert_eq!(
encoded,
json!({
"mode": "page",
"requestId": "req-page",
"environment": {
"visibleNodeIds": ["doc:a", "doc:b"],
"expandableNodeIds": ["doc:a"]
},
"state": {
"focusedId": "doc:a",
"expandedIds": [],
"dropFeedback": null
},
"action": {
"kind": "moveNext"
}
})
);
let decoded: TreeShellRuntimeRequest =
serde_json::from_value(encoded).expect("runtime request should deserialize");
let result = reduce_tree_shell_runtime(decoded);
assert_eq!(result.request_id, "req-page");
assert_eq!(result.mode, TreeShellRuntimeMode::Page);
assert_eq!(
result.state,
TreeShellRuntimeStateSnapshot::Page(PageTreeRuntimeState {
focused_id: Some("doc:b".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
})
);
assert_eq!(
result.dom_patches,
vec![TreeShellDomPatch::PageState {
focused_id: Some("doc:b".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
}]
);
assert!(result.host_events.is_empty());
assert!(result.command_events.is_empty());
}
#[test]
fn runtime_api_maps_page_intents_and_commands_to_structured_events() {
let environment = PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:a", "doc:b"]),
expandable_node_ids: set(&["doc:a"]),
rows: Vec::new(),
};
let state = PageTreeRuntimeState {
focused_id: Some("doc:b".into()),
..PageTreeRuntimeState::default()
};
let open = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-open".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::OpenFocused,
});
assert_eq!(
open.host_events,
vec![TreeShellHostEvent::PageOpen {
node_id: "doc:b".into(),
}]
);
let create_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-create".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::DispatchCreate {
parent_node_id: Some("doc:a".into()),
},
});
assert_eq!(
create_result.command_events,
vec![TreeShellCommandEvent::CreateNode {
command_name: "tree.node.create".into(),
parent_node_id: Some("doc:a".into()),
}]
);
let rename_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-rename".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::DispatchRename {
node_id: "doc:b".into(),
title: "新标题".into(),
},
});
assert_eq!(
rename_result.command_events,
vec![TreeShellCommandEvent::RenameNode {
command_name: "tree.node.rename".into(),
node_id: "doc:b".into(),
title: "新标题".into(),
}]
);
let move_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-move".into(),
environment,
state,
action: PageTreeRuntimeAction::DispatchMove {
source_node_id: "doc:b".into(),
target_parent_id: Some("doc:a".into()),
position: PageTreeDropPosition::Inside,
},
});
assert_eq!(
move_result.command_events,
vec![TreeShellCommandEvent::MoveSubtree {
command_name: "tree.subtree.move".into(),
source_node_id: "doc:b".into(),
target_node_id: None,
target_parent_id: Some("doc:a".into()),
position: PageTreeDropPosition::Inside,
sort_order: None,
}]
);
}
#[test]
fn runtime_api_maps_filetree_and_picker_results_to_common_output_channels() {
let filetree = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
});
assert_eq!(filetree.mode, TreeShellRuntimeMode::FileTree);
assert_eq!(
filetree.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: None,
}]
);
let drag_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-drag".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState {
selection: FileTreeSelectionState::from_selected(&["asset:image".to_string()]),
..FileTreeRuntimeState::default()
},
action: FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: false,
},
});
assert_eq!(
drag_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: None,
}]
);
let drop_target_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-drop-target".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset:image".into()),
},
});
assert_eq!(
drop_target_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: Some("asset:image".into()),
}]
);
let internal_drop = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-internal-drop".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("doc:root".into()),
..FileTreeRuntimeState::default()
},
action: FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("doc:root".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
});
assert_eq!(
internal_drop.host_events,
vec![TreeShellHostEvent::FileTreeInternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
row_ids: ids(&["asset:image"]),
copy: false,
}]
);
assert_eq!(
internal_drop.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: None,
}]
);
let external_drop = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-external-drop".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root"]),
rows: vec![FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
}],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
});
assert_eq!(
external_drop.host_events,
vec![TreeShellHostEvent::FileTreeExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
}]
);
let picker = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Picker {
request_id: "req-picker".into(),
environment: PickerRuntimeEnvironment {
items: vec![PickerRuntimeItem {
item_key: "doc:target".into(),
document_id: Some("target".into()),
pickable: true,
}],
excluded_ids: BTreeSet::new(),
allow_root_pick: false,
},
state: PickerRuntimeState {
active_item_key: Some("doc:target".into()),
},
action: PickerRuntimeAction::Pick,
});
assert_eq!(picker.mode, TreeShellRuntimeMode::Picker);
assert_eq!(
picker.host_events,
vec![TreeShellHostEvent::PickerPickDocument {
document_id: "target".into(),
}]
);
}
#[test]
fn runtime_command_event_schema_covers_tree_and_resource_command_channels() {
let events = vec![
TreeShellCommandEvent::CreateNode {
command_name: "tree.node.create".into(),
parent_node_id: Some("doc:parent".into()),
},
TreeShellCommandEvent::RenameNode {
command_name: "tree.node.rename".into(),
node_id: "doc:target".into(),
title: "新标题".into(),
},
TreeShellCommandEvent::MoveSubtree {
command_name: "tree.subtree.move".into(),
source_node_id: "doc:target".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:parent".into()),
position: PageTreeDropPosition::After,
sort_order: Some(3),
},
TreeShellCommandEvent::CopyResource {
command_name: "tree.resource.copy".into(),
source_asset_ids: vec!["asset:a".into()],
target_document_id: "doc:target".into(),
},
TreeShellCommandEvent::MoveResource {
command_name: "tree.resource.move".into(),
source_asset_ids: vec!["asset:a".into()],
target_document_id: "doc:target".into(),
},
TreeShellCommandEvent::UploadResource {
command_name: "tree.resource.upload".into(),
target_document_id: "doc:target".into(),
file_count: 2,
},
];
let encoded = serde_json::to_value(&events).expect("command events should serialize");
assert_eq!(
encoded,
json!([
{
"kind": "createNode",
"commandName": "tree.node.create",
"parentNodeId": "doc:parent"
},
{
"kind": "renameNode",
"commandName": "tree.node.rename",
"nodeId": "doc:target",
"title": "新标题"
},
{
"kind": "moveSubtree",
"commandName": "tree.subtree.move",
"sourceNodeId": "doc:target",
"targetNodeId": "doc:sibling",
"targetParentId": "doc:parent",
"position": "after",
"sortOrder": 3
},
{
"kind": "copyResource",
"commandName": "tree.resource.copy",
"sourceAssetIds": ["asset:a"],
"targetDocumentId": "doc:target"
},
{
"kind": "moveResource",
"commandName": "tree.resource.move",
"sourceAssetIds": ["asset:a"],
"targetDocumentId": "doc:target"
},
{
"kind": "uploadResource",
"commandName": "tree.resource.upload",
"targetDocumentId": "doc:target",
"fileCount": 2
}
])
);
}
}