feat(tree): close rust family shell cutover
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user