feat: continue tree rust family cutover
- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker - route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans - preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
Generated
+53
@@ -481,6 +481,15 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive-where"
|
||||
version = "1.6.1"
|
||||
@@ -1410,6 +1419,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"storage-convex-bridge",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -1432,6 +1442,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "oco_ref"
|
||||
version = "0.2.1"
|
||||
@@ -1519,6 +1535,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -2341,6 +2363,37 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
use std::io::{self, Read};
|
||||
|
||||
use bridge_runtime::{
|
||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||
build_artifact_success_response, build_failure_response, build_success_response,
|
||||
execute_runtime_command_artifact, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeFailure, RuntimeInput,
|
||||
};
|
||||
use storage_convex_bridge::{BridgeError, BridgeErrorKind};
|
||||
@@ -26,7 +27,16 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
if runtime_input_requests_result(&runtime_input) {
|
||||
if matches!(runtime_input, RuntimeInput::CommandArtifact { .. }) {
|
||||
match execute_runtime_command_artifact(runtime_input) {
|
||||
Ok(artifacts) => {
|
||||
let payload = serde_json::to_string(&build_artifact_success_response(artifacts))
|
||||
.expect("bridge runtime artifact 成功响应必须可序列化");
|
||||
println!("{payload}");
|
||||
}
|
||||
Err(error) => emit_failure(error),
|
||||
}
|
||||
} else if runtime_input_requests_result(&runtime_input) {
|
||||
match execute_runtime_query(runtime_input) {
|
||||
Ok(result) => {
|
||||
let payload = serde_json::to_string(&serde_json::json!({
|
||||
|
||||
@@ -212,6 +212,8 @@ pub struct KernelProjectionFilter {
|
||||
pub node_types: Vec<KernelNodeType>,
|
||||
#[serde(default)]
|
||||
pub edge_types: Vec<KernelEdgeType>,
|
||||
pub query: Option<String>,
|
||||
pub max_results: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub include_deleted: bool,
|
||||
}
|
||||
@@ -526,6 +528,8 @@ mod tests {
|
||||
filters: KernelProjectionFilter {
|
||||
node_types: vec![KernelNodeType::Page, KernelNodeType::Folder],
|
||||
edge_types: vec![KernelEdgeType::ParentOf],
|
||||
query: Some("预算".into()),
|
||||
max_results: Some(20),
|
||||
include_deleted: false,
|
||||
},
|
||||
include_content: false,
|
||||
@@ -535,6 +539,8 @@ mod tests {
|
||||
let value = serde_json::to_value(&request).expect("request 应可序列化");
|
||||
assert_eq!(value["projection"], json!("sidebar_tree"));
|
||||
assert_eq!(value["subtree"]["rootNodeId"], json!("page_root"));
|
||||
assert_eq!(value["filters"]["query"], json!("预算"));
|
||||
assert_eq!(value["filters"]["maxResults"], json!(20));
|
||||
|
||||
let decoded: KernelProjectionRequest =
|
||||
serde_json::from_value(value).expect("request 应可反序列化");
|
||||
|
||||
@@ -22,3 +22,4 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
||||
tower = "0.5"
|
||||
base64 = "0.22"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -68,6 +68,10 @@ impl WebError {
|
||||
self.headers.push((name, value.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::execute_convex_command_plan;
|
||||
use crate::transport::convex::{
|
||||
ConvexCommandExecution, execute_convex_command_plan, execute_convex_command_plan_with_artifacts,
|
||||
};
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire,
|
||||
RuntimeTargetWire, execute_runtime_input,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -66,6 +68,27 @@ pub async fn execute_runtime_command_via_convex(
|
||||
execute_convex_command_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_convex_with_artifacts(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<ConvexCommandExecution, WebError> {
|
||||
let runtime_context = runtime_context(context, effective_workspace_id);
|
||||
let runtime_input = RuntimeInput::Command {
|
||||
context: runtime_context.clone(),
|
||||
command: command.clone(),
|
||||
};
|
||||
let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input)
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))?
|
||||
else {
|
||||
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
|
||||
};
|
||||
|
||||
execute_convex_command_plan_with_artifacts(config, context, &runtime_context, &command, &plan)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn build_tree_target(
|
||||
workspace_id: &str,
|
||||
page_id: Option<&str>,
|
||||
|
||||
@@ -64,6 +64,8 @@ pub async fn next_sidebar(
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: None,
|
||||
depth: None,
|
||||
query: None,
|
||||
max_results: None,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct KernelProjectionQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
pub root_node_id: Option<String>,
|
||||
pub depth: Option<u32>,
|
||||
pub query: Option<String>,
|
||||
pub max_results: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -73,6 +75,8 @@ async fn project_projection(
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: query.root_node_id.as_deref(),
|
||||
depth: query.depth,
|
||||
query: query.query.as_deref(),
|
||||
max_results: query.max_results,
|
||||
projection,
|
||||
},
|
||||
)
|
||||
@@ -388,4 +392,64 @@ mod tests {
|
||||
.iter()
|
||||
.any(|value| value == "expand"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_query_returns_matches_and_ancestors() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=%E9%A2%84%E7%AE%97")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let items = payload["result"]["items"].as_array().expect("items");
|
||||
let row_ids = items
|
||||
.iter()
|
||||
.filter_map(|item| item["rowId"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]);
|
||||
assert_eq!(items[0]["expandedByDefault"], true);
|
||||
assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table");
|
||||
let edges = payload["result"]["edges"].as_array().expect("edges");
|
||||
assert_eq!(edges.len(), 1);
|
||||
assert_eq!(edges[0]["fromNodeId"], "page_root");
|
||||
assert_eq!(edges[0]["toNodeId"], "asset:table_1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_query_honors_max_results_and_keeps_ancestors() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=png&maxResults=1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let items = payload["result"]["items"].as_array().expect("items");
|
||||
let row_ids = items
|
||||
.iter()
|
||||
.filter_map(|item| item["rowId"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(row_ids, vec!["doc:page_root", "asset:asset_file_1"]);
|
||||
assert_eq!(items[0]["expandedByDefault"], true);
|
||||
assert_eq!(items[1]["resourceMeta"]["assetKind"], "image");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ProjectionSnapshotSpec<'a> {
|
||||
pub workspace_id: &'a str,
|
||||
pub root_node_id: Option<&'a str>,
|
||||
pub depth: Option<u32>,
|
||||
pub query: Option<&'a str>,
|
||||
pub max_results: Option<usize>,
|
||||
pub projection: KernelProjectionKind,
|
||||
}
|
||||
|
||||
@@ -53,6 +55,8 @@ pub fn projection_query(spec: &ProjectionSnapshotSpec<'_>) -> RuntimeQueryEnvelo
|
||||
"workspaceId": spec.workspace_id,
|
||||
"rootNodeId": spec.root_node_id,
|
||||
"depth": spec.depth,
|
||||
"query": spec.query,
|
||||
"maxResults": spec.max_results,
|
||||
"includeEdges": true,
|
||||
"includeContent": false,
|
||||
"nodeTypes": [KernelNodeType::Page],
|
||||
|
||||
@@ -78,7 +78,9 @@ pub async fn events(
|
||||
&workspace_id,
|
||||
&overview,
|
||||
change.cursor,
|
||||
change.delta.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||
change
|
||||
.delta
|
||||
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||
);
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
@@ -95,8 +97,7 @@ pub async fn events(
|
||||
return None;
|
||||
};
|
||||
state.query = next_query;
|
||||
state.current_cursor =
|
||||
read_stream_cursor_from_payload(&snapshot_payload);
|
||||
state.current_cursor = read_stream_cursor_from_payload(&snapshot_payload);
|
||||
return Some((
|
||||
Ok(stream_event(
|
||||
"resync",
|
||||
|
||||
@@ -125,7 +125,11 @@ fn is_record(value: &Value) -> bool {
|
||||
fn read_string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
let map = value.as_object()?;
|
||||
for key in keys {
|
||||
let candidate = map.get(*key).and_then(Value::as_str).map(str::trim).unwrap_or("");
|
||||
let candidate = map
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
if !candidate.is_empty() {
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
@@ -149,22 +153,30 @@ fn encode_stream_cursor(id: &str, created_at: &str) -> Option<String> {
|
||||
if id.is_empty() || created_at.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"createdAt": created_at,
|
||||
"id": id,
|
||||
})
|
||||
.to_string())
|
||||
Some(
|
||||
json!({
|
||||
"createdAt": created_at,
|
||||
"id": id,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_command_cursor(row: &Value) -> Option<String> {
|
||||
let id = read_string_field(row, &["id", "command_id", "commandId"])?;
|
||||
let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?;
|
||||
let created_at = read_string_field(
|
||||
row,
|
||||
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||
)?;
|
||||
encode_stream_cursor(&id, &created_at)
|
||||
}
|
||||
|
||||
fn encode_domain_event_cursor(row: &Value) -> Option<String> {
|
||||
let id = read_string_field(row, &["event_id", "eventId", "id"])?;
|
||||
let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?;
|
||||
let created_at = read_string_field(
|
||||
row,
|
||||
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||
)?;
|
||||
encode_stream_cursor(&format!("domain_event:{id}"), &created_at)
|
||||
}
|
||||
|
||||
@@ -176,10 +188,7 @@ fn decode_stream_cursor(raw: &str) -> Option<DecodedStreamCursor> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_stream_cursor(
|
||||
overview: Option<&Value>,
|
||||
fallback: Option<&str>,
|
||||
) -> Option<String> {
|
||||
pub fn resolve_stream_cursor(overview: Option<&Value>, fallback: Option<&str>) -> Option<String> {
|
||||
let fallback = fallback
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -218,29 +227,98 @@ pub fn resolve_stream_cursor(
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_new_command_logs(
|
||||
rows: &[Value],
|
||||
previous_cursor: Option<&str>,
|
||||
) -> (Vec<Value>, bool) {
|
||||
fn collect_new_command_logs(rows: &[Value], previous_cursor: Option<&str>) -> (Vec<Value>, bool) {
|
||||
let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else {
|
||||
return (rows.to_vec(), false);
|
||||
};
|
||||
|
||||
let previous_index = rows.iter().position(|row| {
|
||||
let id = read_string_field(row, &["id", "command_id", "commandId"]).unwrap_or_default();
|
||||
let created_at =
|
||||
read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])
|
||||
.unwrap_or_default();
|
||||
let created_at = read_string_field(
|
||||
row,
|
||||
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
id == previous_cursor.id && created_at == previous_cursor.created_at
|
||||
});
|
||||
|
||||
if let Some(index) = previous_index {
|
||||
(rows.iter().take(index).cloned().collect(), false)
|
||||
} else {
|
||||
let newer_rows = rows
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
read_string_field(
|
||||
row,
|
||||
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||
)
|
||||
.map(|created_at| created_at > previous_cursor.created_at)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if newer_rows.len() < rows.len() {
|
||||
return (newer_rows, false);
|
||||
}
|
||||
(rows.to_vec(), !rows.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_new_domain_events(rows: &[Value], previous_cursor: Option<&str>) -> (Vec<Value>, bool) {
|
||||
let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else {
|
||||
return (rows.to_vec(), false);
|
||||
};
|
||||
let previous_id = previous_cursor
|
||||
.id
|
||||
.strip_prefix("domain_event:")
|
||||
.unwrap_or(previous_cursor.id.as_str());
|
||||
|
||||
let previous_index = rows.iter().position(|row| {
|
||||
let id = read_string_field(row, &["event_id", "eventId", "id"]).unwrap_or_default();
|
||||
let created_at = read_string_field(
|
||||
row,
|
||||
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
id == previous_id && created_at == previous_cursor.created_at
|
||||
});
|
||||
|
||||
if let Some(index) = previous_index {
|
||||
(rows.iter().take(index).cloned().collect(), false)
|
||||
} else {
|
||||
let newer_rows = rows
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
read_string_field(
|
||||
row,
|
||||
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||
)
|
||||
.map(|created_at| created_at > previous_cursor.created_at)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if newer_rows.len() < rows.len() {
|
||||
return (newer_rows, false);
|
||||
}
|
||||
(rows.to_vec(), !rows.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stream_delta_candidate(candidate: &Value) -> Option<Value> {
|
||||
if candidate
|
||||
.as_object()
|
||||
.and_then(|map| map.get("op"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_command_payload_delta(row: &Value) -> Option<Value> {
|
||||
let command_name = read_string_field(row, &["command_name", "commandName"]).unwrap_or_default();
|
||||
if TREE_STREAM_NOOP_COMMANDS.contains(&command_name.as_str()) {
|
||||
@@ -255,17 +333,52 @@ fn read_command_payload_delta(row: &Value) -> Option<Value> {
|
||||
let candidate = payload
|
||||
.as_object()
|
||||
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
|
||||
if candidate
|
||||
read_stream_delta_candidate(candidate)
|
||||
}
|
||||
|
||||
fn read_domain_event_payload_delta(row: &Value) -> Option<Value> {
|
||||
let payload = row.as_object()?.get("payload")?;
|
||||
if !is_record(payload) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidate = payload
|
||||
.as_object()
|
||||
.and_then(|map| map.get("op"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return Some(candidate.clone());
|
||||
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
|
||||
read_stream_delta_candidate(candidate)
|
||||
}
|
||||
|
||||
fn read_command_row_command_id(row: &Value) -> Option<String> {
|
||||
read_string_field(row, &["command_id", "commandId", "id"])
|
||||
}
|
||||
|
||||
fn read_domain_event_command_id(row: &Value) -> Option<String> {
|
||||
read_string_field(row, &["command_id", "commandId"])
|
||||
}
|
||||
|
||||
fn resolve_matching_command_domain_event_delta(
|
||||
command_rows: &[Value],
|
||||
command_drifted: bool,
|
||||
domain_event_rows: &[Value],
|
||||
_domain_event_drifted: bool,
|
||||
) -> Option<Value> {
|
||||
if command_drifted || command_rows.len() != 1 || domain_event_rows.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command_id = read_command_row_command_id(&command_rows[0])?;
|
||||
let event_command_id = read_domain_event_command_id(&domain_event_rows[0])?;
|
||||
if command_id != event_command_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command_delta = read_command_payload_delta(&command_rows[0])?;
|
||||
let event_delta = read_domain_event_payload_delta(&domain_event_rows[0])?;
|
||||
if command_delta == event_delta {
|
||||
Some(event_delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn resolve_stream_change(
|
||||
@@ -281,8 +394,36 @@ pub fn resolve_stream_change(
|
||||
return None;
|
||||
}
|
||||
|
||||
let rows = read_array_field(overview, &["command_logs", "commandLogs"]).cloned().unwrap_or_default();
|
||||
let rows = read_array_field(overview, &["command_logs", "commandLogs"])
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (new_rows, drifted) = collect_new_command_logs(&rows, previous_cursor.as_deref());
|
||||
let event_rows = read_array_field(overview, &["domain_events", "domainEvents"])
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (new_event_rows, event_drifted) =
|
||||
collect_new_domain_events(&event_rows, previous_cursor.as_deref());
|
||||
if !new_rows.is_empty() && !new_event_rows.is_empty() {
|
||||
if let Some(delta) = resolve_matching_command_domain_event_delta(
|
||||
&new_rows,
|
||||
drifted,
|
||||
&new_event_rows,
|
||||
event_drifted,
|
||||
) {
|
||||
return Some(StreamChange {
|
||||
kind: StreamChangeKind::Delta,
|
||||
cursor: next_cursor,
|
||||
delta: Some(delta),
|
||||
});
|
||||
}
|
||||
|
||||
return Some(StreamChange {
|
||||
kind: StreamChangeKind::Resync,
|
||||
cursor: next_cursor,
|
||||
delta: None,
|
||||
});
|
||||
}
|
||||
|
||||
if !drifted && new_rows.len() == 1 {
|
||||
if let Some(delta) = read_command_payload_delta(&new_rows[0]) {
|
||||
return Some(StreamChange {
|
||||
@@ -293,6 +434,16 @@ pub fn resolve_stream_change(
|
||||
}
|
||||
}
|
||||
|
||||
if !event_drifted && new_rows.is_empty() && new_event_rows.len() == 1 {
|
||||
if let Some(delta) = read_domain_event_payload_delta(&new_event_rows[0]) {
|
||||
return Some(StreamChange {
|
||||
kind: StreamChangeKind::Delta,
|
||||
cursor: next_cursor,
|
||||
delta: Some(delta),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some(StreamChange {
|
||||
kind: StreamChangeKind::Resync,
|
||||
cursor: next_cursor,
|
||||
@@ -377,6 +528,8 @@ pub async fn load_stream_snapshot(
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: None,
|
||||
depth: query.depth,
|
||||
query: None,
|
||||
max_results: None,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
},
|
||||
)
|
||||
@@ -388,8 +541,8 @@ pub async fn load_stream_snapshot(
|
||||
})
|
||||
}
|
||||
StreamSnapshotScope::Subtree => {
|
||||
let root_node_id = normalize_root_node_id(query)
|
||||
.expect("subtree scope 已确保 rootNodeId 存在");
|
||||
let root_node_id =
|
||||
normalize_root_node_id(query).expect("subtree scope 已确保 rootNodeId 存在");
|
||||
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
||||
let tree = execute_kernel_query(
|
||||
context,
|
||||
@@ -435,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,
|
||||
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind,
|
||||
StreamSnapshotQuery, StreamSnapshotScope,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -487,6 +640,7 @@ mod tests {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.node.archive",
|
||||
@@ -498,6 +652,7 @@ mod tests {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
@@ -507,14 +662,14 @@ mod tests {
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.cursor,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into())
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into())
|
||||
);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
@@ -525,11 +680,195 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_preserves_move_document_delta_fields() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.subtree.move",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "move_document",
|
||||
"documentId": "page_2",
|
||||
"parentId": "page_1",
|
||||
"sortOrder": 3,
|
||||
"updatedAt": "2026-04-25T10:00:02Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": []
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "move_document",
|
||||
"documentId": "page_2",
|
||||
"parentId": "page_1",
|
||||
"sortOrder": 3,
|
||||
"updatedAt": "2026-04-25T10:00:02Z"
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_preserves_upsert_documents_delta_fields() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.subtree.copy",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "upsert_documents",
|
||||
"upsertDocuments": [
|
||||
{
|
||||
"id": "copy_1",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "Copy",
|
||||
"parent_id": null,
|
||||
"sort_order": 2,
|
||||
"is_starred": false,
|
||||
"access_scope": "private",
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"updated_at": "2026-04-25T10:00:02Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": []
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "upsert_documents",
|
||||
"upsertDocuments": [
|
||||
{
|
||||
"id": "copy_1",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "Copy",
|
||||
"parent_id": null,
|
||||
"sort_order": 2,
|
||||
"is_starred": false,
|
||||
"access_scope": "private",
|
||||
"is_template": false,
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"updated_at": "2026-04-25T10:00:02Z"
|
||||
}
|
||||
]
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_preserves_upsert_assets_delta_fields() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.resource.move",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "upsert_assets",
|
||||
"upsertAssets": [
|
||||
{
|
||||
"id": "asset_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "doc_target",
|
||||
"asset_type": "file",
|
||||
"file_url": "/file.pdf",
|
||||
"thumbnail_url": "/file.pdf",
|
||||
"file_name": "file.pdf",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/pdf",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"updated_at": "2026-04-25T10:00:02Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": []
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "upsert_assets",
|
||||
"upsertAssets": [
|
||||
{
|
||||
"id": "asset_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "doc_target",
|
||||
"asset_type": "file",
|
||||
"file_url": "/file.pdf",
|
||||
"thumbnail_url": "/file.pdf",
|
||||
"file_name": "file.pdf",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/pdf",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"updated_at": "2026-04-25T10:00:02Z"
|
||||
}
|
||||
]
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "page.body.save",
|
||||
@@ -538,6 +877,7 @@ mod tests {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
@@ -547,7 +887,7 @@ mod tests {
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
@@ -555,11 +895,276 @@ mod tests {
|
||||
assert_eq!(change.delta, Some(json!({ "op": "noop" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_detects_delta_from_single_new_domain_event() {
|
||||
let overview = json!({
|
||||
"command_logs": [],
|
||||
"domain_events": [
|
||||
{
|
||||
"event_id": "evt_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"payload": {
|
||||
"command_name": "tree.node.rename",
|
||||
"streamDelta": {
|
||||
"op": "upsert_document",
|
||||
"document": {
|
||||
"id": "page_2",
|
||||
"title": "新标题"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#),
|
||||
)
|
||||
.expect("应识别到 domain event delta");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.cursor,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"domain_event:evt_2"}"#.into())
|
||||
);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "upsert_document",
|
||||
"document": {
|
||||
"id": "page_2",
|
||||
"title": "新标题"
|
||||
}
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_falls_back_to_resync_for_unknown_domain_event_payload() {
|
||||
let overview = json!({
|
||||
"command_logs": [],
|
||||
"domain_events": [
|
||||
{
|
||||
"event_id": "evt_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"payload": {
|
||||
"schema": "mnote.tree.domain_event",
|
||||
"schemaVersion": 1,
|
||||
"eventType": "tree.node.unknown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#),
|
||||
)
|
||||
.expect("应识别到未知 domain event 推进");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Resync);
|
||||
assert_eq!(
|
||||
change.cursor,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"domain_event:evt_2"}"#.into())
|
||||
);
|
||||
assert_eq!(change.delta, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_falls_back_to_resync_when_command_and_domain_event_both_advance() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:03Z",
|
||||
"command_name": "tree.node.rename",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "upsert_document",
|
||||
"document": {
|
||||
"id": "page_2",
|
||||
"title": "命令标题"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": [
|
||||
{
|
||||
"event_id": "evt_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "remove_document",
|
||||
"documentId": "page_3"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到混合变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Resync);
|
||||
assert_eq!(change.delta, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_dedupes_matching_command_and_domain_event_delta() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.node.rename",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "upsert_document",
|
||||
"document": {
|
||||
"id": "page_2",
|
||||
"title": "同一标题"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": [
|
||||
{
|
||||
"event_id": "evt_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "upsert_document",
|
||||
"document": {
|
||||
"id": "page_2",
|
||||
"title": "同一标题"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到同命令去重 delta");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "upsert_document",
|
||||
"document": {
|
||||
"id": "page_2",
|
||||
"title": "同一标题"
|
||||
}
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_dedupes_matching_delta_when_previous_cursor_is_command_log() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.subtree.move",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "move_document",
|
||||
"documentId": "page_2",
|
||||
"parentId": "page_1",
|
||||
"sortOrder": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": [
|
||||
{
|
||||
"event_id": "evt_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "move_document",
|
||||
"documentId": "page_2",
|
||||
"parentId": "page_1",
|
||||
"sortOrder": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event_id": "evt_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "noop"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到同命令去重 delta");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "move_document",
|
||||
"documentId": "page_2",
|
||||
"parentId": "page_1",
|
||||
"sortOrder": 2
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_falls_back_to_resync_when_delta_is_unstable() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.subtree.move",
|
||||
@@ -568,6 +1173,7 @@ mod tests {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
@@ -577,7 +1183,7 @@ mod tests {
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
|
||||
use serde_json::{json, Value};
|
||||
use bridge_runtime::{
|
||||
RuntimeBridgeContextWire, RuntimeCommandArtifactPlan, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan, build_runtime_command_artifact_plan,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
const HEADER_REQUEST_ID: &str = "x-request-id";
|
||||
const HEADER_TRACE_ID: &str = "x-trace-id";
|
||||
@@ -154,6 +158,14 @@ fn load_mutation_fixture(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())
|
||||
}
|
||||
|
||||
fn load_mutation_fixture_by_name(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
function_name: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
if !config.allow_dev_fixtures {
|
||||
return Ok(None);
|
||||
@@ -175,7 +187,7 @@ fn load_mutation_fixture(
|
||||
|
||||
Ok(fixtures
|
||||
.as_object()
|
||||
.and_then(|map| map.get(plan.function_name.as_str()))
|
||||
.and_then(|map| map.get(function_name))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
@@ -434,6 +446,253 @@ pub async fn execute_convex_command_plan(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConvexCommandExecution {
|
||||
pub result: Value,
|
||||
pub artifacts: Option<RuntimeCommandArtifactPlan>,
|
||||
pub artifact_error: Option<String>,
|
||||
}
|
||||
|
||||
fn now_iso_like() -> String {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
|
||||
}
|
||||
|
||||
async fn execute_convex_mutation_by_name(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
function_name: &str,
|
||||
args: Value,
|
||||
workspace_id: Option<&str>,
|
||||
idempotency_key: Option<&str>,
|
||||
error_phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
if let Some(fixture) = load_mutation_fixture_by_name(config, context, function_name)? {
|
||||
return Ok(fixture);
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"path": function_name,
|
||||
"format": "convex_encoded_json",
|
||||
"args": [args],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||
.header("Authorization", build_authorization(config, context)?)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Convex-Client", "mnote-web")
|
||||
.header(HEADER_REQUEST_ID, &context.trace.request_id)
|
||||
.header(HEADER_TRACE_ID, &context.trace.trace_id)
|
||||
.header(HEADER_SOURCE_CHANNEL, context.source.channel.as_str())
|
||||
.header(HEADER_SOURCE_CLIENT, context.source.client.as_str())
|
||||
.json(&payload);
|
||||
|
||||
if let Some(workspace_id) = workspace_id
|
||||
.or(context.workspace.workspace_id.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header(HEADER_WORKSPACE_ID, workspace_id);
|
||||
}
|
||||
if let Some(idempotency_key) = idempotency_key
|
||||
.or(context.source.idempotency_key.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header(HEADER_IDEMPOTENCY_KEY, idempotency_key);
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let base = if error.is_timeout() {
|
||||
WebError::gateway_timeout_code(
|
||||
"convex_timeout",
|
||||
format!("Convex mutation 超时: {error}"),
|
||||
)
|
||||
} else {
|
||||
WebError::service_unavailable_code(
|
||||
"convex_unavailable",
|
||||
format!("Convex mutation 请求失败: {error}"),
|
||||
)
|
||||
};
|
||||
base.with_context(context)
|
||||
.with_header("x-error-phase", error_phase)
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"convex_bad_response",
|
||||
format!("Convex mutation 响应解析失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", error_phase)
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
let message = body
|
||||
.get("errorMessage")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Convex mutation 失败");
|
||||
return Err(
|
||||
WebError::bad_gateway_code("convex_upstream_error", message.to_string())
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", error_phase)
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
match body.get("status").and_then(Value::as_str) {
|
||||
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
|
||||
Some("error") => Err(WebError::bad_gateway_code(
|
||||
"convex_upstream_error",
|
||||
body.get("errorMessage")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Convex 返回 error")
|
||||
.to_string(),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", error_phase)
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||
_ => Err(WebError::bad_gateway_code(
|
||||
"convex_bad_response",
|
||||
format!("未知 Convex 响应: {body}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", error_phase)
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_log_artifact_args(artifact: &bridge_runtime::RuntimeCommandLogArtifactPlan) -> Value {
|
||||
json!({
|
||||
"workspaceId": artifact.workspace_id,
|
||||
"id": artifact.id,
|
||||
"requestId": artifact.request_id,
|
||||
"traceId": artifact.trace_id,
|
||||
"commandId": artifact.command_id,
|
||||
"commandName": artifact.command_name,
|
||||
"actorId": artifact.actor_id,
|
||||
"actorType": artifact.actor_type,
|
||||
"sourceChannel": artifact.source_channel,
|
||||
"sourceClient": artifact.source_client,
|
||||
"status": artifact.status,
|
||||
"targetPageId": artifact.target_page_id,
|
||||
"targetBlockId": artifact.target_block_id,
|
||||
"payload": artifact.payload,
|
||||
"payloadSummary": artifact.payload_summary,
|
||||
"refs": artifact.refs,
|
||||
"idempotencyKey": artifact.idempotency_key,
|
||||
"error": artifact.error,
|
||||
"createdAt": artifact.created_at,
|
||||
"finishedAt": artifact.finished_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn domain_event_artifact_args(artifact: &bridge_runtime::RuntimeDomainEventArtifactPlan) -> Value {
|
||||
json!({
|
||||
"workspaceId": artifact.workspace_id,
|
||||
"id": artifact.id,
|
||||
"requestId": artifact.request_id,
|
||||
"traceId": artifact.trace_id,
|
||||
"commandId": artifact.command_id,
|
||||
"commandLogId": artifact.command_log_id,
|
||||
"eventType": artifact.event_type,
|
||||
"aggregateType": artifact.aggregate_type,
|
||||
"aggregateId": artifact.aggregate_id,
|
||||
"eventVersion": artifact.event_version,
|
||||
"status": artifact.status,
|
||||
"actorType": artifact.actor_type,
|
||||
"payload": artifact.payload,
|
||||
"createdAt": artifact.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn persist_runtime_command_artifacts(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
artifacts: &RuntimeCommandArtifactPlan,
|
||||
) -> Result<(), WebError> {
|
||||
execute_convex_mutation_by_name(
|
||||
config,
|
||||
context,
|
||||
"bridgeLogs:recordCommandLog",
|
||||
command_log_artifact_args(&artifacts.command_log),
|
||||
Some(artifacts.command_log.workspace_id.as_str()),
|
||||
artifacts.command_log.idempotency_key.as_deref(),
|
||||
"artifact_command_log",
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(domain_event) = artifacts.domain_event.as_ref() {
|
||||
execute_convex_mutation_by_name(
|
||||
config,
|
||||
context,
|
||||
"bridgeLogs:recordDomainEvent",
|
||||
domain_event_artifact_args(domain_event),
|
||||
Some(domain_event.workspace_id.as_str()),
|
||||
artifacts.command_log.idempotency_key.as_deref(),
|
||||
"artifact_domain_event",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_convex_command_plan_with_artifacts(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
runtime_context: &RuntimeBridgeContextWire,
|
||||
command: &RuntimeCommandEnvelopeWire,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<ConvexCommandExecution, WebError> {
|
||||
let result = execute_convex_command_plan(config, context, plan).await?;
|
||||
let artifacts = build_runtime_command_artifact_plan(
|
||||
runtime_context,
|
||||
command,
|
||||
plan,
|
||||
&result,
|
||||
&now_iso_like(),
|
||||
);
|
||||
if let Some(artifacts) = artifacts.as_ref() {
|
||||
if let Err(error) = persist_runtime_command_artifacts(config, context, artifacts).await {
|
||||
let message = error.message().to_string();
|
||||
tracing::warn!(
|
||||
error = %message,
|
||||
command_id = %command.command_id,
|
||||
"Rust command artifact 持久化失败,主 mutation 结果继续返回"
|
||||
);
|
||||
return Ok(ConvexCommandExecution {
|
||||
result,
|
||||
artifacts: Some(artifacts.clone()),
|
||||
artifact_error: Some(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ConvexCommandExecution {
|
||||
result,
|
||||
artifacts,
|
||||
artifact_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_authorization;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TreeShellAction {
|
||||
Open,
|
||||
CreateChild,
|
||||
Rename,
|
||||
Move,
|
||||
ContextMenu,
|
||||
Pick,
|
||||
AssetOpen,
|
||||
ResourceCopy,
|
||||
ResourceMove,
|
||||
ResourceUpload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellActionRegistry {
|
||||
pub actions: BTreeSet<TreeShellAction>,
|
||||
}
|
||||
|
||||
impl TreeShellActionRegistry {
|
||||
pub fn page_tree() -> Self {
|
||||
Self {
|
||||
actions: BTreeSet::from([
|
||||
TreeShellAction::Open,
|
||||
TreeShellAction::CreateChild,
|
||||
TreeShellAction::Rename,
|
||||
TreeShellAction::Move,
|
||||
TreeShellAction::ContextMenu,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_tree() -> Self {
|
||||
Self {
|
||||
actions: BTreeSet::from([
|
||||
TreeShellAction::Open,
|
||||
TreeShellAction::ContextMenu,
|
||||
TreeShellAction::AssetOpen,
|
||||
TreeShellAction::ResourceCopy,
|
||||
TreeShellAction::ResourceMove,
|
||||
TreeShellAction::ResourceUpload,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn picker() -> Self {
|
||||
Self {
|
||||
actions: BTreeSet::from([TreeShellAction::Pick]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, action: TreeShellAction) -> bool {
|
||||
self.actions.contains(&action)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TreeShellAction, TreeShellActionRegistry};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_action_registry_separates_page_filetree_and_picker_actions() {
|
||||
let page = TreeShellActionRegistry::page_tree();
|
||||
assert!(page.allows(TreeShellAction::CreateChild));
|
||||
assert!(!page.allows(TreeShellAction::ResourceUpload));
|
||||
|
||||
let filetree = TreeShellActionRegistry::file_tree();
|
||||
assert!(filetree.allows(TreeShellAction::ResourceMove));
|
||||
assert!(filetree.allows(TreeShellAction::AssetOpen));
|
||||
assert!(!filetree.allows(TreeShellAction::Rename));
|
||||
|
||||
let picker = TreeShellActionRegistry::picker();
|
||||
assert!(picker.allows(TreeShellAction::Pick));
|
||||
assert!(!picker.allows(TreeShellAction::Open));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TreeShellDragEffect {
|
||||
Copy,
|
||||
Move,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TreeShellDragPayload {
|
||||
pub row_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellDropTarget {
|
||||
pub row_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn resolve_drag_effect(has_external_files: bool, alt_key: bool) -> TreeShellDragEffect {
|
||||
if has_external_files || alt_key {
|
||||
TreeShellDragEffect::Copy
|
||||
} else {
|
||||
TreeShellDragEffect::Move
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_drag_payload(row_ids: &[String]) -> Option<TreeShellDragPayload> {
|
||||
let mut normalized = Vec::new();
|
||||
for row_id in row_ids {
|
||||
let row_id = row_id.trim();
|
||||
if row_id.is_empty() || normalized.iter().any(|existing| existing == row_id) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(row_id.to_string());
|
||||
}
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(TreeShellDragPayload {
|
||||
row_ids: normalized,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_drag_payload, resolve_drag_effect, TreeShellDragEffect};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_drag_drop_state_normalizes_payload_and_effect() {
|
||||
let payload = normalize_drag_payload(&ids(&[" doc:a ", "asset:b", "doc:a", ""])).unwrap();
|
||||
assert_eq!(payload.row_ids, ids(&["doc:a", "asset:b"]));
|
||||
assert_eq!(resolve_drag_effect(true, false), TreeShellDragEffect::Copy);
|
||||
assert_eq!(resolve_drag_effect(false, true), TreeShellDragEffect::Copy);
|
||||
assert_eq!(resolve_drag_effect(false, false), TreeShellDragEffect::Move);
|
||||
assert!(normalize_drag_payload(&ids(&["", " "])).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellExpansionState {
|
||||
pub expanded_ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl TreeShellExpansionState {
|
||||
pub fn from_defaults(default_expanded_ids: &[String]) -> Self {
|
||||
Self {
|
||||
expanded_ids: default_expanded_ids.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self, node_id: &str) -> Self {
|
||||
let mut expanded_ids = self.expanded_ids.clone();
|
||||
if !expanded_ids.insert(node_id.to_string()) {
|
||||
expanded_ids.remove(node_id);
|
||||
}
|
||||
Self { expanded_ids }
|
||||
}
|
||||
|
||||
pub fn expand_ancestors(
|
||||
&self,
|
||||
node_id: &str,
|
||||
parent_by_id: &BTreeMap<String, Option<String>>,
|
||||
) -> Self {
|
||||
let mut expanded_ids = self.expanded_ids.clone();
|
||||
let mut current = parent_by_id.get(node_id).and_then(Clone::clone);
|
||||
while let Some(parent_id) = current {
|
||||
expanded_ids.insert(parent_id.clone());
|
||||
current = parent_by_id.get(&parent_id).and_then(Clone::clone);
|
||||
}
|
||||
Self { expanded_ids }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TreeShellExpansionState;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_expansion_state_toggles_and_expands_active_ancestors() {
|
||||
let state = TreeShellExpansionState::from_defaults(&ids(&["doc:root"]));
|
||||
assert!(state.expanded_ids.contains("doc:root"));
|
||||
|
||||
let state = state.toggle("doc:root");
|
||||
assert!(!state.expanded_ids.contains("doc:root"));
|
||||
|
||||
let parent_by_id = BTreeMap::from([
|
||||
("doc:root".into(), None),
|
||||
("doc:child".into(), Some("doc:root".into())),
|
||||
("doc:leaf".into(), Some("doc:child".into())),
|
||||
]);
|
||||
let state = state.expand_ancestors("doc:leaf", &parent_by_id);
|
||||
assert_eq!(
|
||||
state.expanded_ids,
|
||||
BTreeSet::from(["doc:root".into(), "doc:child".into()])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
use super::protocol;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeRenderRow {
|
||||
pub row_id: String,
|
||||
pub row_kind: String,
|
||||
pub node_id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub title: String,
|
||||
pub depth: u32,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
pub icon_kind: String,
|
||||
pub document_id: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeInitialRenderInput {
|
||||
pub rows: Vec<FileTreeRenderRow>,
|
||||
}
|
||||
|
||||
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
|
||||
@@ -19,3 +34,139 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn row_test_id(row_kind: &str) -> &'static str {
|
||||
match row_kind {
|
||||
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
|
||||
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
|
||||
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_filetree_row(
|
||||
html: &mut String,
|
||||
row: &FileTreeRenderRow,
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<FileTreeRenderRow>>,
|
||||
) {
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}" data-document-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
test_id = row_test_id(&row.row_kind),
|
||||
row_id = escape_html(&row.row_id),
|
||||
row_kind = escape_html(&row.row_kind),
|
||||
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||
selected = row.selected,
|
||||
icon_kind = escape_html(&row.icon_kind),
|
||||
title = escape_html(&row.title),
|
||||
));
|
||||
if row.expandable && row.expanded {
|
||||
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||
html.push_str(r#"<ul class="tree-children">"#);
|
||||
for child in children {
|
||||
render_filetree_row(html, child, children_by_parent);
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
}
|
||||
}
|
||||
html.push_str("</li>");
|
||||
}
|
||||
|
||||
pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String {
|
||||
let mut html = String::from(
|
||||
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
|
||||
);
|
||||
if input.rows.is_empty() {
|
||||
html.push_str(
|
||||
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">当前 file tree 没有可渲染的页面。</li>"#,
|
||||
);
|
||||
html.push_str("</ul>");
|
||||
return html;
|
||||
}
|
||||
|
||||
let ids = input
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.node_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut children_by_parent = BTreeMap::<Option<String>, Vec<FileTreeRenderRow>>::new();
|
||||
for row in &input.rows {
|
||||
let parent_id = row
|
||||
.parent_node_id
|
||||
.as_ref()
|
||||
.filter(|parent_id| ids.contains(*parent_id))
|
||||
.cloned();
|
||||
children_by_parent
|
||||
.entry(parent_id)
|
||||
.or_default()
|
||||
.push(row.clone());
|
||||
}
|
||||
|
||||
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
||||
for root in &roots {
|
||||
render_filetree_row(&mut html, root, &children_by_parent);
|
||||
}
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
html
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_renderer_outputs_initial_nested_html_contract() {
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows: vec![
|
||||
FileTreeRenderRow {
|
||||
row_id: "doc:page_root".into(),
|
||||
row_kind: "document".into(),
|
||||
node_id: "page_root".into(),
|
||||
parent_node_id: None,
|
||||
title: "首页 <安全>".into(),
|
||||
depth: 0,
|
||||
expandable: true,
|
||||
expanded: true,
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
selected: true,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
row_id: "index:page_root".into(),
|
||||
row_kind: "index".into(),
|
||||
node_id: "index:page_root".into(),
|
||||
parent_node_id: Some("page_root".into()),
|
||||
title: "index.md".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
icon_kind: "index".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
|
||||
assert!(html.contains("data-testid=\"filetree-doc-row\""));
|
||||
assert!(html.contains("data-testid=\"filetree-index-row\""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeSelectionReducerContract {
|
||||
pub contract_name: &'static str,
|
||||
pub actions: BTreeSet<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for FileTreeSelectionReducerContract {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contract_name: FILETREE_SELECTION_REDUCER_CONTRACT_NAME,
|
||||
actions: BTreeSet::from([
|
||||
"select_row",
|
||||
"select_context_row",
|
||||
"normalize_visible_rows",
|
||||
"clear",
|
||||
"resolve_drag_rows",
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeSelectionModifiers {
|
||||
pub shift_key: bool,
|
||||
pub ctrl_key: bool,
|
||||
pub meta_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeSelectionState {
|
||||
pub selected_row_ids: BTreeSet<String>,
|
||||
pub anchor_row_id: Option<String>,
|
||||
pub focused_row_id: Option<String>,
|
||||
}
|
||||
|
||||
impl FileTreeSelectionState {
|
||||
pub fn from_selected(row_ids: &[String]) -> Self {
|
||||
Self {
|
||||
selected_row_ids: row_ids.iter().cloned().collect(),
|
||||
anchor_row_id: row_ids.first().cloned(),
|
||||
focused_row_id: row_ids.first().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_row(
|
||||
&self,
|
||||
row_id: &str,
|
||||
visible_row_ids: &[String],
|
||||
modifiers: FileTreeSelectionModifiers,
|
||||
) -> Self {
|
||||
let toggle_selection = modifiers.ctrl_key || modifiers.meta_key;
|
||||
|
||||
if modifiers.shift_key {
|
||||
let anchor = self
|
||||
.anchor_row_id
|
||||
.clone()
|
||||
.or_else(|| self.focused_row_id.clone())
|
||||
.unwrap_or_else(|| row_id.to_string());
|
||||
let mut selected_row_ids = if toggle_selection {
|
||||
self.selected_row_ids.clone()
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
for range_row_id in range_row_ids(visible_row_ids, &anchor, row_id) {
|
||||
selected_row_ids.insert(range_row_id);
|
||||
}
|
||||
return Self {
|
||||
selected_row_ids,
|
||||
anchor_row_id: self.anchor_row_id.clone().or(Some(anchor)),
|
||||
focused_row_id: Some(row_id.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
if toggle_selection {
|
||||
let mut selected_row_ids = self.selected_row_ids.clone();
|
||||
if selected_row_ids.contains(row_id) {
|
||||
selected_row_ids.remove(row_id);
|
||||
} else {
|
||||
selected_row_ids.insert(row_id.to_string());
|
||||
}
|
||||
return Self {
|
||||
selected_row_ids,
|
||||
anchor_row_id: Some(row_id.to_string()),
|
||||
focused_row_id: Some(row_id.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
Self {
|
||||
selected_row_ids: BTreeSet::from([row_id.to_string()]),
|
||||
anchor_row_id: Some(row_id.to_string()),
|
||||
focused_row_id: Some(row_id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_context_row(&self, row_id: &str) -> Self {
|
||||
if self.selected_row_ids.contains(row_id) {
|
||||
return Self {
|
||||
selected_row_ids: self.selected_row_ids.clone(),
|
||||
anchor_row_id: self.anchor_row_id.clone(),
|
||||
focused_row_id: Some(row_id.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
Self {
|
||||
selected_row_ids: BTreeSet::from([row_id.to_string()]),
|
||||
anchor_row_id: Some(row_id.to_string()),
|
||||
focused_row_id: Some(row_id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_for_visible_rows(&self, visible_row_ids: &[String]) -> Self {
|
||||
let visible = visible_row_ids.iter().collect::<BTreeSet<_>>();
|
||||
Self {
|
||||
selected_row_ids: self
|
||||
.selected_row_ids
|
||||
.iter()
|
||||
.filter(|row_id| visible.contains(row_id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
anchor_row_id: self
|
||||
.anchor_row_id
|
||||
.as_ref()
|
||||
.filter(|row_id| visible.contains(row_id))
|
||||
.cloned(),
|
||||
focused_row_id: self
|
||||
.focused_row_id
|
||||
.as_ref()
|
||||
.filter(|row_id| visible.contains(row_id))
|
||||
.cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn resolve_drag_row_ids(&self, row_id: &str) -> Vec<String> {
|
||||
if self.selected_row_ids.contains(row_id) {
|
||||
return self.selected_row_ids.iter().cloned().collect();
|
||||
}
|
||||
vec![row_id.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> {
|
||||
let from_index = visible_row_ids.iter().position(|row_id| row_id == from_id);
|
||||
let to_index = visible_row_ids.iter().position(|row_id| row_id == to_id);
|
||||
let (Some(from_index), Some(to_index)) = (from_index, to_index) else {
|
||||
return vec![to_id.to_string()];
|
||||
};
|
||||
let low = from_index.min(to_index);
|
||||
let high = from_index.max(to_index);
|
||||
visible_row_ids[low..=high].to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FileTreeSelectionModifiers, FileTreeSelectionReducerContract, FileTreeSelectionState,
|
||||
FILETREE_SELECTION_REDUCER_CONTRACT_NAME,
|
||||
};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_selection_click_toggle_and_range_follow_contract() {
|
||||
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(),
|
||||
);
|
||||
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"));
|
||||
|
||||
state = state.select_row(
|
||||
"asset:d",
|
||||
&visible,
|
||||
FileTreeSelectionModifiers {
|
||||
shift_key: true,
|
||||
..FileTreeSelectionModifiers::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
state.selected_row_ids,
|
||||
ids(&["doc:b", "asset:c", "asset:d"]).into_iter().collect()
|
||||
);
|
||||
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
|
||||
assert_eq!(state.focused_row_id.as_deref(), Some("asset:d"));
|
||||
|
||||
state = state.select_row(
|
||||
"doc:a",
|
||||
&visible,
|
||||
FileTreeSelectionModifiers {
|
||||
ctrl_key: true,
|
||||
..FileTreeSelectionModifiers::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
state.selected_row_ids,
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_selection_context_clear_normalize_and_drag_rows_are_stable() {
|
||||
let visible = ids(&["doc:a", "doc:b", "asset:c"]);
|
||||
let mut state = FileTreeSelectionState::from_selected(&ids(&["doc:a", "asset:missing"]));
|
||||
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.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.anchor_row_id.as_deref(), Some("asset:c"));
|
||||
assert_eq!(state.focused_row_id.as_deref(), Some("asset:c"));
|
||||
|
||||
assert_eq!(state.resolve_drag_row_ids("asset:c"), ids(&["asset:c"]));
|
||||
assert_eq!(state.resolve_drag_row_ids("doc:b"), ids(&["doc:b"]));
|
||||
|
||||
state = state.clear();
|
||||
assert!(state.selected_row_ids.is_empty());
|
||||
assert_eq!(state.anchor_row_id, None);
|
||||
assert_eq!(state.focused_row_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_selection_reducer_contract_exposes_supported_actions() {
|
||||
let contract = FileTreeSelectionReducerContract::default();
|
||||
assert_eq!(
|
||||
contract.contract_name,
|
||||
FILETREE_SELECTION_REDUCER_CONTRACT_NAME
|
||||
);
|
||||
assert!(contract.actions.contains("select_row"));
|
||||
assert!(contract.actions.contains("select_context_row"));
|
||||
assert!(contract.actions.contains("normalize_visible_rows"));
|
||||
assert!(contract.actions.contains("clear"));
|
||||
assert!(contract.actions.contains("resolve_drag_rows"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
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")]
|
||||
pub struct PageFocusKeyboardReducerContract {
|
||||
pub contract_name: &'static str,
|
||||
pub actions: BTreeSet<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for PageFocusKeyboardReducerContract {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contract_name: PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME,
|
||||
actions: BTreeSet::from([
|
||||
"normalize",
|
||||
"focus",
|
||||
"move_next",
|
||||
"move_previous",
|
||||
"move_home",
|
||||
"move_end",
|
||||
"expand",
|
||||
"collapse",
|
||||
"open",
|
||||
"context_menu",
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellFocusState {
|
||||
pub focused_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TreeShellFocusState {
|
||||
pub fn normalize(&self, visible_ids: &[String]) -> Self {
|
||||
if let Some(focused_id) = self.focused_id.as_deref() {
|
||||
if visible_ids.iter().any(|id| id == focused_id) {
|
||||
return self.clone();
|
||||
}
|
||||
}
|
||||
Self {
|
||||
focused_id: visible_ids.first().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_next(&self, visible_ids: &[String]) -> Self {
|
||||
self.move_by(visible_ids, 1)
|
||||
}
|
||||
|
||||
pub fn move_previous(&self, visible_ids: &[String]) -> Self {
|
||||
self.move_by(visible_ids, -1)
|
||||
}
|
||||
|
||||
pub fn move_home(&self, visible_ids: &[String]) -> Self {
|
||||
Self {
|
||||
focused_id: visible_ids.first().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_end(&self, visible_ids: &[String]) -> Self {
|
||||
Self {
|
||||
focused_id: visible_ids.last().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_by(&self, visible_ids: &[String], offset: isize) -> Self {
|
||||
if visible_ids.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let current_index = self
|
||||
.focused_id
|
||||
.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;
|
||||
Self {
|
||||
focused_id: Some(visible_ids[next_index].clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
PageFocusKeyboardReducerContract, TreeShellFocusState,
|
||||
PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME,
|
||||
};
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_focus_state_normalizes_and_moves_within_visible_rows() {
|
||||
let visible_ids = ids(&["doc:a", "doc:b", "doc:c"]);
|
||||
let state = TreeShellFocusState {
|
||||
focused_id: Some("missing".into()),
|
||||
}
|
||||
.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);
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_focus_state_empty_rows_clear_focus() {
|
||||
let state = TreeShellFocusState {
|
||||
focused_id: Some("doc:a".into()),
|
||||
}
|
||||
.normalize(&[]);
|
||||
assert_eq!(state.focused_id, None);
|
||||
assert_eq!(state.move_next(&[]).focused_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_page_focus_keyboard_reducer_contract_exposes_supported_actions() {
|
||||
let contract = PageFocusKeyboardReducerContract::default();
|
||||
assert_eq!(
|
||||
contract.contract_name,
|
||||
PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME
|
||||
);
|
||||
assert!(contract.actions.contains("focus"));
|
||||
assert!(contract.actions.contains("move_next"));
|
||||
assert!(contract.actions.contains("move_previous"));
|
||||
assert!(contract.actions.contains("move_home"));
|
||||
assert!(contract.actions.contains("move_end"));
|
||||
assert!(contract.actions.contains("expand"));
|
||||
assert!(contract.actions.contains("collapse"));
|
||||
assert!(contract.actions.contains("open"));
|
||||
assert!(contract.actions.contains("context_menu"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TreeShellKeyboardIntent {
|
||||
MoveNext,
|
||||
MovePrevious,
|
||||
MoveHome,
|
||||
MoveEnd,
|
||||
Expand,
|
||||
Collapse,
|
||||
Open,
|
||||
ContextMenu,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellKeyboardModifiers {
|
||||
pub shift_key: bool,
|
||||
pub ctrl_key: bool,
|
||||
pub meta_key: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_tree_shell_keyboard_intent(
|
||||
key: &str,
|
||||
modifiers: TreeShellKeyboardModifiers,
|
||||
) -> TreeShellKeyboardIntent {
|
||||
match key {
|
||||
"ArrowDown" => TreeShellKeyboardIntent::MoveNext,
|
||||
"ArrowUp" => TreeShellKeyboardIntent::MovePrevious,
|
||||
"Home" => TreeShellKeyboardIntent::MoveHome,
|
||||
"End" => TreeShellKeyboardIntent::MoveEnd,
|
||||
"ArrowRight" => TreeShellKeyboardIntent::Expand,
|
||||
"ArrowLeft" => TreeShellKeyboardIntent::Collapse,
|
||||
"Enter" => TreeShellKeyboardIntent::Open,
|
||||
"ContextMenu" => TreeShellKeyboardIntent::ContextMenu,
|
||||
"F10" if modifiers.shift_key && !modifiers.ctrl_key && !modifiers.meta_key => {
|
||||
TreeShellKeyboardIntent::ContextMenu
|
||||
}
|
||||
_ => TreeShellKeyboardIntent::None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
resolve_tree_shell_keyboard_intent, TreeShellKeyboardIntent, TreeShellKeyboardModifiers,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_keyboard_state_maps_navigation_open_and_context_menu_intents() {
|
||||
assert_eq!(
|
||||
resolve_tree_shell_keyboard_intent("ArrowDown", TreeShellKeyboardModifiers::default()),
|
||||
TreeShellKeyboardIntent::MoveNext
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_tree_shell_keyboard_intent("ArrowUp", TreeShellKeyboardModifiers::default()),
|
||||
TreeShellKeyboardIntent::MovePrevious
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_tree_shell_keyboard_intent("Home", TreeShellKeyboardModifiers::default()),
|
||||
TreeShellKeyboardIntent::MoveHome
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_tree_shell_keyboard_intent("End", TreeShellKeyboardModifiers::default()),
|
||||
TreeShellKeyboardIntent::MoveEnd
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_tree_shell_keyboard_intent("Enter", TreeShellKeyboardModifiers::default()),
|
||||
TreeShellKeyboardIntent::Open
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_tree_shell_keyboard_intent(
|
||||
"F10",
|
||||
TreeShellKeyboardModifiers {
|
||||
shift_key: true,
|
||||
..TreeShellKeyboardModifiers::default()
|
||||
},
|
||||
),
|
||||
TreeShellKeyboardIntent::ContextMenu
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
pub mod action_registry;
|
||||
pub mod drag_drop_state;
|
||||
pub mod dispatcher;
|
||||
pub mod expansion_state;
|
||||
pub mod filetree_renderer;
|
||||
pub mod filetree_selection;
|
||||
pub mod focus_state;
|
||||
pub mod keyboard_state;
|
||||
pub mod loader;
|
||||
pub mod page_renderer;
|
||||
pub mod picker_renderer;
|
||||
pub mod picker_state;
|
||||
pub mod protocol;
|
||||
pub mod renderer_input;
|
||||
pub mod state;
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use super::protocol;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeRenderRow {
|
||||
pub node_id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub title: String,
|
||||
pub depth: u32,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -20,6 +23,13 @@ pub struct PageTreeDomRow {
|
||||
pub expandable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeInitialRenderInput {
|
||||
pub rows: Vec<PageTreeRenderRow>,
|
||||
pub active_node_id: Option<String>,
|
||||
pub focused_node_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
|
||||
rows.iter()
|
||||
.map(|row| PageTreeDomRow {
|
||||
@@ -35,24 +45,127 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRo
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn render_page_row(
|
||||
html: &mut String,
|
||||
row: &PageTreeRenderRow,
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<PageTreeRenderRow>>,
|
||||
input: &PageTreeInitialRenderInput,
|
||||
) {
|
||||
let dom_rows = build_page_tree_dom_rows(&[row.clone()]);
|
||||
if let Some(row) = dom_rows.first() {
|
||||
let active = input
|
||||
.active_node_id
|
||||
.as_deref()
|
||||
.map(|active_node_id| active_node_id == row.node_id)
|
||||
.unwrap_or(false);
|
||||
let focused = input
|
||||
.focused_node_id
|
||||
.as_deref()
|
||||
.map(|focused_node_id| focused_node_id == row.node_id)
|
||||
.unwrap_or(false);
|
||||
let expanded = input
|
||||
.rows
|
||||
.iter()
|
||||
.find(|source| source.node_id == row.node_id)
|
||||
.map(|source| source.expanded)
|
||||
.unwrap_or(false);
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
|
||||
test_id = row.test_id,
|
||||
active = active,
|
||||
focused = focused,
|
||||
tab_index = if focused { "0" } else { "-1" },
|
||||
title = escape_html(&row.title),
|
||||
));
|
||||
if row.expandable && expanded {
|
||||
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||
html.push_str(r#"<ul class="tree-children">"#);
|
||||
for child in children {
|
||||
render_page_row(html, child, children_by_parent, input);
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
}
|
||||
}
|
||||
html.push_str("</li>");
|
||||
}
|
||||
}
|
||||
|
||||
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">"#,
|
||||
);
|
||||
if input.rows.is_empty() {
|
||||
html.push_str(
|
||||
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
|
||||
);
|
||||
html.push_str("</ul>");
|
||||
return html;
|
||||
}
|
||||
|
||||
let ids = input
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.node_id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PageTreeRenderRow>>::new();
|
||||
for row in &input.rows {
|
||||
let parent_id = row
|
||||
.parent_node_id
|
||||
.as_ref()
|
||||
.filter(|parent_id| ids.contains(*parent_id))
|
||||
.cloned();
|
||||
children_by_parent
|
||||
.entry(parent_id)
|
||||
.or_default()
|
||||
.push(row.clone());
|
||||
}
|
||||
|
||||
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
||||
for root in &roots {
|
||||
render_page_row(&mut html, root, &children_by_parent, input);
|
||||
}
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
html
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_page_tree_dom_rows, PageTreeRenderRow};
|
||||
use super::{
|
||||
build_page_tree_dom_rows, render_initial_page_tree_html, PageTreeInitialRenderInput,
|
||||
PageTreeRenderRow,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
|
||||
let rows = build_page_tree_dom_rows(&[
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
parent_node_id: None,
|
||||
title: "首页".into(),
|
||||
depth: 0,
|
||||
expandable: true,
|
||||
expanded: false,
|
||||
},
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_child".into(),
|
||||
parent_node_id: Some("page_root".into()),
|
||||
title: "子页".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -63,4 +176,42 @@ mod tests {
|
||||
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
|
||||
assert_eq!(rows[1].depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_page_renderer_outputs_initial_html_contract() {
|
||||
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
rows: vec![
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
parent_node_id: None,
|
||||
title: "首页 <安全>".into(),
|
||||
depth: 0,
|
||||
expandable: true,
|
||||
expanded: true,
|
||||
},
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_child".into(),
|
||||
parent_node_id: Some("page_root".into()),
|
||||
title: "子页".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
},
|
||||
],
|
||||
active_node_id: Some("page_root".into()),
|
||||
focused_node_id: Some("page_root".into()),
|
||||
});
|
||||
|
||||
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"page\""));
|
||||
assert!(html.contains("data-shell-mode=\"page\""));
|
||||
assert!(html.contains("data-rust-action=\"open\""));
|
||||
assert!(html.contains("data-rust-action=\"create\""));
|
||||
assert!(html.contains("draggable=\"true\""));
|
||||
assert!(html.contains("tree-children"));
|
||||
assert!(html.contains("子页"));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-active=\"true\""));
|
||||
assert!(html.contains("data-focused=\"true\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use super::protocol;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerRenderRow {
|
||||
pub node_id: String,
|
||||
pub parent_node_id: Option<String>,
|
||||
pub title: String,
|
||||
pub depth: u32,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -13,6 +19,13 @@ pub struct PickerRenderResult {
|
||||
pub allow_root_pick: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerInitialRenderInput {
|
||||
pub rows: Vec<PickerRenderRow>,
|
||||
pub allow_root_pick: bool,
|
||||
pub root_active: bool,
|
||||
}
|
||||
|
||||
pub fn build_picker_render_result(
|
||||
rows: &[PickerRenderRow],
|
||||
allow_root_pick: bool,
|
||||
@@ -24,10 +37,84 @@ pub fn build_picker_render_result(
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn render_picker_row(
|
||||
html: &mut String,
|
||||
row: &PickerRenderRow,
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
|
||||
) {
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||
active = row.active,
|
||||
title = escape_html(&row.title),
|
||||
));
|
||||
if row.expandable && row.expanded {
|
||||
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||
html.push_str(r#"<ul class="tree-children">"#);
|
||||
for child in children {
|
||||
render_picker_row(html, child, children_by_parent);
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
}
|
||||
}
|
||||
html.push_str("</li>");
|
||||
}
|
||||
|
||||
pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
|
||||
let mut html = String::from(
|
||||
r#"<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">"#,
|
||||
);
|
||||
if input.allow_root_pick {
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
||||
focused = input.root_active,
|
||||
));
|
||||
}
|
||||
|
||||
let ids = input
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.node_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PickerRenderRow>>::new();
|
||||
for row in &input.rows {
|
||||
let parent_id = row
|
||||
.parent_node_id
|
||||
.as_ref()
|
||||
.filter(|parent_id| ids.contains(*parent_id))
|
||||
.cloned();
|
||||
children_by_parent
|
||||
.entry(parent_id)
|
||||
.or_default()
|
||||
.push(row.clone());
|
||||
}
|
||||
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
||||
for root in &roots {
|
||||
render_picker_row(&mut html, root, &children_by_parent);
|
||||
}
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
html
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
|
||||
use super::{build_picker_render_result, PickerRenderRow};
|
||||
use super::{
|
||||
build_picker_render_result, render_initial_picker_html, PickerInitialRenderInput,
|
||||
PickerRenderRow,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
|
||||
@@ -35,18 +122,41 @@ mod tests {
|
||||
FileTreeRenderRow {
|
||||
row_id: "doc:page_root".into(),
|
||||
row_kind: "document".into(),
|
||||
node_id: "page_root".into(),
|
||||
parent_node_id: None,
|
||||
title: "首页".into(),
|
||||
depth: 0,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
icon_kind: "page".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: None,
|
||||
selected: false,
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
row_id: "asset:asset_1".into(),
|
||||
row_kind: "asset".into(),
|
||||
node_id: "asset:asset_1".into(),
|
||||
parent_node_id: Some("page_root".into()),
|
||||
title: "附件".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
icon_kind: "file".into(),
|
||||
document_id: Some("page_root".into()),
|
||||
asset_id: Some("asset_1".into()),
|
||||
selected: false,
|
||||
},
|
||||
]);
|
||||
let picker = build_picker_render_result(
|
||||
&[PickerRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
parent_node_id: None,
|
||||
title: "首页".into(),
|
||||
depth: 0,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
active: false,
|
||||
}],
|
||||
true,
|
||||
);
|
||||
@@ -56,4 +166,29 @@ mod tests {
|
||||
assert_eq!(picker.root_test_id, "tree-picker-root");
|
||||
assert!(picker.allow_root_pick);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_picker_renderer_outputs_initial_html_contract() {
|
||||
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,
|
||||
}],
|
||||
});
|
||||
|
||||
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
|
||||
assert!(html.contains("data-rust-rendered-row=\"picker\""));
|
||||
assert!(html.contains("data-testid=\"tree-picker-root\""));
|
||||
assert!(html.contains("data-testid=\"tree-picker-row\""));
|
||||
assert!(html.contains("首页 <安全>"));
|
||||
assert!(html.contains("data-focused=\"true\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub const PICKER_STATE_REDUCER_CONTRACT_NAME: &str = "rust_picker_state_reducer_v1";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PickerStateReducerContract {
|
||||
pub contract_name: &'static str,
|
||||
pub actions: BTreeSet<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for PickerStateReducerContract {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contract_name: PICKER_STATE_REDUCER_CONTRACT_NAME,
|
||||
actions: BTreeSet::from([
|
||||
"normalize",
|
||||
"focus",
|
||||
"next",
|
||||
"previous",
|
||||
"home",
|
||||
"end",
|
||||
"pick",
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerItem {
|
||||
pub item_key: String,
|
||||
pub document_id: Option<String>,
|
||||
pub pickable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PickerState {
|
||||
pub active_item_key: Option<String>,
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
pub fn normalize(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||
if let Some(active_item_key) = self.active_item_key.as_deref() {
|
||||
if is_pickable_item_key(items, excluded_ids, active_item_key) {
|
||||
return self.clone();
|
||||
}
|
||||
}
|
||||
Self {
|
||||
active_item_key: first_pickable_item_key(items, excluded_ids),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_next(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||
self.move_by(items, excluded_ids, 1)
|
||||
}
|
||||
|
||||
pub fn move_previous(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||
self.move_by(items, excluded_ids, -1)
|
||||
}
|
||||
|
||||
pub fn move_home(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||
Self {
|
||||
active_item_key: first_pickable_item_key(items, excluded_ids),
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pick(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
|
||||
let active = self.active_item_key.as_deref()?;
|
||||
pickable_items(items, excluded_ids)
|
||||
.find(|item| item.item_key == active)
|
||||
.and_then(|item| item.document_id.clone())
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
let current_index = self
|
||||
.active_item_key
|
||||
.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;
|
||||
Self {
|
||||
active_item_key: Some(pickable[next_index].item_key.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_item_excluded(item: &PickerItem, excluded_ids: &BTreeSet<String>) -> bool {
|
||||
item.document_id
|
||||
.as_ref()
|
||||
.is_some_and(|document_id| excluded_ids.contains(document_id))
|
||||
}
|
||||
|
||||
fn pickable_items<'a>(
|
||||
items: &'a [PickerItem],
|
||||
excluded_ids: &'a BTreeSet<String>,
|
||||
) -> impl DoubleEndedIterator<Item = &'a PickerItem> + 'a {
|
||||
items
|
||||
.iter()
|
||||
.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 is_pickable_item_key(
|
||||
items: &[PickerItem],
|
||||
excluded_ids: &BTreeSet<String>,
|
||||
item_key: &str,
|
||||
) -> bool {
|
||||
pickable_items(items, excluded_ids).any(|item| item.item_key == item_key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
PickerItem, PickerState, PickerStateReducerContract, PICKER_STATE_REDUCER_CONTRACT_NAME,
|
||||
};
|
||||
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) -> PickerItem {
|
||||
PickerItem {
|
||||
item_key: item_key.into(),
|
||||
document_id: document_id.map(ToOwned::to_owned),
|
||||
pickable,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_picker_state_skips_excluded_items_and_picks_active_document() {
|
||||
let items = vec![
|
||||
item("root", None, true),
|
||||
item("doc:a", Some("doc:a"), true),
|
||||
item("doc:b", Some("doc:b"), true),
|
||||
item("doc:c", Some("doc:c"), true),
|
||||
];
|
||||
let excluded_ids = excluded(&["doc:b"]);
|
||||
|
||||
let state = PickerState {
|
||||
active_item_key: Some("doc:b".into()),
|
||||
}
|
||||
.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);
|
||||
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
|
||||
assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c"));
|
||||
|
||||
let state = state.move_previous(&items, &excluded_ids);
|
||||
assert_eq!(state.active_item_key.as_deref(), Some("doc:a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_picker_state_home_end_and_empty_cases_are_stable() {
|
||||
let items = vec![
|
||||
item("doc:a", Some("doc:a"), true),
|
||||
item("doc:b", Some("doc:b"), false),
|
||||
item("doc:c", Some("doc:c"), true),
|
||||
];
|
||||
let excluded_ids = excluded(&[]);
|
||||
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(),
|
||||
Some("doc:a")
|
||||
);
|
||||
|
||||
let empty = PickerState::default().normalize(&items, &excluded(&["doc:a", "doc:c"]));
|
||||
assert_eq!(empty.active_item_key, None);
|
||||
assert_eq!(empty.pick(&items, &excluded(&["doc:a", "doc:c"])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_picker_state_reducer_contract_exposes_supported_actions() {
|
||||
let contract = PickerStateReducerContract::default();
|
||||
assert_eq!(contract.contract_name, PICKER_STATE_REDUCER_CONTRACT_NAME);
|
||||
assert!(contract.actions.contains("normalize"));
|
||||
assert!(contract.actions.contains("focus"));
|
||||
assert!(contract.actions.contains("next"));
|
||||
assert!(contract.actions.contains("previous"));
|
||||
assert!(contract.actions.contains("home"));
|
||||
assert!(contract.actions.contains("end"));
|
||||
assert!(contract.actions.contains("pick"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use super::filetree_selection::{FileTreeSelectionReducerContract, FileTreeSelectionState};
|
||||
use super::focus_state::PageFocusKeyboardReducerContract;
|
||||
use super::picker_state::PickerStateReducerContract;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TreeShellRendererMode {
|
||||
Page,
|
||||
FileTree,
|
||||
Picker,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TreeShellRendererInput {
|
||||
pub mode: TreeShellRendererMode,
|
||||
pub projection_item_ids: Vec<String>,
|
||||
pub expanded_ids: BTreeSet<String>,
|
||||
pub focused_id: Option<String>,
|
||||
pub page_focus_keyboard_reducer: Option<PageFocusKeyboardReducerContract>,
|
||||
pub filetree_selection: FileTreeSelectionState,
|
||||
pub filetree_selection_reducer: Option<FileTreeSelectionReducerContract>,
|
||||
pub active_picker_item: Option<String>,
|
||||
pub excluded_picker_ids: BTreeSet<String>,
|
||||
pub picker_state_reducer: Option<PickerStateReducerContract>,
|
||||
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TreeShellCommandDispatcher {
|
||||
pub channel: String,
|
||||
pub command_names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl TreeShellRendererInput {
|
||||
pub fn page(input: PageTreeRendererInput) -> Self {
|
||||
Self {
|
||||
mode: TreeShellRendererMode::Page,
|
||||
projection_item_ids: input.projection_item_ids,
|
||||
expanded_ids: input.expanded_ids,
|
||||
focused_id: input.focused_id,
|
||||
page_focus_keyboard_reducer: Some(PageFocusKeyboardReducerContract::default()),
|
||||
filetree_selection: FileTreeSelectionState::default(),
|
||||
filetree_selection_reducer: None,
|
||||
active_picker_item: None,
|
||||
excluded_picker_ids: BTreeSet::new(),
|
||||
picker_state_reducer: None,
|
||||
command_dispatcher: input.command_dispatcher,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filetree(input: FileTreeRendererInput) -> Self {
|
||||
Self {
|
||||
mode: TreeShellRendererMode::FileTree,
|
||||
projection_item_ids: input.projection_item_ids,
|
||||
expanded_ids: input.expanded_ids,
|
||||
focused_id: input.filetree_selection.focused_row_id.clone(),
|
||||
page_focus_keyboard_reducer: None,
|
||||
filetree_selection: input.filetree_selection,
|
||||
filetree_selection_reducer: Some(FileTreeSelectionReducerContract::default()),
|
||||
active_picker_item: None,
|
||||
excluded_picker_ids: BTreeSet::new(),
|
||||
picker_state_reducer: None,
|
||||
command_dispatcher: input.command_dispatcher,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn picker(input: PickerRendererInput) -> Self {
|
||||
Self {
|
||||
mode: TreeShellRendererMode::Picker,
|
||||
projection_item_ids: input.projection_item_ids,
|
||||
expanded_ids: input.expanded_ids,
|
||||
focused_id: input.active_picker_item.clone(),
|
||||
page_focus_keyboard_reducer: None,
|
||||
filetree_selection: FileTreeSelectionState::default(),
|
||||
filetree_selection_reducer: None,
|
||||
active_picker_item: input.active_picker_item,
|
||||
excluded_picker_ids: input.excluded_picker_ids,
|
||||
picker_state_reducer: Some(PickerStateReducerContract::default()),
|
||||
command_dispatcher: input.command_dispatcher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeRendererInput {
|
||||
pub projection_item_ids: Vec<String>,
|
||||
pub expanded_ids: BTreeSet<String>,
|
||||
pub focused_id: Option<String>,
|
||||
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeRendererInput {
|
||||
pub projection_item_ids: Vec<String>,
|
||||
pub expanded_ids: BTreeSet<String>,
|
||||
pub filetree_selection: FileTreeSelectionState,
|
||||
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerRendererInput {
|
||||
pub projection_item_ids: Vec<String>,
|
||||
pub expanded_ids: BTreeSet<String>,
|
||||
pub active_picker_item: Option<String>,
|
||||
pub excluded_picker_ids: BTreeSet<String>,
|
||||
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||
TreeShellRendererInput, TreeShellRendererMode,
|
||||
};
|
||||
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn set(values: &[&str]) -> BTreeSet<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
fn dispatcher() -> TreeShellCommandDispatcher {
|
||||
TreeShellCommandDispatcher {
|
||||
channel: "mnote.tree.shell".into(),
|
||||
command_names: set(&["tree.node.create", "tree.resource.move"]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_shell_renderer_input_contract_covers_page_filetree_and_picker_state() {
|
||||
let page = TreeShellRendererInput::page(PageTreeRendererInput {
|
||||
projection_item_ids: vec!["doc:root".into()],
|
||||
expanded_ids: set(&["doc:root"]),
|
||||
focused_id: Some("doc:root".into()),
|
||||
command_dispatcher: dispatcher(),
|
||||
});
|
||||
assert_eq!(page.mode, TreeShellRendererMode::Page);
|
||||
assert_eq!(page.focused_id.as_deref(), Some("doc:root"));
|
||||
assert_eq!(page.command_dispatcher.channel, "mnote.tree.shell");
|
||||
assert_eq!(
|
||||
page.page_focus_keyboard_reducer
|
||||
.as_ref()
|
||||
.map(|contract| contract.contract_name),
|
||||
Some("rust_page_focus_keyboard_reducer_v1")
|
||||
);
|
||||
|
||||
let mut selection = FileTreeSelectionState::from_selected(&["asset:a".into()]);
|
||||
selection.focused_row_id = Some("asset:a".into());
|
||||
let filetree = TreeShellRendererInput::filetree(FileTreeRendererInput {
|
||||
projection_item_ids: vec!["doc:root".into(), "asset:a".into()],
|
||||
expanded_ids: set(&["doc:root"]),
|
||||
filetree_selection: selection,
|
||||
command_dispatcher: dispatcher(),
|
||||
});
|
||||
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.page_focus_keyboard_reducer.is_none());
|
||||
assert_eq!(
|
||||
filetree
|
||||
.filetree_selection_reducer
|
||||
.as_ref()
|
||||
.map(|contract| contract.contract_name),
|
||||
Some("rust_filetree_selection_reducer_v1")
|
||||
);
|
||||
|
||||
let picker = TreeShellRendererInput::picker(PickerRendererInput {
|
||||
projection_item_ids: vec!["doc:root".into(), "doc:child".into()],
|
||||
expanded_ids: set(&["doc:root"]),
|
||||
active_picker_item: Some("doc:child".into()),
|
||||
excluded_picker_ids: set(&["doc:archived"]),
|
||||
command_dispatcher: dispatcher(),
|
||||
});
|
||||
assert_eq!(picker.mode, TreeShellRendererMode::Picker);
|
||||
assert_eq!(picker.focused_id.as_deref(), Some("doc:child"));
|
||||
assert!(picker.excluded_picker_ids.contains("doc:archived"));
|
||||
assert!(picker.page_focus_keyboard_reducer.is_none());
|
||||
assert!(picker.filetree_selection_reducer.is_none());
|
||||
assert_eq!(
|
||||
picker
|
||||
.picker_state_reducer
|
||||
.as_ref()
|
||||
.map(|contract| contract.contract_name),
|
||||
Some("rust_picker_state_reducer_v1")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
"tree.node.purge" => "documents:purge",
|
||||
"tree.subtree.move" => "documents:move",
|
||||
"tree.subtree.copy" => "documents:copyTree",
|
||||
"tree.resource.copy" => "mediaAssets:batchCopy",
|
||||
"tree.resource.move" => "mediaAssets:batchMove",
|
||||
"tree.resource.upload" => "mediaAssets:createWithStorage",
|
||||
"tree.filetree.drop.preflight" => "tree:fileTreeDropPreflight",
|
||||
"tree.filetree.delete.preflight" => "tree:fileTreeDeletePreflight",
|
||||
"tree.filetree.paste.preflight" => "tree:fileTreePastePreflight",
|
||||
"tree.filetree.upload-target.preflight" => "tree:fileTreeUploadTargetPreflight",
|
||||
"tree.node.embed" => "documents:updateContent",
|
||||
"documents.create" => "documents:createWithParentReference",
|
||||
"documents.move" => "documents:move",
|
||||
|
||||
Reference in New Issue
Block a user