feat(tree): close rust family shell cutover
This commit is contained in:
@@ -294,6 +294,8 @@ pub struct RuntimeCommandExecutionPlan {
|
||||
pub struct RuntimeCommandArtifactPlan {
|
||||
pub command_log: RuntimeCommandLogArtifactPlan,
|
||||
pub domain_event: Option<RuntimeDomainEventArtifactPlan>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub domain_events: Vec<RuntimeDomainEventArtifactPlan>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
@@ -627,7 +629,8 @@ struct DocumentEmbedCommandPayload {
|
||||
document_id: String,
|
||||
workspace_id: Option<String>,
|
||||
revision: Option<u64>,
|
||||
content: Value,
|
||||
#[serde(default)]
|
||||
content: Option<Value>,
|
||||
conflict_detection_key: Option<String>,
|
||||
source_document_id: String,
|
||||
target_document_id: String,
|
||||
@@ -2247,6 +2250,32 @@ fn materialize_tree_domain_event_plan(
|
||||
result: &Value,
|
||||
) -> Option<(String, Value)> {
|
||||
let event_plan = plan.args_json.get("domainEventPlan")?;
|
||||
materialize_tree_domain_event_value(event_plan, plan, result)
|
||||
}
|
||||
|
||||
fn materialize_tree_domain_event_plans(
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
result: &Value,
|
||||
) -> Vec<(String, Value)> {
|
||||
plan.args_json
|
||||
.get("domainEventPlans")
|
||||
.and_then(Value::as_array)
|
||||
.map(|event_plans| {
|
||||
event_plans
|
||||
.iter()
|
||||
.filter_map(|event_plan| materialize_tree_domain_event_value(event_plan, plan, result))
|
||||
.collect()
|
||||
})
|
||||
.filter(|event_plans: &Vec<(String, Value)>| !event_plans.is_empty())
|
||||
.or_else(|| materialize_tree_domain_event_plan(plan, result).map(|event_plan| vec![event_plan]))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn materialize_tree_domain_event_value(
|
||||
event_plan: &Value,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
result: &Value,
|
||||
) -> Option<(String, Value)> {
|
||||
if event_plan.get("family").and_then(Value::as_str) != Some("tree")
|
||||
|| event_plan.get("schema").and_then(Value::as_str) != Some("mnote.tree.domain_event")
|
||||
|| event_plan.get("schemaVersion").and_then(Value::as_i64) != Some(1)
|
||||
@@ -2354,8 +2383,8 @@ pub fn build_runtime_command_artifact_plan(
|
||||
.and_then(|target| target.block_id.as_ref())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let materialized_event_plan = materialize_tree_domain_event_plan(plan, result);
|
||||
let command_payload = if let Some((_, domain_event_plan)) = &materialized_event_plan {
|
||||
let materialized_event_plans = materialize_tree_domain_event_plans(plan, result);
|
||||
let command_payload = if let Some((_, domain_event_plan)) = materialized_event_plans.first() {
|
||||
if let Some(stream_delta) = domain_event_plan.get("streamDelta") {
|
||||
let mut payload = command.payload.clone();
|
||||
if let Some(map) = payload.as_object_mut() {
|
||||
@@ -2374,6 +2403,18 @@ pub fn build_runtime_command_artifact_plan(
|
||||
command.payload.clone()
|
||||
};
|
||||
|
||||
let aggregate_type = if target_block_id.is_some() {
|
||||
"block"
|
||||
} else if target_page_id.is_some() {
|
||||
"page"
|
||||
} else {
|
||||
"workspace"
|
||||
};
|
||||
let aggregate_id = target_block_id
|
||||
.as_deref()
|
||||
.or(target_page_id.as_deref())
|
||||
.unwrap_or(workspace_id);
|
||||
|
||||
let command_log = RuntimeCommandLogArtifactPlan {
|
||||
workspace_id: workspace_id.into(),
|
||||
id: command_log_id.clone(),
|
||||
@@ -2400,25 +2441,22 @@ pub fn build_runtime_command_artifact_plan(
|
||||
finished_at: Some(now.into()),
|
||||
};
|
||||
|
||||
let domain_event = materialized_event_plan.map(|(event_type, domain_event_plan)| {
|
||||
let aggregate_type = if target_block_id.is_some() {
|
||||
"block"
|
||||
} else if target_page_id.is_some() {
|
||||
"page"
|
||||
} else {
|
||||
"workspace"
|
||||
};
|
||||
let aggregate_id = target_block_id
|
||||
.as_deref()
|
||||
.or(target_page_id.as_deref())
|
||||
.unwrap_or(workspace_id);
|
||||
let domain_events: Vec<RuntimeDomainEventArtifactPlan> = materialized_event_plans
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (event_type, domain_event_plan))| {
|
||||
RuntimeDomainEventArtifactPlan {
|
||||
workspace_id: workspace_id.into(),
|
||||
id: format!("evt_{}", command.command_id),
|
||||
id: tree_domain_event_artifact_id(
|
||||
&command.command_id,
|
||||
event_type,
|
||||
index,
|
||||
materialized_event_plans.len(),
|
||||
),
|
||||
request_id: context.request_id.clone(),
|
||||
trace_id: context.trace_id.clone(),
|
||||
command_id: command.command_id.clone(),
|
||||
command_log_id,
|
||||
command_log_id: command_log_id.clone(),
|
||||
event_type: event_type.clone(),
|
||||
aggregate_type: aggregate_type.into(),
|
||||
aggregate_id: aggregate_id.into(),
|
||||
@@ -2435,14 +2473,110 @@ pub fn build_runtime_command_artifact_plan(
|
||||
),
|
||||
created_at: now.into(),
|
||||
}
|
||||
});
|
||||
})
|
||||
.collect();
|
||||
let domain_event = domain_events.first().cloned();
|
||||
|
||||
Some(RuntimeCommandArtifactPlan {
|
||||
command_log,
|
||||
domain_event,
|
||||
domain_events,
|
||||
})
|
||||
}
|
||||
|
||||
fn tree_domain_event_artifact_id(command_id: &str, event_type: &str, index: usize, total: usize) -> String {
|
||||
if total <= 1 || index == 0 {
|
||||
return format!("evt_{command_id}");
|
||||
}
|
||||
let suffix: String = event_type
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("evt_{command_id}_{:02}_{suffix}", index + 1)
|
||||
}
|
||||
|
||||
fn read_preflight_field<'a>(command: &'a RuntimeCommandEnvelopeWire, field: &str) -> Option<&'a Value> {
|
||||
command
|
||||
.preflight_data
|
||||
.as_ref()
|
||||
.and_then(|preflight| preflight.get(field))
|
||||
}
|
||||
|
||||
fn compose_content_with_blocks(content: &Value, blocks: Vec<Value>) -> Value {
|
||||
if content.is_array() {
|
||||
return Value::Array(blocks);
|
||||
}
|
||||
if let Some(map) = content.as_object() {
|
||||
let mut next = map.clone();
|
||||
next.insert("blocks".into(), Value::Array(blocks));
|
||||
return Value::Object(next);
|
||||
}
|
||||
json!({ "blocks": blocks })
|
||||
}
|
||||
|
||||
fn build_page_aggregate_embed_plan(
|
||||
command_wire: &RuntimeCommandEnvelopeWire,
|
||||
payload: &DocumentEmbedCommandPayload,
|
||||
) -> Result<Option<Value>, BridgeError> {
|
||||
let Some(input) = read_preflight_field(command_wire, "pageAggregateEmbed") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target_content = input
|
||||
.get("targetContent")
|
||||
.ok_or_else(|| BridgeError::validation("pageAggregateEmbed 缺少 targetContent"))?;
|
||||
let source_title = read_trimmed_str_field(input, "sourceTitle").unwrap_or("无标题");
|
||||
let anchor_block_id = payload
|
||||
.anchor_block_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| read_trimmed_str_field(input, "anchorBlockId"));
|
||||
let block_id = read_trimmed_str_field(input, "blockId")
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("page_ref_{}", payload.source_document_id));
|
||||
let current_blocks = normalize_blocks_from_value(target_content);
|
||||
let insert_index = anchor_block_id
|
||||
.and_then(|anchor_id| {
|
||||
current_blocks.iter().position(|block| {
|
||||
read_trimmed_str_field(block, "id")
|
||||
.map(|block_id| block_id == anchor_id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.map(|index| index + 1)
|
||||
.unwrap_or(current_blocks.len());
|
||||
let page_reference_block = json!({
|
||||
"id": block_id,
|
||||
"type": "pageReference",
|
||||
"props": {
|
||||
"pageId": payload.source_document_id,
|
||||
"title": source_title,
|
||||
},
|
||||
});
|
||||
let mut next_blocks = Vec::with_capacity(current_blocks.len() + 1);
|
||||
next_blocks.extend(current_blocks.iter().take(insert_index).cloned());
|
||||
next_blocks.push(page_reference_block.clone());
|
||||
next_blocks.extend(current_blocks.iter().skip(insert_index).cloned());
|
||||
let next_content = compose_content_with_blocks(target_content, next_blocks);
|
||||
Ok(Some(json!({
|
||||
"schema": "mnote.page_aggregate.embed_plan",
|
||||
"schemaVersion": 1,
|
||||
"sourceDocumentId": payload.source_document_id,
|
||||
"targetDocumentId": payload.target_document_id,
|
||||
"anchorBlockId": anchor_block_id,
|
||||
"insertIndex": insert_index,
|
||||
"block": page_reference_block,
|
||||
"content": next_content,
|
||||
"blockCount": normalize_blocks_from_value(&next_content).len(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeBlockSummary {
|
||||
@@ -6288,6 +6422,8 @@ fn build_file_tree_projection_result(
|
||||
filters: &KernelProjectionFilter,
|
||||
) -> KernelProjectionResult {
|
||||
let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data);
|
||||
let requested_query = normalize_projection_query(filters.query.as_deref());
|
||||
let requested_max_results = filters.max_results;
|
||||
let page_ids = subtree
|
||||
.nodes
|
||||
.iter()
|
||||
@@ -6595,6 +6731,8 @@ fn build_file_tree_projection_result(
|
||||
}
|
||||
|
||||
apply_file_tree_projection_search_filter(&mut items, &mut edges, filters);
|
||||
let visible_rows = items.len();
|
||||
let visible_edges = edges.len();
|
||||
|
||||
KernelProjectionResult {
|
||||
projection_id: format!(
|
||||
@@ -6605,6 +6743,31 @@ fn build_file_tree_projection_result(
|
||||
root_node_id: root_node_id.map(ToOwned::to_owned),
|
||||
items,
|
||||
edges,
|
||||
meta: BTreeMap::from([(
|
||||
"search".into(),
|
||||
json!({
|
||||
"query": requested_query.clone(),
|
||||
"maxResults": requested_max_results,
|
||||
"maxResultsRule": "matches_only_before_ancestor_completion",
|
||||
"ancestorCompletion": "include_all_ancestors_after_match_truncation",
|
||||
"ordering": "kernel_file_tree_preorder",
|
||||
"indexingVisibility": {
|
||||
"schema": "mnote.file_tree.indexing_visibility",
|
||||
"schemaVersion": 1,
|
||||
"source": "kernel.project_view",
|
||||
"status": "visible",
|
||||
"requestKey": requested_query
|
||||
.as_ref()
|
||||
.map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))),
|
||||
"indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
"metrics": {
|
||||
"visibleRows": visible_rows,
|
||||
"visibleEdges": visible_edges,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6883,6 +7046,7 @@ fn build_kernel_projection_result(
|
||||
root_node_id: root_node_id.map(ToOwned::to_owned),
|
||||
items,
|
||||
edges: subtree.edges,
|
||||
meta: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7677,11 +7841,24 @@ fn execute_command(
|
||||
"documentId": payload.document_id,
|
||||
}),
|
||||
);
|
||||
let domain_event_payload = document_save_domain_event_payload(
|
||||
let page_body_event_payload = document_save_page_body_domain_event_payload(
|
||||
&payload,
|
||||
&editor_document,
|
||||
);
|
||||
let snapshot_event_payload = document_save_snapshot_domain_event_payload(
|
||||
&payload,
|
||||
&canonical_content,
|
||||
)?;
|
||||
let page_body_event_plan = tree_domain_event_plan_with_payload(
|
||||
"page.body.saved",
|
||||
page_body_event_payload,
|
||||
stream_delta_hint.clone(),
|
||||
);
|
||||
let snapshot_event_plan = tree_domain_event_plan_with_payload(
|
||||
"document.snapshot.saved",
|
||||
snapshot_event_payload,
|
||||
stream_delta_hint.clone(),
|
||||
);
|
||||
json!({
|
||||
"id": payload.document_id,
|
||||
"content": canonical_content,
|
||||
@@ -7691,11 +7868,8 @@ fn execute_command(
|
||||
"conflictDetectionKey": payload.conflict_detection_key,
|
||||
"streamDeltaHint": stream_delta_hint,
|
||||
"domainEventHint": tree_domain_event_hint("page.body.saved"),
|
||||
"domainEventPlan": tree_domain_event_plan_with_payload(
|
||||
"page.body.saved",
|
||||
domain_event_payload,
|
||||
stream_delta_hint,
|
||||
),
|
||||
"domainEventPlan": page_body_event_plan.clone(),
|
||||
"domainEventPlans": [page_body_event_plan, snapshot_event_plan],
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -7707,6 +7881,17 @@ fn execute_command(
|
||||
} else {
|
||||
"documents.embed"
|
||||
};
|
||||
let page_aggregate_embed_plan = build_page_aggregate_embed_plan(&command_wire, &payload)?;
|
||||
let embed_content = page_aggregate_embed_plan
|
||||
.as_ref()
|
||||
.and_then(|plan| plan.get("content"))
|
||||
.cloned()
|
||||
.or_else(|| payload.content.clone())
|
||||
.ok_or_else(|| {
|
||||
BridgeError::validation(format!(
|
||||
"{command_name} 缺少 content 或 pageAggregateEmbed preflight"
|
||||
))
|
||||
})?;
|
||||
let command = CommandEnvelope {
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
@@ -7718,7 +7903,7 @@ fn execute_command(
|
||||
page_id: payload.document_id.clone(),
|
||||
workspace_id: payload.workspace_id.clone(),
|
||||
revision: payload.revision,
|
||||
content_json: serde_json::to_string(&payload.content).map_err(|error| {
|
||||
content_json: serde_json::to_string(&embed_content).map_err(|error| {
|
||||
BridgeError::validation(format!(
|
||||
"{command_name} content 序列化失败: {error}"
|
||||
))
|
||||
@@ -7743,12 +7928,13 @@ fn execute_command(
|
||||
payload_json: request.payload_json,
|
||||
args_json: json!({
|
||||
"id": payload.document_id,
|
||||
"content": payload.content,
|
||||
"content": embed_content,
|
||||
"expectedRevision": payload.revision,
|
||||
"conflictDetectionKey": payload.conflict_detection_key,
|
||||
"sourceDocumentId": payload.source_document_id,
|
||||
"targetDocumentId": payload.target_document_id,
|
||||
"anchorBlockId": payload.anchor_block_id,
|
||||
"pageAggregateEmbedPlan": page_aggregate_embed_plan,
|
||||
"streamDeltaHint": tree_stream_delta_hint("noop", json!({})),
|
||||
"domainEventHint": tree_domain_event_hint("tree.node.embedded"),
|
||||
"domainEventPlan": tree_domain_event_plan(
|
||||
@@ -7979,8 +8165,12 @@ fn execute_command(
|
||||
"documents.move" | "tree.subtree.move" => {
|
||||
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?;
|
||||
let normalized_move =
|
||||
let move_order_plan =
|
||||
resolve_document_move_order_plan(&payload, command_wire.preflight_data.as_ref())?;
|
||||
let tree_write_operation = document_move_write_operation(
|
||||
context.workspace_id.as_deref(),
|
||||
move_order_plan.as_ref(),
|
||||
);
|
||||
let command_name = if command_wire.name == "tree.subtree.move" {
|
||||
"tree.subtree.move"
|
||||
} else {
|
||||
@@ -8014,11 +8204,7 @@ fn execute_command(
|
||||
"id": payload.document_id,
|
||||
"parentId": payload.parent_id,
|
||||
"sortOrder": payload.sort_order,
|
||||
"normalizedMove": normalized_move,
|
||||
"treeWriteOperation": document_move_write_operation(
|
||||
context.workspace_id.as_deref(),
|
||||
normalized_move.as_ref(),
|
||||
),
|
||||
"treeWriteOperation": tree_write_operation,
|
||||
"streamDeltaHint": tree_stream_delta_hint("move_document", json!({
|
||||
"documentId": payload.document_id,
|
||||
"parentId": payload.parent_id,
|
||||
@@ -8792,9 +8978,24 @@ fn editor_document_block_ids(document: &EditorBlockDocument) -> Vec<String> {
|
||||
ids
|
||||
}
|
||||
|
||||
fn document_save_domain_event_payload(
|
||||
fn document_save_page_body_domain_event_payload(
|
||||
payload: &DocumentSaveCommandPayload,
|
||||
editor_document: &EditorBlockDocument,
|
||||
) -> Value {
|
||||
json!({
|
||||
"page": {
|
||||
"id": payload.document_id.clone(),
|
||||
"workspaceId": payload.workspace_id.clone(),
|
||||
},
|
||||
"blocks": {
|
||||
"ids": editor_document_block_ids(editor_document),
|
||||
"count": editor_document.blocks.len(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn document_save_snapshot_domain_event_payload(
|
||||
payload: &DocumentSaveCommandPayload,
|
||||
canonical_content: &Value,
|
||||
) -> Result<Value, BridgeError> {
|
||||
Ok(json!({
|
||||
@@ -8807,10 +9008,6 @@ fn document_save_domain_event_payload(
|
||||
"contentHash": stable_json_content_hash(canonical_content)?,
|
||||
"updatedAt": Value::Null,
|
||||
},
|
||||
"blocks": {
|
||||
"ids": editor_document_block_ids(editor_document),
|
||||
"count": editor_document.blocks.len(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -9023,26 +9220,47 @@ mod tests {
|
||||
"id": "block_1",
|
||||
"type": "paragraph",
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
},
|
||||
"domainEventHint": {
|
||||
"family": "tree",
|
||||
"eventType": "tree.block.patched"
|
||||
},
|
||||
"domainEventPlan": {
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "tree.block.patched",
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
}
|
||||
}
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "blocks.patch",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "block_1"
|
||||
}
|
||||
},
|
||||
"domainEventHint": {
|
||||
"family": "tree",
|
||||
"eventType": "block.patched"
|
||||
},
|
||||
"domainEventPlan": {
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "block.patched",
|
||||
"payload": {
|
||||
"document": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
},
|
||||
"block": {
|
||||
"id": "block_1"
|
||||
},
|
||||
"patch": {
|
||||
"summary": "replace_block",
|
||||
"nextType": "paragraph"
|
||||
}
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "blocks.patch",
|
||||
"documentId": "doc_1",
|
||||
"blockId": "block_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -10197,11 +10415,6 @@ mod tests {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
},
|
||||
"snapshot": {
|
||||
"version": 8,
|
||||
"contentHash": "fnv1a64:d039f8f3496411e8",
|
||||
"updatedAt": null
|
||||
},
|
||||
"blocks": {
|
||||
"ids": ["legacy_content_1"],
|
||||
"count": 1
|
||||
@@ -10218,6 +10431,62 @@ mod tests {
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plan.args_json["domainEventPlans"],
|
||||
json!([
|
||||
{
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "page.body.saved",
|
||||
"payload": {
|
||||
"page": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
},
|
||||
"blocks": {
|
||||
"ids": ["legacy_content_1"],
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "page_body_saved",
|
||||
"pageId": "doc_1",
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "document.snapshot.saved",
|
||||
"payload": {
|
||||
"page": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1"
|
||||
},
|
||||
"snapshot": {
|
||||
"version": 8,
|
||||
"contentHash": "fnv1a64:d039f8f3496411e8",
|
||||
"updatedAt": null
|
||||
}
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "resync_required",
|
||||
"args": {
|
||||
"reason": "page_body_saved",
|
||||
"pageId": "doc_1",
|
||||
"documentId": "doc_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10610,10 +10879,11 @@ mod tests {
|
||||
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||
"expectedRevision": 5,
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
"streamDeltaHint": {
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
"pageAggregateEmbedPlan": null,
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
@@ -11177,7 +11447,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_includes_normalized_move_plan_from_snapshot() {
|
||||
fn tree_subtree_move_command_does_not_emit_legacy_normalized_move_fallback() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
@@ -11226,9 +11496,15 @@ mod tests {
|
||||
assert_eq!(plan.command_name, "tree.subtree.move");
|
||||
assert_eq!(plan.function_name, "documents:move");
|
||||
assert_eq!(plan.args_json["sortOrder"], json!(-2));
|
||||
assert!(plan.args_json.get("normalizedMove").is_none());
|
||||
assert_eq!(
|
||||
plan.args_json["normalizedMove"],
|
||||
plan.args_json["treeWriteOperation"],
|
||||
json!({
|
||||
"family": "tree",
|
||||
"schema": "mnote.tree.write_operation",
|
||||
"schemaVersion": 1,
|
||||
"operation": "tree.subtree.move.write",
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_b",
|
||||
"fromParentId": "source",
|
||||
"toParentId": "target",
|
||||
@@ -11711,20 +11987,33 @@ mod tests {
|
||||
page_id: Some("doc_2".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_2",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 5,
|
||||
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("树命令嵌入页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
payload: json!({
|
||||
"documentId": "doc_2",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 5,
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"pageAggregateEmbed": {
|
||||
"sourceDocumentId": "doc_1",
|
||||
"sourceTitle": "来源页面",
|
||||
"targetDocumentId": "doc_2",
|
||||
"targetContent": {
|
||||
"blocks": [
|
||||
{ "id": "anchor_1", "type": "paragraph" }
|
||||
],
|
||||
"format": "editor"
|
||||
},
|
||||
"anchorBlockId": "anchor_1",
|
||||
"blockId": "page_ref_doc_1"
|
||||
}
|
||||
})),
|
||||
reason: Some("树命令嵌入页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
@@ -11736,15 +12025,59 @@ mod tests {
|
||||
assert_eq!(plan.command_name, "tree.node.embed");
|
||||
assert_eq!(
|
||||
plan.args_json,
|
||||
json!({
|
||||
"id": "doc_2",
|
||||
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||
"expectedRevision": 5,
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
"streamDeltaHint": {
|
||||
json!({
|
||||
"id": "doc_2",
|
||||
"content": {
|
||||
"blocks": [
|
||||
{ "id": "anchor_1", "type": "paragraph" },
|
||||
{
|
||||
"id": "page_ref_doc_1",
|
||||
"type": "pageReference",
|
||||
"props": {
|
||||
"pageId": "doc_1",
|
||||
"title": "来源页面"
|
||||
}
|
||||
}
|
||||
],
|
||||
"format": "editor"
|
||||
},
|
||||
"expectedRevision": 5,
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
"pageAggregateEmbedPlan": {
|
||||
"schema": "mnote.page_aggregate.embed_plan",
|
||||
"schemaVersion": 1,
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
"insertIndex": 1,
|
||||
"block": {
|
||||
"id": "page_ref_doc_1",
|
||||
"type": "pageReference",
|
||||
"props": {
|
||||
"pageId": "doc_1",
|
||||
"title": "来源页面"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"blocks": [
|
||||
{ "id": "anchor_1", "type": "paragraph" },
|
||||
{
|
||||
"id": "page_ref_doc_1",
|
||||
"type": "pageReference",
|
||||
"props": {
|
||||
"pageId": "doc_1",
|
||||
"title": "来源页面"
|
||||
}
|
||||
}
|
||||
],
|
||||
"format": "editor"
|
||||
},
|
||||
"blockCount": 2
|
||||
},
|
||||
"streamDeltaHint": {
|
||||
"family": "tree",
|
||||
"kind": "noop",
|
||||
"args": {}
|
||||
@@ -13181,6 +13514,18 @@ mod tests {
|
||||
result["projectionId"],
|
||||
json!("kernel_projection:file_tree:page_root")
|
||||
);
|
||||
assert_eq!(
|
||||
result["meta"]["search"]["indexingVisibility"]["schema"],
|
||||
json!("mnote.file_tree.indexing_visibility")
|
||||
);
|
||||
assert_eq!(
|
||||
result["meta"]["search"]["indexingVisibility"]["status"],
|
||||
json!("visible")
|
||||
);
|
||||
assert_eq!(
|
||||
result["meta"]["search"]["indexingVisibility"]["metrics"]["visibleRows"],
|
||||
json!(items.len())
|
||||
);
|
||||
assert_eq!(
|
||||
item_by_row_id["doc:page_root"]["rowKind"],
|
||||
json!("document")
|
||||
|
||||
@@ -352,6 +352,8 @@ pub struct KernelProjectionResult {
|
||||
pub root_node_id: Option<String>,
|
||||
pub items: Vec<KernelProjectionItem>,
|
||||
pub edges: Vec<KernelEdge>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub meta: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "tree-shell-runtime-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,71 @@
|
||||
use serde_wasm_bindgen::{from_value, to_value};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// 说明:复用 mnote-web 当前 tree shell runtime 源码,避免复制第二份 reducer 真相。
|
||||
pub mod tree_shell {
|
||||
pub mod drag_drop_state {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/drag_drop_state.rs"
|
||||
));
|
||||
}
|
||||
pub mod filetree_runtime {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/filetree_runtime.rs"
|
||||
));
|
||||
}
|
||||
pub mod filetree_selection {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/filetree_selection.rs"
|
||||
));
|
||||
}
|
||||
pub mod focus_state {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/focus_state.rs"
|
||||
));
|
||||
}
|
||||
pub mod page_runtime {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/page_runtime.rs"
|
||||
));
|
||||
}
|
||||
pub mod picker_runtime {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/picker_runtime.rs"
|
||||
));
|
||||
}
|
||||
pub mod picker_state {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/picker_state.rs"
|
||||
));
|
||||
}
|
||||
pub mod runtime_api {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../mnote-web/src/tree_shell/runtime_api.rs"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn format_js_error(stage: &str, error: impl std::fmt::Display) -> JsValue {
|
||||
JsValue::from_str(&format!("[tree_shell_runtime_wasm:{stage}] {error}"))
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn install_panic_hook() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = reduceTreeShellRuntime)]
|
||||
pub fn reduce_tree_shell_runtime_export(request: JsValue) -> Result<JsValue, JsValue> {
|
||||
let request = from_value::<tree_shell::runtime_api::TreeShellRuntimeRequest>(request)
|
||||
.map_err(|error| format_js_error("decode_request", error))?;
|
||||
let result = tree_shell::runtime_api::reduce_tree_shell_runtime(request);
|
||||
to_value(&result).map_err(|error| format_js_error("encode_result", error))
|
||||
}
|
||||
Reference in New Issue
Block a user