4-26 树rust-2
This commit is contained in:
+3
-1
@@ -62,4 +62,6 @@ design
|
|||||||
|
|
||||||
# pnpm 本地缓存
|
# pnpm 本地缓存
|
||||||
/.pnpm-store/
|
/.pnpm-store/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
rust/spikes/leptos-tiptap-spike/trunk-8123.err
|
||||||
|
rust/spikes/leptos-tiptap-spike/trunk-8123.out
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use index_fts::{
|
|||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use storage_convex_bridge::{
|
use storage_convex_bridge::{
|
||||||
@@ -122,6 +122,8 @@ pub struct RuntimeCommandEnvelopeWire {
|
|||||||
pub source: RuntimeSourceWire,
|
pub source: RuntimeSourceWire,
|
||||||
pub target: Option<RuntimeTargetWire>,
|
pub target: Option<RuntimeTargetWire>,
|
||||||
pub payload: Value,
|
pub payload: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub preflight_data: Option<Value>,
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
pub refs: Vec<String>,
|
pub refs: Vec<String>,
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
@@ -606,6 +608,190 @@ struct DocumentMoveCommandPayload {
|
|||||||
sort_order: i64,
|
sort_order: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocumentMovePreflightDocument {
|
||||||
|
id: String,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocumentMoveSnapshotDocument {
|
||||||
|
id: String,
|
||||||
|
#[serde(default, alias = "workspace_id")]
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
#[serde(default, alias = "parent_id")]
|
||||||
|
parent_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocumentMovePreflightPayload {
|
||||||
|
source_document: DocumentMovePreflightDocument,
|
||||||
|
target_parent_document: Option<DocumentMovePreflightDocument>,
|
||||||
|
#[serde(default)]
|
||||||
|
target_ancestor_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocumentMoveSnapshotSidebarPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
documents: Vec<DocumentMoveSnapshotDocument>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocumentMoveSnapshotPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
documents: Vec<DocumentMoveSnapshotDocument>,
|
||||||
|
#[serde(default, alias = "sidebarSnapshot")]
|
||||||
|
sidebar_snapshot: Option<DocumentMoveSnapshotSidebarPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_document_move_preflight_from_snapshot(
|
||||||
|
payload: &DocumentMoveCommandPayload,
|
||||||
|
snapshot: &DocumentMoveSnapshotPayload,
|
||||||
|
) -> Result<DocumentMovePreflightPayload, BridgeError> {
|
||||||
|
let documents = if !snapshot.documents.is_empty() {
|
||||||
|
snapshot.documents.clone()
|
||||||
|
} else if let Some(sidebar_snapshot) = snapshot.sidebar_snapshot.as_ref() {
|
||||||
|
sidebar_snapshot.documents.clone()
|
||||||
|
} else {
|
||||||
|
return Err(BridgeError::validation(
|
||||||
|
"move preflightData 缺少 documents 快照",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let document_by_id: HashMap<String, DocumentMoveSnapshotDocument> = documents
|
||||||
|
.into_iter()
|
||||||
|
.map(|document| (document.id.clone(), document))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let source_document =
|
||||||
|
document_by_id
|
||||||
|
.get(payload.document_id.as_str())
|
||||||
|
.ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?;
|
||||||
|
|
||||||
|
let target_parent_document = payload
|
||||||
|
.parent_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|parent_id| {
|
||||||
|
document_by_id
|
||||||
|
.get(parent_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| BridgeError::validation("目标父页面不存在或无权限"))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let mut target_ancestor_ids = Vec::new();
|
||||||
|
let mut cursor = target_parent_document
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|document| document.parent_id.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let mut depth = 0;
|
||||||
|
while let Some(parent_id) = cursor {
|
||||||
|
if depth >= 256 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
target_ancestor_ids.push(parent_id.clone());
|
||||||
|
cursor = document_by_id
|
||||||
|
.get(parent_id.as_str())
|
||||||
|
.and_then(|document| document.parent_id.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DocumentMovePreflightPayload {
|
||||||
|
source_document: DocumentMovePreflightDocument {
|
||||||
|
id: source_document.id.clone(),
|
||||||
|
workspace_id: source_document.workspace_id.clone(),
|
||||||
|
},
|
||||||
|
target_parent_document: target_parent_document.map(|document| DocumentMovePreflightDocument {
|
||||||
|
id: document.id,
|
||||||
|
workspace_id: document.workspace_id,
|
||||||
|
}),
|
||||||
|
target_ancestor_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_document_move_preflight(
|
||||||
|
payload: &DocumentMoveCommandPayload,
|
||||||
|
preflight_data: Option<&Value>,
|
||||||
|
) -> Result<Option<DocumentMovePreflightPayload>, BridgeError> {
|
||||||
|
let Some(raw_preflight) = preflight_data else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(preflight) =
|
||||||
|
serde_json::from_value::<DocumentMovePreflightPayload>(raw_preflight.clone())
|
||||||
|
{
|
||||||
|
return Ok(Some(preflight));
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = serde_json::from_value::<DocumentMoveSnapshotPayload>(raw_preflight.clone())
|
||||||
|
.map_err(|error| BridgeError::validation(format!("move preflightData 非法: {error}")))?;
|
||||||
|
derive_document_move_preflight_from_snapshot(payload, &snapshot).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_document_move_legality(
|
||||||
|
payload: &DocumentMoveCommandPayload,
|
||||||
|
preflight_data: Option<&Value>,
|
||||||
|
) -> Result<(), BridgeError> {
|
||||||
|
let Some(parent_id) = payload.parent_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if parent_id == payload.document_id {
|
||||||
|
return Err(BridgeError::validation("不能把页面移动到自身下面"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(preflight) = resolve_document_move_preflight(payload, preflight_data)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if preflight.source_document.id.trim() == parent_id {
|
||||||
|
return Err(BridgeError::validation("不能把页面移动到自身下面"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if preflight
|
||||||
|
.target_ancestor_ids
|
||||||
|
.iter()
|
||||||
|
.any(|ancestor_id| ancestor_id.trim() == payload.document_id)
|
||||||
|
{
|
||||||
|
return Err(BridgeError::validation("不能把页面移动到自己的后代下面"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(target_parent_document) = preflight.target_parent_document.as_ref() {
|
||||||
|
let source_workspace_id = preflight
|
||||||
|
.source_document
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let target_workspace_id = target_parent_document
|
||||||
|
.workspace_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
if source_workspace_id.is_some()
|
||||||
|
&& target_workspace_id.is_some()
|
||||||
|
&& source_workspace_id != target_workspace_id
|
||||||
|
{
|
||||||
|
return Err(BridgeError::validation("暂不支持跨工作空间移动页面"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DocumentDeleteCommandPayload {
|
struct DocumentDeleteCommandPayload {
|
||||||
@@ -5557,10 +5743,15 @@ fn execute_command(
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"documents.embed" => {
|
"documents.embed" | "tree.node.embed" => {
|
||||||
let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?;
|
let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||||
|
let command_name = if command_wire.name == "tree.node.embed" {
|
||||||
|
"tree.node.embed"
|
||||||
|
} else {
|
||||||
|
"documents.embed"
|
||||||
|
};
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
name: "documents.embed".into(),
|
name: command_name.into(),
|
||||||
command_id: command_wire.command_id.clone(),
|
command_id: command_wire.command_id.clone(),
|
||||||
idempotency_key: command_wire.idempotency_key.clone(),
|
idempotency_key: command_wire.idempotency_key.clone(),
|
||||||
actor: to_actor_payload(&command_wire.actor),
|
actor: to_actor_payload(&command_wire.actor),
|
||||||
@@ -5571,9 +5762,7 @@ fn execute_command(
|
|||||||
workspace_id: payload.workspace_id.clone(),
|
workspace_id: payload.workspace_id.clone(),
|
||||||
revision: payload.revision,
|
revision: payload.revision,
|
||||||
content_json: serde_json::to_string(&payload.content).map_err(|error| {
|
content_json: serde_json::to_string(&payload.content).map_err(|error| {
|
||||||
BridgeError::validation(format!(
|
BridgeError::validation(format!("{command_name} content 序列化失败: {error}"))
|
||||||
"documents.embed content 序列化失败: {error}"
|
|
||||||
))
|
|
||||||
})?,
|
})?,
|
||||||
conflict_detection_key: payload.conflict_detection_key.clone(),
|
conflict_detection_key: payload.conflict_detection_key.clone(),
|
||||||
},
|
},
|
||||||
@@ -5818,6 +6007,7 @@ fn execute_command(
|
|||||||
}
|
}
|
||||||
"documents.move" | "tree.subtree.move" => {
|
"documents.move" | "tree.subtree.move" => {
|
||||||
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
|
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||||
|
validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?;
|
||||||
let command_name = if command_wire.name == "tree.subtree.move" {
|
let command_name = if command_wire.name == "tree.subtree.move" {
|
||||||
"tree.subtree.move"
|
"tree.subtree.move"
|
||||||
} else {
|
} else {
|
||||||
@@ -5854,11 +6044,16 @@ fn execute_command(
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"documents.delete" => {
|
"documents.delete" | "tree.node.archive" => {
|
||||||
let payload: DocumentDeleteCommandPayload =
|
let payload: DocumentDeleteCommandPayload =
|
||||||
parse_payload(command_wire.payload.clone())?;
|
parse_payload(command_wire.payload.clone())?;
|
||||||
|
let command_name = if command_wire.name == "tree.node.archive" {
|
||||||
|
"tree.node.archive"
|
||||||
|
} else {
|
||||||
|
"documents.delete"
|
||||||
|
};
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
name: "documents.delete".into(),
|
name: command_name.into(),
|
||||||
command_id: command_wire.command_id.clone(),
|
command_id: command_wire.command_id.clone(),
|
||||||
idempotency_key: command_wire.idempotency_key.clone(),
|
idempotency_key: command_wire.idempotency_key.clone(),
|
||||||
actor: to_actor_payload(&command_wire.actor),
|
actor: to_actor_payload(&command_wire.actor),
|
||||||
@@ -5886,11 +6081,16 @@ fn execute_command(
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"documents.restore" => {
|
"documents.restore" | "tree.node.restore" => {
|
||||||
let payload: DocumentRestoreCommandPayload =
|
let payload: DocumentRestoreCommandPayload =
|
||||||
parse_payload(command_wire.payload.clone())?;
|
parse_payload(command_wire.payload.clone())?;
|
||||||
|
let command_name = if command_wire.name == "tree.node.restore" {
|
||||||
|
"tree.node.restore"
|
||||||
|
} else {
|
||||||
|
"documents.restore"
|
||||||
|
};
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
name: "documents.restore".into(),
|
name: command_name.into(),
|
||||||
command_id: command_wire.command_id.clone(),
|
command_id: command_wire.command_id.clone(),
|
||||||
idempotency_key: command_wire.idempotency_key.clone(),
|
idempotency_key: command_wire.idempotency_key.clone(),
|
||||||
actor: to_actor_payload(&command_wire.actor),
|
actor: to_actor_payload(&command_wire.actor),
|
||||||
@@ -6017,10 +6217,15 @@ fn execute_command(
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"documents.purge" => {
|
"documents.purge" | "tree.node.purge" => {
|
||||||
let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?;
|
let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||||
|
let command_name = if command_wire.name == "tree.node.purge" {
|
||||||
|
"tree.node.purge"
|
||||||
|
} else {
|
||||||
|
"documents.purge"
|
||||||
|
};
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
name: "documents.purge".into(),
|
name: command_name.into(),
|
||||||
command_id: command_wire.command_id.clone(),
|
command_id: command_wire.command_id.clone(),
|
||||||
idempotency_key: command_wire.idempotency_key.clone(),
|
idempotency_key: command_wire.idempotency_key.clone(),
|
||||||
actor: to_actor_payload(&command_wire.actor),
|
actor: to_actor_payload(&command_wire.actor),
|
||||||
@@ -6048,11 +6253,16 @@ fn execute_command(
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"documents.copy_tree" => {
|
"documents.copy_tree" | "tree.subtree.copy" => {
|
||||||
let payload: DocumentCopyTreeCommandPayload =
|
let payload: DocumentCopyTreeCommandPayload =
|
||||||
parse_payload(command_wire.payload.clone())?;
|
parse_payload(command_wire.payload.clone())?;
|
||||||
|
let command_name = if command_wire.name == "tree.subtree.copy" {
|
||||||
|
"tree.subtree.copy"
|
||||||
|
} else {
|
||||||
|
"documents.copy_tree"
|
||||||
|
};
|
||||||
let command = CommandEnvelope {
|
let command = CommandEnvelope {
|
||||||
name: "documents.copy_tree".into(),
|
name: command_name.into(),
|
||||||
command_id: command_wire.command_id.clone(),
|
command_id: command_wire.command_id.clone(),
|
||||||
idempotency_key: command_wire.idempotency_key.clone(),
|
idempotency_key: command_wire.idempotency_key.clone(),
|
||||||
actor: to_actor_payload(&command_wire.actor),
|
actor: to_actor_payload(&command_wire.actor),
|
||||||
@@ -6426,6 +6636,7 @@ mod tests {
|
|||||||
"type": "paragraph",
|
"type": "paragraph",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("替换块快照".into()),
|
reason: Some("替换块快照".into()),
|
||||||
refs: vec![],
|
refs: vec![],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -6732,6 +6943,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"createOnly": true,
|
"createOnly": true,
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("保存导图".into()),
|
reason: Some("保存导图".into()),
|
||||||
refs: vec!["task-032".into()],
|
refs: vec!["task-032".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7229,6 +7441,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"conflictDetectionKey": "doc_1:4"
|
"conflictDetectionKey": "doc_1:4"
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("保存正文".into()),
|
reason: Some("保存正文".into()),
|
||||||
refs: vec!["task-055".into()],
|
refs: vec!["task-055".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7326,6 +7539,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
"conflictDetectionKey": "doc_1:6"
|
"conflictDetectionKey": "doc_1:6"
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("保存正文".into()),
|
reason: Some("保存正文".into()),
|
||||||
refs: vec!["task-save-fallback".into()],
|
refs: vec!["task-save-fallback".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7427,6 +7641,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
"conflictDetectionKey": "doc_1:7"
|
"conflictDetectionKey": "doc_1:7"
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("保存正文".into()),
|
reason: Some("保存正文".into()),
|
||||||
refs: vec!["task-save-prefer-editor".into()],
|
refs: vec!["task-save-prefer-editor".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7489,6 +7704,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
"conflictDetectionKey": "doc_1:8"
|
"conflictDetectionKey": "doc_1:8"
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("保存正文".into()),
|
reason: Some("保存正文".into()),
|
||||||
refs: vec!["task-save-content-only".into()],
|
refs: vec!["task-save-content-only".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7540,6 +7756,7 @@ mod tests {
|
|||||||
"blockId": "block_1",
|
"blockId": "block_1",
|
||||||
"targetDocumentId": "doc_2",
|
"targetDocumentId": "doc_2",
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("移动块".into()),
|
reason: Some("移动块".into()),
|
||||||
refs: vec![],
|
refs: vec![],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7594,6 +7811,7 @@ mod tests {
|
|||||||
"targetDocumentId": "doc_2",
|
"targetDocumentId": "doc_2",
|
||||||
"targetBlockId": "anchor_1",
|
"targetBlockId": "anchor_1",
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("嵌入块".into()),
|
reason: Some("嵌入块".into()),
|
||||||
refs: vec![],
|
refs: vec![],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7653,6 +7871,7 @@ mod tests {
|
|||||||
"targetDocumentId": "doc_2",
|
"targetDocumentId": "doc_2",
|
||||||
"anchorBlockId": "anchor_1",
|
"anchorBlockId": "anchor_1",
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("嵌入页面".into()),
|
reason: Some("嵌入页面".into()),
|
||||||
refs: vec![],
|
refs: vec![],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -7683,6 +7902,504 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_lifecycle_command_aliases_keep_tree_command_names() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"tree.node.archive",
|
||||||
|
"documents:softDelete",
|
||||||
|
json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"id": "doc_1",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tree.node.restore",
|
||||||
|
"documents:restore",
|
||||||
|
json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"id": "doc_1",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tree.node.purge",
|
||||||
|
"documents:purge",
|
||||||
|
json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"id": "doc_1",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (command_name, function_name, payload, args_json) in cases {
|
||||||
|
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: command_name.into(),
|
||||||
|
command_id: format!("cmd_{command_name}"),
|
||||||
|
idempotency_key: Some(format!("idem_{command_name}")),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload,
|
||||||
|
preflight_data: None,
|
||||||
|
reason: Some("树命令切流".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("command plan should build");
|
||||||
|
|
||||||
|
match plan {
|
||||||
|
RuntimeExecutionPlan::Command(plan) => {
|
||||||
|
assert_eq!(plan.function_name, function_name);
|
||||||
|
assert_eq!(plan.command_name, command_name);
|
||||||
|
assert_eq!(plan.args_json, args_json);
|
||||||
|
}
|
||||||
|
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
|
||||||
|
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_subtree_move_command_rejects_self_target_via_preflight() {
|
||||||
|
let error = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.subtree.move".into(),
|
||||||
|
command_id: "cmd_tree_move_self".into(),
|
||||||
|
idempotency_key: Some("idem_tree_move_self".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"parentId": "doc_1",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"movePreflight": {
|
||||||
|
"sourceDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetParentDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetAncestorIds": []
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
preflight_data: Some(json!({
|
||||||
|
"sourceDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetParentDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetAncestorIds": []
|
||||||
|
})),
|
||||||
|
reason: Some("树命令切流".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect_err("self move should be rejected");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||||
|
assert_eq!(error.message, "不能把页面移动到自身下面");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_subtree_move_command_rejects_descendant_target_via_preflight() {
|
||||||
|
let error = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.subtree.move".into(),
|
||||||
|
command_id: "cmd_tree_move_descendant".into(),
|
||||||
|
idempotency_key: Some("idem_tree_move_descendant".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"parentId": "child_1",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"movePreflight": {
|
||||||
|
"sourceDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetParentDocument": {
|
||||||
|
"id": "child_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": "doc_1"
|
||||||
|
},
|
||||||
|
"targetAncestorIds": ["doc_1"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
preflight_data: Some(json!({
|
||||||
|
"sourceDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetParentDocument": {
|
||||||
|
"id": "child_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": "doc_1"
|
||||||
|
},
|
||||||
|
"targetAncestorIds": ["doc_1"]
|
||||||
|
})),
|
||||||
|
reason: Some("树命令切流".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect_err("descendant move should be rejected");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||||
|
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_subtree_move_command_rejects_cross_workspace_target_via_preflight() {
|
||||||
|
let error = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.subtree.move".into(),
|
||||||
|
command_id: "cmd_tree_move_cross_workspace".into(),
|
||||||
|
idempotency_key: Some("idem_tree_move_cross_workspace".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"parentId": "parent_remote",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"movePreflight": {
|
||||||
|
"sourceDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetParentDocument": {
|
||||||
|
"id": "parent_remote",
|
||||||
|
"workspaceId": "ws_2",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetAncestorIds": []
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
preflight_data: Some(json!({
|
||||||
|
"sourceDocument": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetParentDocument": {
|
||||||
|
"id": "parent_remote",
|
||||||
|
"workspaceId": "ws_2",
|
||||||
|
"parentId": null
|
||||||
|
},
|
||||||
|
"targetAncestorIds": []
|
||||||
|
})),
|
||||||
|
reason: Some("树命令切流".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect_err("cross-workspace move should be rejected");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||||
|
assert_eq!(error.message, "暂不支持跨工作空间移动页面");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_subtree_move_command_rejects_descendant_target_via_snapshot_documents() {
|
||||||
|
let error = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.subtree.move".into(),
|
||||||
|
command_id: "cmd_tree_move_desc_snapshot".into(),
|
||||||
|
idempotency_key: Some("idem_tree_move_desc_snapshot".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"parentId": "child_1",
|
||||||
|
"sortOrder": 0
|
||||||
|
}),
|
||||||
|
preflight_data: Some(json!({
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"parent_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "child_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"parent_id": "doc_1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
reason: Some("树命令切流".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect_err("descendant move should be rejected from snapshot documents");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||||
|
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_subtree_move_command_rejects_missing_target_parent_via_snapshot_documents() {
|
||||||
|
let error = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.subtree.move".into(),
|
||||||
|
command_id: "cmd_tree_move_missing_parent_snapshot".into(),
|
||||||
|
idempotency_key: Some("idem_tree_move_missing_parent_snapshot".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"parentId": "missing_parent",
|
||||||
|
"sortOrder": 0
|
||||||
|
}),
|
||||||
|
preflight_data: Some(json!({
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"parent_id": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
reason: Some("树命令切流".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect_err("missing parent should be rejected from snapshot documents");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||||
|
assert_eq!(error.message, "目标父页面不存在或无权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_embed_and_copy_aliases_keep_tree_command_names() {
|
||||||
|
let embed_plan = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.node.embed".into(),
|
||||||
|
command_id: "cmd_tree_embed_1".into(),
|
||||||
|
idempotency_key: Some("idem_tree_embed".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("doc_2".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": "doc_2",
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"revision": 5,
|
||||||
|
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||||
|
"conflictDetectionKey": "conflict_5",
|
||||||
|
"sourceDocumentId": "doc_1",
|
||||||
|
"targetDocumentId": "doc_2",
|
||||||
|
"anchorBlockId": "anchor_1",
|
||||||
|
}),
|
||||||
|
preflight_data: None,
|
||||||
|
reason: Some("树命令嵌入页面".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("embed plan should build");
|
||||||
|
|
||||||
|
match embed_plan {
|
||||||
|
RuntimeExecutionPlan::Command(plan) => {
|
||||||
|
assert_eq!(plan.function_name, "documents:updateContent");
|
||||||
|
assert_eq!(plan.command_name, "tree.node.embed");
|
||||||
|
assert_eq!(
|
||||||
|
plan.args_json,
|
||||||
|
json!({
|
||||||
|
"id": "doc_2",
|
||||||
|
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||||
|
"expectedRevision": 5,
|
||||||
|
"conflictDetectionKey": "conflict_5",
|
||||||
|
"sourceDocumentId": "doc_1",
|
||||||
|
"targetDocumentId": "doc_2",
|
||||||
|
"anchorBlockId": "anchor_1",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
|
||||||
|
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let copy_plan = execute_runtime_input(RuntimeInput::Command {
|
||||||
|
context: demo_context(),
|
||||||
|
command: RuntimeCommandEnvelopeWire {
|
||||||
|
name: "tree.subtree.copy".into(),
|
||||||
|
command_id: "cmd_tree_copy_1".into(),
|
||||||
|
idempotency_key: Some("idem_tree_copy".into()),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: "user".into(),
|
||||||
|
actor_id: "user_1".into(),
|
||||||
|
session_id: Some("sess_1".into()),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: "next-route".into(),
|
||||||
|
client: "wolai-frontend".into(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: Some("ws_1".into()),
|
||||||
|
page_id: Some("parent_1".into()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"recursive": true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"targetParentId": "parent_1",
|
||||||
|
}),
|
||||||
|
preflight_data: None,
|
||||||
|
reason: Some("树命令复制子树".into()),
|
||||||
|
refs: vec![],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("copy plan should build");
|
||||||
|
|
||||||
|
match copy_plan {
|
||||||
|
RuntimeExecutionPlan::Command(plan) => {
|
||||||
|
assert_eq!(plan.function_name, "documents:copyTree");
|
||||||
|
assert_eq!(plan.command_name, "tree.subtree.copy");
|
||||||
|
assert_eq!(
|
||||||
|
plan.args_json,
|
||||||
|
json!({
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"recursive": true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"targetParentId": "parent_1",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
|
||||||
|
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
|
fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
|
||||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ pub async fn save(
|
|||||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||||
"blockCount": body.block_count,
|
"blockCount": body.block_count,
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("mnote-web human editor save".into()),
|
reason: Some("mnote-web human editor save".into()),
|
||||||
refs: vec!["mnote-web-editor-runtime".into()],
|
refs: vec!["mnote-web-editor-runtime".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
|
|||||||
@@ -1,34 +1,138 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
use crate::routes::stream_support::{
|
||||||
|
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
|
||||||
|
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
|
||||||
|
StreamSnapshotQuery,
|
||||||
|
};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
pub async fn events(
|
pub async fn events(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Query(query): Query<StreamSnapshotQuery>,
|
Query(query): Query<StreamSnapshotQuery>,
|
||||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
|
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
|
||||||
let payload = load_stream_snapshot(state.config(), &context, &query).await?;
|
let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?;
|
||||||
let event = snapshot_event(&payload);
|
let initial_cursor = read_stream_cursor_from_payload(&initial_payload);
|
||||||
|
let max_polls = query.max_polls;
|
||||||
|
let poll_ms = query.poll_ms.unwrap_or(2_000).max(250);
|
||||||
|
let state_for_stream = state.clone();
|
||||||
|
let context_for_stream = context.clone();
|
||||||
|
let query_for_stream = query.clone();
|
||||||
|
let stream = stream::unfold(
|
||||||
|
Some(StreamPollState {
|
||||||
|
app_state: state_for_stream,
|
||||||
|
context: context_for_stream,
|
||||||
|
query: query_for_stream,
|
||||||
|
current_cursor: initial_cursor,
|
||||||
|
polls: 0,
|
||||||
|
initial_payload,
|
||||||
|
initial_emitted: false,
|
||||||
|
}),
|
||||||
|
move |state| async move {
|
||||||
|
let mut state = state?;
|
||||||
|
|
||||||
Ok(Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
|
if !state.initial_emitted {
|
||||||
|
state.initial_emitted = true;
|
||||||
|
return Some((
|
||||||
|
Ok(stream_event("snapshot", &state.initial_payload)),
|
||||||
|
Some(state),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(max_polls) = max_polls {
|
||||||
|
if state.polls >= max_polls {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.polls += 1;
|
||||||
|
sleep(Duration::from_millis(poll_ms)).await;
|
||||||
|
|
||||||
|
let Ok((workspace_id, overview)) =
|
||||||
|
load_stream_overview(state.app_state.config(), &state.context, &state.query)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Some(change) =
|
||||||
|
resolve_stream_change(&overview, state.current_cursor.as_deref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
state.current_cursor = change.cursor.clone();
|
||||||
|
|
||||||
|
match change.kind {
|
||||||
|
StreamChangeKind::Delta => {
|
||||||
|
let payload = build_stream_delta_payload(
|
||||||
|
&state.context,
|
||||||
|
&state.query,
|
||||||
|
&workspace_id,
|
||||||
|
&overview,
|
||||||
|
change.cursor,
|
||||||
|
change.delta.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||||
|
);
|
||||||
|
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||||
|
}
|
||||||
|
StreamChangeKind::Resync => {
|
||||||
|
let mut next_query = state.query.clone();
|
||||||
|
next_query.cursor = change.cursor;
|
||||||
|
let Ok(snapshot_payload) = load_stream_snapshot(
|
||||||
|
state.app_state.config(),
|
||||||
|
&state.context,
|
||||||
|
&next_query,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
state.query = next_query;
|
||||||
|
state.current_cursor =
|
||||||
|
read_stream_cursor_from_payload(&snapshot_payload);
|
||||||
|
return Some((
|
||||||
|
Ok(stream_event(
|
||||||
|
"resync",
|
||||||
|
&with_stream_kind(&snapshot_payload, "resync"),
|
||||||
|
)),
|
||||||
|
Some(state),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Sse::new(stream).keep_alive(
|
||||||
KeepAlive::new()
|
KeepAlive::new()
|
||||||
.interval(Duration::from_secs(15))
|
.interval(Duration::from_secs(15))
|
||||||
.text("keepalive"),
|
.text("keepalive"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_event(payload: &Value) -> Event {
|
#[derive(Clone)]
|
||||||
|
struct StreamPollState {
|
||||||
|
app_state: AppState,
|
||||||
|
context: RequestContext,
|
||||||
|
query: StreamSnapshotQuery,
|
||||||
|
current_cursor: Option<String>,
|
||||||
|
polls: u32,
|
||||||
|
initial_payload: Value,
|
||||||
|
initial_emitted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||||
Event::default()
|
Event::default()
|
||||||
.event("snapshot")
|
.event(event_name)
|
||||||
.json_data(payload)
|
.json_data(payload)
|
||||||
.expect("SSE snapshot 事件必须可序列化")
|
.expect("SSE 事件必须可序列化")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -62,7 +166,7 @@ mod tests {
|
|||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/api/stream/events?workspaceId=ws_demo")
|
.uri("/api/stream/events?workspaceId=ws_demo&maxPolls=0")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request"),
|
.expect("request"),
|
||||||
)
|
)
|
||||||
@@ -75,7 +179,8 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||||
assert!(text.contains("\"scope\":\"workspace\""));
|
assert!(text.contains("\"stream\":\"workspace\""));
|
||||||
|
assert!(text.contains("\"projection\":\"sidebar_tree\""));
|
||||||
assert!(text.contains("\"workspaceId\":\"ws_demo\""));
|
assert!(text.contains("\"workspaceId\":\"ws_demo\""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ use core_protocol::KernelProjectionKind;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
|
||||||
|
"page.body.save",
|
||||||
|
"page.layout.updateOptions",
|
||||||
|
"documents.stats.update",
|
||||||
|
"blocks.patch",
|
||||||
|
"blocks.move",
|
||||||
|
"blocks.embed",
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StreamSnapshotQuery {
|
pub struct StreamSnapshotQuery {
|
||||||
@@ -21,6 +30,8 @@ pub struct StreamSnapshotQuery {
|
|||||||
pub depth: Option<u32>,
|
pub depth: Option<u32>,
|
||||||
pub cursor: Option<String>,
|
pub cursor: Option<String>,
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
|
pub poll_ms: Option<u64>,
|
||||||
|
pub max_polls: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -29,6 +40,19 @@ pub enum StreamSnapshotScope {
|
|||||||
Subtree,
|
Subtree,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum StreamChangeKind {
|
||||||
|
Delta,
|
||||||
|
Resync,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct StreamChange {
|
||||||
|
pub kind: StreamChangeKind,
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
pub delta: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
impl StreamSnapshotScope {
|
impl StreamSnapshotScope {
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -36,6 +60,19 @@ impl StreamSnapshotScope {
|
|||||||
Self::Subtree => "subtree",
|
Self::Subtree => "subtree",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn projection(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Workspace => "sidebar_tree",
|
||||||
|
Self::Subtree => "page_tree",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct DecodedStreamCursor {
|
||||||
|
created_at: String,
|
||||||
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope {
|
pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope {
|
||||||
@@ -52,6 +89,15 @@ pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_root_node_id(query: &StreamSnapshotQuery) -> Option<String> {
|
||||||
|
query
|
||||||
|
.root_node_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn workspace_overview_query(
|
fn workspace_overview_query(
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
query: &StreamSnapshotQuery,
|
query: &StreamSnapshotQuery,
|
||||||
@@ -72,6 +118,246 @@ fn workspace_overview_query(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_record(value: &Value) -> bool {
|
||||||
|
value.is_object()
|
||||||
|
}
|
||||||
|
|
||||||
|
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("");
|
||||||
|
if !candidate.is_empty() {
|
||||||
|
return Some(candidate.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_array_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec<Value>> {
|
||||||
|
let map = value.as_object()?;
|
||||||
|
for key in keys {
|
||||||
|
if let Some(items) = map.get(*key).and_then(Value::as_array) {
|
||||||
|
return Some(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_stream_cursor(id: &str, created_at: &str) -> Option<String> {
|
||||||
|
let id = id.trim();
|
||||||
|
let created_at = created_at.trim();
|
||||||
|
if id.is_empty() || created_at.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
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"])?;
|
||||||
|
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"])?;
|
||||||
|
encode_stream_cursor(&format!("domain_event:{id}"), &created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_stream_cursor(raw: &str) -> Option<DecodedStreamCursor> {
|
||||||
|
let parsed = serde_json::from_str::<Value>(raw).ok()?;
|
||||||
|
Some(DecodedStreamCursor {
|
||||||
|
created_at: read_string_field(&parsed, &["createdAt"])?,
|
||||||
|
id: read_string_field(&parsed, &["id"])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_stream_cursor(
|
||||||
|
overview: Option<&Value>,
|
||||||
|
fallback: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let fallback = fallback
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let Some(overview) = overview else {
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
let command_cursor = read_array_field(overview, &["command_logs", "commandLogs"])
|
||||||
|
.and_then(|rows| rows.first())
|
||||||
|
.and_then(encode_command_cursor);
|
||||||
|
let domain_event_cursor = read_array_field(overview, &["domain_events", "domainEvents"])
|
||||||
|
.and_then(|rows| rows.first())
|
||||||
|
.and_then(encode_domain_event_cursor);
|
||||||
|
|
||||||
|
match (command_cursor, domain_event_cursor) {
|
||||||
|
(None, None) => fallback,
|
||||||
|
(Some(cursor), None) => Some(cursor),
|
||||||
|
(None, Some(cursor)) => Some(cursor),
|
||||||
|
(Some(command_cursor), Some(domain_event_cursor)) => {
|
||||||
|
let decoded_command = decode_stream_cursor(&command_cursor);
|
||||||
|
let decoded_domain_event = decode_stream_cursor(&domain_event_cursor);
|
||||||
|
match (decoded_command, decoded_domain_event) {
|
||||||
|
(Some(command), Some(event)) => {
|
||||||
|
if event.created_at > command.created_at {
|
||||||
|
Some(domain_event_cursor)
|
||||||
|
} else {
|
||||||
|
Some(command_cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), None) => Some(command_cursor),
|
||||||
|
(None, Some(_)) => Some(domain_event_cursor),
|
||||||
|
(None, None) => fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
id == previous_cursor.id && created_at == previous_cursor.created_at
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(index) = previous_index {
|
||||||
|
(rows.iter().take(index).cloned().collect(), false)
|
||||||
|
} else {
|
||||||
|
(rows.to_vec(), !rows.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
return Some(json!({ "op": "noop" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = row.as_object()?.get("payload")?;
|
||||||
|
if !is_record(payload) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = payload
|
||||||
|
.as_object()
|
||||||
|
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_stream_change(
|
||||||
|
overview: &Value,
|
||||||
|
previous_cursor: Option<&str>,
|
||||||
|
) -> Option<StreamChange> {
|
||||||
|
let next_cursor = resolve_stream_cursor(Some(overview), previous_cursor);
|
||||||
|
let previous_cursor = previous_cursor
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
if next_cursor == previous_cursor {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
if !drifted && new_rows.len() == 1 {
|
||||||
|
if let Some(delta) = read_command_payload_delta(&new_rows[0]) {
|
||||||
|
return Some(StreamChange {
|
||||||
|
kind: StreamChangeKind::Delta,
|
||||||
|
cursor: next_cursor,
|
||||||
|
delta: Some(delta),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(StreamChange {
|
||||||
|
kind: StreamChangeKind::Resync,
|
||||||
|
cursor: next_cursor,
|
||||||
|
delta: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_stream_cursor_from_payload(payload: &Value) -> Option<String> {
|
||||||
|
read_string_field(payload, &["cursor"])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
|
||||||
|
if let Some(mut map) = payload.as_object().cloned() {
|
||||||
|
map.insert("kind".into(), Value::String(kind.into()));
|
||||||
|
return Value::Object(map);
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"kind": kind,
|
||||||
|
"data": payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_stream_delta_payload(
|
||||||
|
context: &RequestContext,
|
||||||
|
query: &StreamSnapshotQuery,
|
||||||
|
workspace_id: &str,
|
||||||
|
overview: &Value,
|
||||||
|
cursor: Option<String>,
|
||||||
|
delta: Value,
|
||||||
|
) -> Value {
|
||||||
|
let scope = resolve_stream_scope(query);
|
||||||
|
json!({
|
||||||
|
"kind": "delta",
|
||||||
|
"stream": scope.as_str(),
|
||||||
|
"projection": scope.projection(),
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
"workspaceId": workspace_id,
|
||||||
|
"rootNodeId": normalize_root_node_id(query),
|
||||||
|
"depth": query.depth,
|
||||||
|
"cursor": cursor,
|
||||||
|
"data": delta,
|
||||||
|
"snapshot": Value::Null,
|
||||||
|
"overview": overview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_stream_overview(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
query: &StreamSnapshotQuery,
|
||||||
|
) -> Result<(String, Value), WebError> {
|
||||||
|
let effective_workspace_id =
|
||||||
|
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
|
||||||
|
.expect("workspace_required 已确保存在");
|
||||||
|
let overview = execute_runtime_query_via_convex(
|
||||||
|
config,
|
||||||
|
context,
|
||||||
|
Some(&effective_workspace_id),
|
||||||
|
workspace_overview_query(&effective_workspace_id, query),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((effective_workspace_id, overview))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_stream_snapshot(
|
pub async fn load_stream_snapshot(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -102,17 +388,13 @@ pub async fn load_stream_snapshot(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
StreamSnapshotScope::Subtree => {
|
StreamSnapshotScope::Subtree => {
|
||||||
let root_node_id = query
|
let root_node_id = normalize_root_node_id(query)
|
||||||
.root_node_id
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.expect("subtree scope 已确保 rootNodeId 存在");
|
.expect("subtree scope 已确保 rootNodeId 存在");
|
||||||
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
||||||
let tree = execute_kernel_query(
|
let tree = execute_kernel_query(
|
||||||
context,
|
context,
|
||||||
&effective_workspace_id,
|
&effective_workspace_id,
|
||||||
subtree_query(&effective_workspace_id, root_node_id, query.depth),
|
subtree_query(&effective_workspace_id, &root_node_id, query.depth),
|
||||||
dataset.clone(),
|
dataset.clone(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -131,15 +413,20 @@ pub async fn load_stream_snapshot(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
let cursor = resolve_stream_cursor(overview.as_ref(), query.cursor.as_deref());
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"kind": "snapshot",
|
"kind": "snapshot",
|
||||||
"scope": scope.as_str(),
|
"scope": scope.as_str(),
|
||||||
|
"stream": scope.as_str(),
|
||||||
|
"projection": scope.projection(),
|
||||||
|
"cursor": cursor,
|
||||||
"requestId": context.trace.request_id,
|
"requestId": context.trace.request_id,
|
||||||
"traceId": context.trace.trace_id,
|
"traceId": context.trace.trace_id,
|
||||||
"workspaceId": effective_workspace_id,
|
"workspaceId": effective_workspace_id,
|
||||||
"rootNodeId": query.root_node_id,
|
"rootNodeId": normalize_root_node_id(query),
|
||||||
"depth": query.depth,
|
"depth": query.depth,
|
||||||
|
"data": snapshot,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"overview": overview,
|
"overview": overview,
|
||||||
}))
|
}))
|
||||||
@@ -147,7 +434,11 @@ pub async fn load_stream_snapshot(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope};
|
use super::{
|
||||||
|
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope,
|
||||||
|
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_scope_defaults_to_workspace() {
|
fn stream_scope_defaults_to_workspace() {
|
||||||
@@ -167,4 +458,130 @@ mod tests {
|
|||||||
StreamSnapshotScope::Subtree
|
StreamSnapshotScope::Subtree
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_cursor_prefers_newer_domain_event() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt_2",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_stream_cursor(Some(&overview), None),
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_2"}"#.into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_detects_delta_from_single_new_command() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.node.archive",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "remove_document",
|
||||||
|
"documentId": "page_2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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":"cmd_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.cursor,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "remove_document",
|
||||||
|
"documentId": "page_2"
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "page.body.save",
|
||||||
|
"payload": {
|
||||||
|
"documentId": "page_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":"cmd_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(change.delta, Some(json!({ "op": "noop" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_falls_back_to_resync_when_delta_is_unstable() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.subtree.move",
|
||||||
|
"payload": {
|
||||||
|
"documentId": "page_2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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":"cmd_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Resync);
|
||||||
|
assert_eq!(change.delta, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub struct TreeShellQuery {
|
|||||||
pub root_node_id: Option<String>,
|
pub root_node_id: Option<String>,
|
||||||
pub depth: Option<u32>,
|
pub depth: Option<u32>,
|
||||||
pub active_document_id: Option<String>,
|
pub active_document_id: Option<String>,
|
||||||
|
pub focused_document_id: Option<String>,
|
||||||
|
pub active_picker_item_key: Option<String>,
|
||||||
pub actor_id: Option<String>,
|
pub actor_id: Option<String>,
|
||||||
pub channel: Option<String>,
|
pub channel: Option<String>,
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
@@ -162,6 +164,8 @@ fn build_tree_shell_html(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
root_node_id: Option<&str>,
|
root_node_id: Option<&str>,
|
||||||
active_document_id: Option<&str>,
|
active_document_id: Option<&str>,
|
||||||
|
focused_document_id: Option<&str>,
|
||||||
|
active_picker_item_key: Option<&str>,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
host: Option<&str>,
|
host: Option<&str>,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -175,6 +179,8 @@ fn build_tree_shell_html(
|
|||||||
"workspaceId": workspace_id,
|
"workspaceId": workspace_id,
|
||||||
"rootNodeId": root_node_id,
|
"rootNodeId": root_node_id,
|
||||||
"activeDocumentId": active_document_id,
|
"activeDocumentId": active_document_id,
|
||||||
|
"focusedDocumentId": focused_document_id,
|
||||||
|
"activePickerItemKey": active_picker_item_key,
|
||||||
"actorId": context.auth.actor_id,
|
"actorId": context.auth.actor_id,
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"host": host,
|
"host": host,
|
||||||
@@ -663,6 +669,21 @@ fn build_tree_shell_html(
|
|||||||
.tree-kind-badge[data-kind="table"] {
|
.tree-kind-badge[data-kind="table"] {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
.tree-kind-badge[data-kind="pdf"] {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.tree-kind-badge[data-kind="book"] {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.tree-kind-badge[data-kind="image"] {
|
||||||
|
color: #0891b2;
|
||||||
|
}
|
||||||
|
.tree-kind-badge[data-kind="video"] {
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
.tree-kind-badge[data-kind="audio"] {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
.tree-kind-badge[data-kind="file"] {
|
.tree-kind-badge[data-kind="file"] {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
@@ -776,6 +797,14 @@ fn build_tree_shell_html(
|
|||||||
typeof state.activeDocumentId === "string" && state.activeDocumentId.trim()
|
typeof state.activeDocumentId === "string" && state.activeDocumentId.trim()
|
||||||
? state.activeDocumentId.trim()
|
? state.activeDocumentId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
const focusedDocumentId =
|
||||||
|
typeof state.focusedDocumentId === "string" && state.focusedDocumentId.trim()
|
||||||
|
? state.focusedDocumentId.trim()
|
||||||
|
: "";
|
||||||
|
const activePickerItemKey =
|
||||||
|
typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim()
|
||||||
|
? state.activePickerItemKey.trim()
|
||||||
|
: "";
|
||||||
const mode = (() => {
|
const mode = (() => {
|
||||||
const rawMode =
|
const rawMode =
|
||||||
typeof state.mode === "string" ? state.mode.trim() : "";
|
typeof state.mode === "string" ? state.mode.trim() : "";
|
||||||
@@ -938,19 +967,43 @@ fn build_tree_shell_html(
|
|||||||
.filter((item) => item.childCount > 0 && item.expandedByDefault)
|
.filter((item) => item.childCount > 0 && item.expandedByDefault)
|
||||||
.map((item) => item.nodeId),
|
.map((item) => item.nodeId),
|
||||||
);
|
);
|
||||||
let focusedNodeId =
|
let currentActiveDocumentId = activeDocumentId;
|
||||||
activeDocumentId && itemById.has(activeDocumentId)
|
let currentFocusedDocumentId = focusedDocumentId;
|
||||||
? activeDocumentId
|
let currentActivePickerItemKey = activePickerItemKey;
|
||||||
: roots[0]?.nodeId || "";
|
const resolvePickerRootFocused = () =>
|
||||||
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
|
mode === "picker" && currentActivePickerItemKey === "__root__";
|
||||||
let fileTreeAnchorRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
|
const resolveFocusedNodeIdFromHostState = () => {
|
||||||
let fileTreeFocusedRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
|
const pickerRootFocused = resolvePickerRootFocused();
|
||||||
|
return mode === "picker"
|
||||||
|
? currentActivePickerItemKey &&
|
||||||
|
currentActivePickerItemKey !== "__root__" &&
|
||||||
|
itemById.has(currentActivePickerItemKey)
|
||||||
|
? currentActivePickerItemKey
|
||||||
|
: currentActiveDocumentId && itemById.has(currentActiveDocumentId)
|
||||||
|
? currentActiveDocumentId
|
||||||
|
: pickerRootFocused
|
||||||
|
? ""
|
||||||
|
: roots[0]?.nodeId || ""
|
||||||
|
: currentFocusedDocumentId && itemById.has(currentFocusedDocumentId)
|
||||||
|
? currentFocusedDocumentId
|
||||||
|
: currentActiveDocumentId && itemById.has(currentActiveDocumentId)
|
||||||
|
? currentActiveDocumentId
|
||||||
|
: roots[0]?.nodeId || "";
|
||||||
|
};
|
||||||
|
let focusedNodeId = resolveFocusedNodeIdFromHostState();
|
||||||
|
let selectedFileTreeRowIds = new Set(
|
||||||
|
currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : []
|
||||||
|
);
|
||||||
|
let fileTreeAnchorRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null;
|
||||||
|
let fileTreeFocusedRowId = currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null;
|
||||||
let visibleFileTreeRowIds = [];
|
let visibleFileTreeRowIds = [];
|
||||||
|
let draggingPageNodeId = "";
|
||||||
|
let activePageDropNodeId = null;
|
||||||
let draggingFileTreeRowIds = [];
|
let draggingFileTreeRowIds = [];
|
||||||
let activeFileTreeDropRowId = null;
|
let activeFileTreeDropRowId = null;
|
||||||
let activeFileTreeRootDrop = false;
|
let activeFileTreeRootDrop = false;
|
||||||
|
|
||||||
let activeCursor = itemById.get(activeDocumentId) || null;
|
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
||||||
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
||||||
expanded.add(activeCursor.parentNodeId);
|
expanded.add(activeCursor.parentNodeId);
|
||||||
activeCursor = itemById.get(activeCursor.parentNodeId) || null;
|
activeCursor = itemById.get(activeCursor.parentNodeId) || null;
|
||||||
@@ -1400,6 +1453,36 @@ fn build_tree_shell_html(
|
|||||||
<path d="M3.8 6.6h8.4M6.6 3.8v8.4M9.4 3.8v8.4" stroke="currentColor" stroke-width="1.1"/>
|
<path d="M3.8 6.6h8.4M6.6 3.8v8.4M9.4 3.8v8.4" stroke="currentColor" stroke-width="1.1"/>
|
||||||
</svg>
|
</svg>
|
||||||
`,
|
`,
|
||||||
|
pdf: `
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 10.8V6.5h1.5a1.2 1.2 0 1 1 0 2.4H6m3.2-2.4v4.3m0 0c1.1 0 1.8-.8 1.8-2.1 0-1.3-.7-2.2-1.8-2.2m-1.7 4.3h1.7" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
book: `
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4.2 3.2h6.2a1.6 1.6 0 0 1 1.6 1.6v7.4H5.4a1.2 1.2 0 0 0-1.2 1.2V4.4a1.2 1.2 0 0 1 1.2-1.2Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
<path d="M5.4 12.2V4.1M7 6h3.1M7 8.2h3.1" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
image: `
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
|
||||||
|
<circle cx="6.2" cy="6.2" r="1.1" stroke="currentColor" stroke-width="1"/>
|
||||||
|
<path d="M4.5 11 7.1 8.6l1.8 1.7 1.7-1.5L12 11" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
video: `
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="3.4" width="7.8" height="9.2" rx="1.4" stroke="currentColor" stroke-width="1.2"/>
|
||||||
|
<path d="m9.8 7 2.8-1.7v5.4L9.8 9" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
audio: `
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6.4 4.2v7.6a1.5 1.5 0 1 1-1-1.4V5.6l5.2-1.2v5.2a1.5 1.5 0 1 1-1-1.4V3.5L6.4 4.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
file: `
|
file: `
|
||||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
@@ -1432,10 +1515,16 @@ fn build_tree_shell_html(
|
|||||||
throw new Error(await readErrorMessage(response));
|
throw new Error(await readErrorMessage(response));
|
||||||
}
|
}
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (!data || data.ok !== true || !data.result) {
|
if (!data || typeof data !== "object") {
|
||||||
throw new Error("tree command 返回了无效响应");
|
throw new Error("tree command 返回了无效响应");
|
||||||
}
|
}
|
||||||
return data.result;
|
if (data.ok === true && data.result) {
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
if (data.result && typeof data.result === "object") {
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -1453,9 +1542,101 @@ fn build_tree_shell_html(
|
|||||||
return (childrenByParentId.get(parentId) || []).slice();
|
return (childrenByParentId.get(parentId) || []).slice();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_DRAG_MIME = "application/x-mnote-page-tree-node";
|
||||||
|
|
||||||
|
const clearPageDropFeedback = () => {
|
||||||
|
if (!activePageDropNodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousRow = appElement.querySelector(
|
||||||
|
`.tree-row[data-shell-mode="page"][data-node-id="${activePageDropNodeId}"]`,
|
||||||
|
);
|
||||||
|
if (previousRow instanceof HTMLElement) {
|
||||||
|
previousRow.dataset.dropFeedback = "false";
|
||||||
|
}
|
||||||
|
activePageDropNodeId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPageDropFeedback = (nodeId) => {
|
||||||
|
const nextNodeId = normalizeText(nodeId);
|
||||||
|
if (activePageDropNodeId && activePageDropNodeId !== nextNodeId) {
|
||||||
|
const previousRow = appElement.querySelector(
|
||||||
|
`.tree-row[data-shell-mode="page"][data-node-id="${activePageDropNodeId}"]`,
|
||||||
|
);
|
||||||
|
if (previousRow instanceof HTMLElement) {
|
||||||
|
previousRow.dataset.dropFeedback = "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextNodeId) {
|
||||||
|
activePageDropNodeId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRow = appElement.querySelector(
|
||||||
|
`.tree-row[data-shell-mode="page"][data-node-id="${nextNodeId}"]`,
|
||||||
|
);
|
||||||
|
if (nextRow instanceof HTMLElement) {
|
||||||
|
nextRow.dataset.dropFeedback = "true";
|
||||||
|
}
|
||||||
|
activePageDropNodeId = nextNodeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePageDropTargetNodeId = (element) => {
|
||||||
|
const row = element instanceof Element
|
||||||
|
? element.closest('.tree-row[data-shell-mode="page"]')
|
||||||
|
: null;
|
||||||
|
if (!(row instanceof HTMLElement)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return normalizeText(row.dataset.nodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPageDragNodeId = (event) => {
|
||||||
|
const raw =
|
||||||
|
event.dataTransfer?.getData(PAGE_DRAG_MIME) ||
|
||||||
|
event.dataTransfer?.getData("text/plain") ||
|
||||||
|
draggingPageNodeId ||
|
||||||
|
"";
|
||||||
|
return normalizeText(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAcceptPageDrop = (sourceNodeId, targetNodeId) => {
|
||||||
|
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sourceItem = itemById.get(sourceNodeId);
|
||||||
|
const targetItem = itemById.get(targetNodeId);
|
||||||
|
if (!sourceItem || !targetItem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return sourceItem.parentNodeId === targetItem.parentNodeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postPageExpandChange = (nodeId, nextExpanded) => {
|
||||||
|
if (mode !== "page" || !nodeId) return;
|
||||||
|
postToHost("tree.page.expand.changed", {
|
||||||
|
documentId: nodeId,
|
||||||
|
expanded: nextExpanded === true,
|
||||||
|
target: { documentId: nodeId },
|
||||||
|
payload: { documentId: nodeId, expanded: nextExpanded === true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const postPageFocusChange = (nodeId) => {
|
||||||
|
if (mode !== "page" || !nodeId) return;
|
||||||
|
postToHost("tree.page.focus.changed", {
|
||||||
|
documentId: nodeId,
|
||||||
|
target: { documentId: nodeId },
|
||||||
|
payload: { documentId: nodeId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toggleExpand = (nodeId) => {
|
const toggleExpand = (nodeId) => {
|
||||||
if (expanded.has(nodeId)) expanded.delete(nodeId);
|
const nextExpanded = !expanded.has(nodeId);
|
||||||
else expanded.add(nodeId);
|
if (nextExpanded) expanded.add(nodeId);
|
||||||
|
else expanded.delete(nodeId);
|
||||||
|
postPageExpandChange(nodeId, nextExpanded);
|
||||||
renderTree();
|
renderTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1473,13 +1654,150 @@ fn build_tree_shell_html(
|
|||||||
return visible;
|
return visible;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getVisiblePickerEntries = () => {
|
||||||
|
if (mode !== "picker") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = [];
|
||||||
|
if (allowRootPick) {
|
||||||
|
visible.push({
|
||||||
|
pickerItemKey: "__root__",
|
||||||
|
item: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const walk = (entries) => {
|
||||||
|
entries.forEach((item) => {
|
||||||
|
visible.push({
|
||||||
|
pickerItemKey: item.nodeId,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
||||||
|
walk(getSiblings(item.nodeId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(roots);
|
||||||
|
return visible;
|
||||||
|
};
|
||||||
|
|
||||||
const focusNode = (nodeId) => {
|
const focusNode = (nodeId) => {
|
||||||
if (!nodeId || !itemById.has(nodeId)) return;
|
if (!nodeId || !itemById.has(nodeId)) return;
|
||||||
|
if (focusedNodeId === nodeId) {
|
||||||
|
focusRowElement(nodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
focusedNodeId = nodeId;
|
focusedNodeId = nodeId;
|
||||||
|
postPageFocusChange(nodeId);
|
||||||
renderTree();
|
renderTree();
|
||||||
focusRowElement(nodeId);
|
focusRowElement(nodeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const postPickerFocusChange = (pickerItemKey) => {
|
||||||
|
if (mode !== "picker") return;
|
||||||
|
const normalizedItemKey = normalizeText(pickerItemKey);
|
||||||
|
const documentId =
|
||||||
|
normalizedItemKey && normalizedItemKey !== "__root__"
|
||||||
|
? normalizedItemKey
|
||||||
|
: null;
|
||||||
|
postToHost("tree.picker.focus.changed", {
|
||||||
|
documentId,
|
||||||
|
itemKey: normalizedItemKey || null,
|
||||||
|
pickerItemKey: normalizedItemKey || null,
|
||||||
|
target: { documentId },
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
itemKey: normalizedItemKey || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPickerFocusByItemKey = (pickerItemKey) => {
|
||||||
|
if (mode !== "picker") return;
|
||||||
|
|
||||||
|
const normalizedItemKey = normalizeText(pickerItemKey);
|
||||||
|
const nextPickerItemKey =
|
||||||
|
normalizedItemKey === "__root__"
|
||||||
|
? "__root__"
|
||||||
|
: itemById.has(normalizedItemKey)
|
||||||
|
? normalizedItemKey
|
||||||
|
: "";
|
||||||
|
const nextDocumentId =
|
||||||
|
nextPickerItemKey && nextPickerItemKey !== "__root__"
|
||||||
|
? nextPickerItemKey
|
||||||
|
: null;
|
||||||
|
|
||||||
|
currentActivePickerItemKey = nextPickerItemKey;
|
||||||
|
currentActiveDocumentId = nextDocumentId;
|
||||||
|
focusedNodeId = nextDocumentId || "";
|
||||||
|
renderTree();
|
||||||
|
if (nextDocumentId) {
|
||||||
|
focusRowElement(nextDocumentId);
|
||||||
|
}
|
||||||
|
postPickerFocusChange(nextPickerItemKey || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickerCommand = (command) => {
|
||||||
|
if (mode !== "picker") return;
|
||||||
|
|
||||||
|
const normalizedCommand = normalizeText(command);
|
||||||
|
const visible = getVisiblePickerEntries();
|
||||||
|
if (visible.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPickerItemKey =
|
||||||
|
currentActivePickerItemKey ||
|
||||||
|
(currentActiveDocumentId && itemById.has(currentActiveDocumentId)
|
||||||
|
? currentActiveDocumentId
|
||||||
|
: allowRootPick
|
||||||
|
? "__root__"
|
||||||
|
: visible[0]?.pickerItemKey || "");
|
||||||
|
const currentIndex = visible.findIndex(
|
||||||
|
(entry) => entry.pickerItemKey === currentPickerItemKey,
|
||||||
|
);
|
||||||
|
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
|
|
||||||
|
if (normalizedCommand === "pick") {
|
||||||
|
const target = visible[resolvedIndex];
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.pickerItemKey === "__root__") {
|
||||||
|
setLastAction("已选择根目录");
|
||||||
|
postToHost("tree.pick.root", {
|
||||||
|
documentId: null,
|
||||||
|
target: { documentId: null },
|
||||||
|
payload: { documentId: null },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleNavigate(target.pickerItemKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = resolvedIndex;
|
||||||
|
if (normalizedCommand === "next") {
|
||||||
|
nextIndex = Math.min(visible.length - 1, resolvedIndex + 1);
|
||||||
|
} else if (normalizedCommand === "previous") {
|
||||||
|
nextIndex = Math.max(0, resolvedIndex - 1);
|
||||||
|
} else if (normalizedCommand === "home") {
|
||||||
|
nextIndex = 0;
|
||||||
|
} else if (normalizedCommand === "end") {
|
||||||
|
nextIndex = visible.length - 1;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = visible[nextIndex];
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyPickerFocusByItemKey(target.pickerItemKey);
|
||||||
|
};
|
||||||
|
|
||||||
const openFileTreeContextMenu = ({
|
const openFileTreeContextMenu = ({
|
||||||
documentId,
|
documentId,
|
||||||
assetId,
|
assetId,
|
||||||
@@ -1575,6 +1893,7 @@ fn build_tree_shell_html(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (item.childCount > 0 && !expanded.has(item.nodeId)) {
|
if (item.childCount > 0 && !expanded.has(item.nodeId)) {
|
||||||
expanded.add(item.nodeId);
|
expanded.add(item.nodeId);
|
||||||
|
postPageExpandChange(item.nodeId, true);
|
||||||
renderTree();
|
renderTree();
|
||||||
focusRowElement(item.nodeId);
|
focusRowElement(item.nodeId);
|
||||||
return;
|
return;
|
||||||
@@ -1589,6 +1908,7 @@ fn build_tree_shell_html(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
||||||
expanded.delete(item.nodeId);
|
expanded.delete(item.nodeId);
|
||||||
|
postPageExpandChange(item.nodeId, false);
|
||||||
renderTree();
|
renderTree();
|
||||||
focusRowElement(item.nodeId);
|
focusRowElement(item.nodeId);
|
||||||
return;
|
return;
|
||||||
@@ -1747,6 +2067,41 @@ fn build_tree_shell_html(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePageDropMove = async (sourceNodeId, targetNodeId) => {
|
||||||
|
const sourceItem = itemById.get(sourceNodeId);
|
||||||
|
const targetItem = itemById.get(targetNodeId);
|
||||||
|
if (!sourceItem || !targetItem) return;
|
||||||
|
const siblings = getSiblings(targetItem.parentNodeId);
|
||||||
|
const targetIndex = siblings.findIndex((entry) => entry.nodeId === targetNodeId);
|
||||||
|
if (targetIndex < 0) return;
|
||||||
|
try {
|
||||||
|
const result = await sendCommand({
|
||||||
|
action: "move",
|
||||||
|
workspaceId,
|
||||||
|
documentId: sourceNodeId,
|
||||||
|
parentId: targetItem.parentNodeId,
|
||||||
|
sortOrder: targetIndex,
|
||||||
|
});
|
||||||
|
const documentId =
|
||||||
|
typeof result.documentId === "string" && result.documentId.trim()
|
||||||
|
? result.documentId.trim()
|
||||||
|
: sourceNodeId;
|
||||||
|
setStatus("移动页面成功");
|
||||||
|
setLastAction(`页面已拖放到 ${targetItem.title}`);
|
||||||
|
postToHost("tree.subtree.moved", {
|
||||||
|
documentId,
|
||||||
|
target: { documentId },
|
||||||
|
payload: { documentId },
|
||||||
|
});
|
||||||
|
scheduleRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "拖拽移动失败";
|
||||||
|
setStatus(message, "error");
|
||||||
|
setLastAction("拖拽移动失败", "error");
|
||||||
|
window.alert(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createKindBadge = (kind) => {
|
const createKindBadge = (kind) => {
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "tree-kind-badge";
|
badge.className = "tree-kind-badge";
|
||||||
@@ -1756,9 +2111,19 @@ fn build_tree_shell_html(
|
|||||||
? ICONS.mindmap
|
? ICONS.mindmap
|
||||||
: kind === "table"
|
: kind === "table"
|
||||||
? ICONS.table
|
? ICONS.table
|
||||||
: kind === "index"
|
: kind === "pdf"
|
||||||
? ICONS.index
|
? ICONS.pdf
|
||||||
: kind === "page"
|
: kind === "book"
|
||||||
|
? ICONS.book
|
||||||
|
: kind === "image"
|
||||||
|
? ICONS.image
|
||||||
|
: kind === "video"
|
||||||
|
? ICONS.video
|
||||||
|
: kind === "audio"
|
||||||
|
? ICONS.audio
|
||||||
|
: kind === "index"
|
||||||
|
? ICONS.index
|
||||||
|
: kind === "page"
|
||||||
? ICONS.page
|
? ICONS.page
|
||||||
: ICONS.file;
|
: ICONS.file;
|
||||||
return badge;
|
return badge;
|
||||||
@@ -1784,17 +2149,21 @@ fn build_tree_shell_html(
|
|||||||
const hasChildren = item.childCount > 0;
|
const hasChildren = item.childCount > 0;
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "tree-row";
|
row.className = "tree-row";
|
||||||
row.dataset.active = String(item.nodeId === activeDocumentId);
|
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
|
||||||
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
||||||
row.dataset.nodeId = item.nodeId;
|
row.dataset.nodeId = item.nodeId;
|
||||||
row.dataset.shellMode = mode;
|
row.dataset.shellMode = mode;
|
||||||
|
row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId);
|
||||||
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
|
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
|
||||||
row.setAttribute("role", "treeitem");
|
row.setAttribute("role", "treeitem");
|
||||||
row.setAttribute("aria-level", String(item.depth + 1));
|
row.setAttribute("aria-level", String(item.depth + 1));
|
||||||
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
||||||
|
row.draggable = mode === "page";
|
||||||
|
row.dataset.draggable = String(mode === "page");
|
||||||
row.addEventListener("focus", () => {
|
row.addEventListener("focus", () => {
|
||||||
if (focusedNodeId !== item.nodeId) {
|
if (focusedNodeId !== item.nodeId) {
|
||||||
focusedNodeId = item.nodeId;
|
focusedNodeId = item.nodeId;
|
||||||
|
postPageFocusChange(item.nodeId);
|
||||||
renderTree();
|
renderTree();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1804,6 +2173,58 @@ fn build_tree_shell_html(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openContextMenu(item.nodeId, event.clientX, event.clientY);
|
openContextMenu(item.nodeId, event.clientX, event.clientY);
|
||||||
});
|
});
|
||||||
|
row.addEventListener("dragstart", (event) => {
|
||||||
|
if (mode !== "page") return;
|
||||||
|
draggingPageNodeId = item.nodeId;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId);
|
||||||
|
event.dataTransfer.setData("text/plain", item.nodeId);
|
||||||
|
}
|
||||||
|
setLastAction(`开始拖拽页面 ${item.title}`);
|
||||||
|
});
|
||||||
|
row.addEventListener("dragover", (event) => {
|
||||||
|
if (mode !== "page") return;
|
||||||
|
const sourceNodeId = readPageDragNodeId(event);
|
||||||
|
const targetNodeId = resolvePageDropTargetNodeId(event.target);
|
||||||
|
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
|
||||||
|
clearPageDropFeedback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
setPageDropFeedback(targetNodeId);
|
||||||
|
});
|
||||||
|
row.addEventListener("dragleave", (event) => {
|
||||||
|
if (mode !== "page") return;
|
||||||
|
const relatedTarget =
|
||||||
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
||||||
|
if (relatedTarget && row.contains(relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activePageDropNodeId === item.nodeId) {
|
||||||
|
clearPageDropFeedback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.addEventListener("drop", (event) => {
|
||||||
|
if (mode !== "page") return;
|
||||||
|
const sourceNodeId = readPageDragNodeId(event);
|
||||||
|
const targetNodeId = resolvePageDropTargetNodeId(event.target);
|
||||||
|
clearPageDropFeedback();
|
||||||
|
draggingPageNodeId = "";
|
||||||
|
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void handlePageDropMove(sourceNodeId, targetNodeId);
|
||||||
|
});
|
||||||
|
row.addEventListener("dragend", () => {
|
||||||
|
if (mode !== "page") return;
|
||||||
|
draggingPageNodeId = "";
|
||||||
|
clearPageDropFeedback();
|
||||||
|
});
|
||||||
|
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
const toggleButton = document.createElement("button");
|
const toggleButton = document.createElement("button");
|
||||||
@@ -2008,7 +2429,9 @@ fn build_tree_shell_html(
|
|||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "tree-row";
|
row.className = "tree-row";
|
||||||
row.style.marginLeft = `${item.depth * 22}px`;
|
row.style.marginLeft = `${item.depth * 22}px`;
|
||||||
row.dataset.active = String(item.rowKind === "document" && documentId === activeDocumentId);
|
row.dataset.active = String(
|
||||||
|
item.rowKind === "document" && documentId === currentActiveDocumentId
|
||||||
|
);
|
||||||
row.dataset.nodeId = item.nodeId;
|
row.dataset.nodeId = item.nodeId;
|
||||||
row.dataset.rowId = item.rowId;
|
row.dataset.rowId = item.rowId;
|
||||||
row.dataset.rowKind = item.rowKind;
|
row.dataset.rowKind = item.rowKind;
|
||||||
@@ -2195,6 +2618,7 @@ fn build_tree_shell_html(
|
|||||||
rootButton.type = "button";
|
rootButton.type = "button";
|
||||||
rootButton.className = "tree-row";
|
rootButton.className = "tree-row";
|
||||||
rootButton.setAttribute("data-testid", "tree-picker-root");
|
rootButton.setAttribute("data-testid", "tree-picker-root");
|
||||||
|
rootButton.dataset.focused = String(resolvePickerRootFocused());
|
||||||
rootButton.addEventListener("click", () => {
|
rootButton.addEventListener("click", () => {
|
||||||
setLastAction("已选择根目录");
|
setLastAction("已选择根目录");
|
||||||
postToHost("tree.pick.root", {
|
postToHost("tree.pick.root", {
|
||||||
@@ -2244,6 +2668,52 @@ fn build_tree_shell_html(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const payload = event.data;
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalizeText(payload.channel) !== channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageType = normalizeText(payload.type);
|
||||||
|
if (messageType === "tree.picker.command") {
|
||||||
|
handlePickerCommand(payload.command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messageType !== "tree.shell.state.patch") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextActiveDocumentId = normalizeText(payload.activeDocumentId);
|
||||||
|
const nextFocusedDocumentId = normalizeText(payload.focusedDocumentId);
|
||||||
|
const nextActivePickerItemKey = normalizeText(payload.activePickerItemKey);
|
||||||
|
|
||||||
|
if (nextActiveDocumentId !== currentActiveDocumentId) {
|
||||||
|
currentActiveDocumentId = nextActiveDocumentId;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (nextFocusedDocumentId !== currentFocusedDocumentId) {
|
||||||
|
currentFocusedDocumentId = nextFocusedDocumentId;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (nextActivePickerItemKey !== currentActivePickerItemKey) {
|
||||||
|
currentActivePickerItemKey = nextActivePickerItemKey;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
||||||
|
renderTree();
|
||||||
|
if (mode === "page" && focusedNodeId) {
|
||||||
|
focusRowElement(focusedNodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
createRootButton.addEventListener("click", () => {
|
createRootButton.addEventListener("click", () => {
|
||||||
if (mode === "picker") return;
|
if (mode === "picker") return;
|
||||||
void handleCreate(null);
|
void handleCreate(null);
|
||||||
@@ -2257,6 +2727,9 @@ fn build_tree_shell_html(
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderTree();
|
renderTree();
|
||||||
|
if (mode === "page" && focusedNodeId) {
|
||||||
|
postPageFocusChange(focusedNodeId);
|
||||||
|
}
|
||||||
if (mode === "filetree") {
|
if (mode === "filetree") {
|
||||||
emitFileTreeSelectionChange();
|
emitFileTreeSelectionChange();
|
||||||
}
|
}
|
||||||
@@ -2328,6 +2801,8 @@ pub async fn tree_shell(
|
|||||||
&effective_workspace_id,
|
&effective_workspace_id,
|
||||||
query.root_node_id.as_deref(),
|
query.root_node_id.as_deref(),
|
||||||
query.active_document_id.as_deref(),
|
query.active_document_id.as_deref(),
|
||||||
|
query.focused_document_id.as_deref(),
|
||||||
|
query.active_picker_item_key.as_deref(),
|
||||||
&normalize_channel(query.channel),
|
&normalize_channel(query.channel),
|
||||||
query.host.as_deref(),
|
query.host.as_deref(),
|
||||||
&effective_context,
|
&effective_context,
|
||||||
@@ -2389,6 +2864,7 @@ fn create_command_wire(
|
|||||||
"accessScope": access_scope,
|
"accessScope": access_scope,
|
||||||
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
|
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("tree-shell create".into()),
|
reason: Some("tree-shell create".into()),
|
||||||
refs: vec!["mnote-web-tree".into()],
|
refs: vec!["mnote-web-tree".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -2423,6 +2899,7 @@ fn create_command_wire(
|
|||||||
"documentId": document_id,
|
"documentId": document_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("tree-shell rename".into()),
|
reason: Some("tree-shell rename".into()),
|
||||||
refs: vec!["mnote-web-tree".into()],
|
refs: vec!["mnote-web-tree".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -2461,6 +2938,7 @@ fn create_command_wire(
|
|||||||
"parentId": parent_id,
|
"parentId": parent_id,
|
||||||
"sortOrder": sort_order,
|
"sortOrder": sort_order,
|
||||||
}),
|
}),
|
||||||
|
preflight_data: None,
|
||||||
reason: Some("tree-shell move".into()),
|
reason: Some("tree-shell move".into()),
|
||||||
refs: vec!["mnote-web-tree".into()],
|
refs: vec!["mnote-web-tree".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
@@ -2645,6 +3123,11 @@ mod tests {
|
|||||||
assert!(html.contains("test-shell"));
|
assert!(html.contains("test-shell"));
|
||||||
assert!(html.contains("tree-action-menu"));
|
assert!(html.contains("tree-action-menu"));
|
||||||
assert!(html.contains("tree.page.context-menu"));
|
assert!(html.contains("tree.page.context-menu"));
|
||||||
|
assert!(html.contains("tree.page.expand.changed"));
|
||||||
|
assert!(html.contains("tree.page.focus.changed"));
|
||||||
|
assert!(html.contains("tree.shell.state.patch"));
|
||||||
|
assert!(html.contains("application/x-mnote-page-tree-node"));
|
||||||
|
assert!(html.contains("页面已拖放到"));
|
||||||
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||||
assert!(html.contains("setAttribute(\"aria-level\""));
|
assert!(html.contains("setAttribute(\"aria-level\""));
|
||||||
}
|
}
|
||||||
@@ -2670,6 +3153,8 @@ mod tests {
|
|||||||
assert!(html.contains("\"allowRootPick\":true"));
|
assert!(html.contains("\"allowRootPick\":true"));
|
||||||
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
||||||
assert!(html.contains("tree.pick.root"));
|
assert!(html.contains("tree.pick.root"));
|
||||||
|
assert!(html.contains("tree.picker.command"));
|
||||||
|
assert!(html.contains("tree.picker.focus.changed"));
|
||||||
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}
|
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// 说明:
|
||||||
|
// - 这份 smoke 只验证 rust_family picker 的键盘高亮与 Enter 选中。
|
||||||
|
// - 这里刻意使用 fresh-open 的最短链路,避免把空态/多轮搜索切换的抖动混进同一条回归里。
|
||||||
|
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
createTempDocument,
|
||||||
|
ensureAuthenticated,
|
||||||
|
ensurePageOptionsVisible,
|
||||||
|
openDocument,
|
||||||
|
purgeDocument,
|
||||||
|
renameDocument,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const headless =
|
||||||
|
process.env.MNOTE_SMOKE_HEADLESS === "1"
|
||||||
|
? true
|
||||||
|
: process.env.MNOTE_SMOKE_HEADLESS === "0"
|
||||||
|
? false
|
||||||
|
: !process.env.DISPLAY;
|
||||||
|
const browser = await chromium.launch({ headless });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 960 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
let fixture = null;
|
||||||
|
let caughtError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAuthenticated(page, context.request);
|
||||||
|
const uniqueSuffix = Date.now().toString();
|
||||||
|
const parent = await createTempDocument(context.request, null);
|
||||||
|
const childA = await createTempDocument(context.request, parent.documentId);
|
||||||
|
const target = await createTempDocument(context.request, null);
|
||||||
|
|
||||||
|
fixture = {
|
||||||
|
workspaceId: parent.workspaceId,
|
||||||
|
parentId: parent.documentId,
|
||||||
|
childAId: childA.documentId,
|
||||||
|
targetId: target.documentId,
|
||||||
|
parentTitle: `task113-parent-${uniqueSuffix}`,
|
||||||
|
childATitle: `task113-child-a-${uniqueSuffix}`,
|
||||||
|
targetTitle: `task113-target-${uniqueSuffix}`,
|
||||||
|
createdIds: [parent.documentId, childA.documentId, target.documentId],
|
||||||
|
};
|
||||||
|
|
||||||
|
await renameDocument(context.request, fixture.workspaceId, fixture.parentId, fixture.parentTitle);
|
||||||
|
await renameDocument(context.request, fixture.workspaceId, fixture.childAId, fixture.childATitle);
|
||||||
|
await renameDocument(context.request, fixture.workspaceId, fixture.targetId, fixture.targetTitle);
|
||||||
|
|
||||||
|
await openDocument(page, fixture.workspaceId, fixture.childAId);
|
||||||
|
await ensurePageOptionsVisible(page);
|
||||||
|
const openButton = page.getByRole("button", { name: "移动/嵌入到..." });
|
||||||
|
await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await openButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const searchInput = dialog.getByPlaceholder("移动到...");
|
||||||
|
const keyboardQuery = `task113-keyboard-${Date.now()}`;
|
||||||
|
|
||||||
|
await page.route("**/api/search/documents", async (route) => {
|
||||||
|
const payload = route.request().postDataJSON();
|
||||||
|
if (payload?.query !== keyboardQuery) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: fixture.parentId,
|
||||||
|
title: fixture.parentTitle,
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: fixture.targetId,
|
||||||
|
title: fixture.targetTitle,
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await searchInput.fill(keyboardQuery, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(nodeId) => {
|
||||||
|
const frame = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
|
)
|
||||||
|
.reverse()
|
||||||
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
||||||
|
if (!(frame instanceof HTMLIFrameElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(frame.contentDocument?.querySelector(`.tree-row[data-node-id="${nodeId}"]`));
|
||||||
|
},
|
||||||
|
fixture.targetId,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
await searchInput.focus();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
return active instanceof HTMLInputElement && active.placeholder === "移动到...";
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
const readFocusedPickerItemKey = async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const frame = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
|
)
|
||||||
|
.reverse()
|
||||||
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
||||||
|
if (!(frame instanceof HTMLIFrameElement)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const rootButton = frame.contentDocument?.querySelector(
|
||||||
|
'.tree-row[data-testid="tree-picker-root"][data-focused="true"]',
|
||||||
|
);
|
||||||
|
if (rootButton) {
|
||||||
|
return "__root__";
|
||||||
|
}
|
||||||
|
const row = frame.contentDocument?.querySelector('.tree-row[data-focused="true"]');
|
||||||
|
return row ? row.getAttribute("data-node-id") || "" : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveHighlightTo = async (targetNodeId, maxSteps = 4) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const frame = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
|
)
|
||||||
|
.reverse()
|
||||||
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
||||||
|
if (!(frame instanceof HTMLIFrameElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rootButton = frame.contentDocument?.querySelector(
|
||||||
|
'.tree-row[data-testid="tree-picker-root"][data-focused="true"]',
|
||||||
|
);
|
||||||
|
if (rootButton) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(frame.contentDocument?.querySelector('.tree-row[data-focused="true"]'));
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let index = 0; index < maxSteps; index += 1) {
|
||||||
|
const currentKey = await readFocusedPickerItemKey();
|
||||||
|
if (currentKey === targetNodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await page.waitForFunction(
|
||||||
|
(previousKey) => {
|
||||||
|
const frame = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
|
)
|
||||||
|
.reverse()
|
||||||
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
||||||
|
if (!(frame instanceof HTMLIFrameElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rootButton = frame.contentDocument?.querySelector(
|
||||||
|
'.tree-row[data-testid="tree-picker-root"][data-focused="true"]',
|
||||||
|
);
|
||||||
|
const row = frame.contentDocument?.querySelector('.tree-row[data-focused="true"]');
|
||||||
|
const currentKey = rootButton
|
||||||
|
? "__root__"
|
||||||
|
: row
|
||||||
|
? row.getAttribute("data-node-id") || ""
|
||||||
|
: "";
|
||||||
|
return currentKey !== previousKey;
|
||||||
|
},
|
||||||
|
currentKey,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
return active instanceof HTMLInputElement && active.placeholder === "移动到...";
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalKey = await readFocusedPickerItemKey();
|
||||||
|
if (finalKey !== targetNodeId) {
|
||||||
|
throw new Error(`picker 键盘高亮未落到目标节点:当前=${finalKey || "<empty>"} 目标=${targetNodeId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await moveHighlightTo(fixture.targetId);
|
||||||
|
|
||||||
|
const moveRequest = page.waitForResponse(
|
||||||
|
async (response) => {
|
||||||
|
if (
|
||||||
|
!response.url().includes("/api/tree/commands") ||
|
||||||
|
response.request().method() !== "POST" ||
|
||||||
|
!response.ok()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = response.request().postDataJSON();
|
||||||
|
return payload?.action === "move" && payload?.documentId === fixture.childAId && payload?.parentId === fixture.targetId;
|
||||||
|
},
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
await searchInput.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await moveRequest;
|
||||||
|
await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||||
|
} finally {
|
||||||
|
await page.unroute("**/api/search/documents").catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
workspaceId: fixture.workspaceId,
|
||||||
|
sourceId: fixture.childAId,
|
||||||
|
targetId: fixture.targetId,
|
||||||
|
searchQuery: keyboardQuery,
|
||||||
|
pickerKeyboardHighlight: true,
|
||||||
|
pickerKeyboardSelect: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
} finally {
|
||||||
|
if (fixture) {
|
||||||
|
for (const documentId of [...fixture.createdIds].reverse()) {
|
||||||
|
try {
|
||||||
|
await purgeDocument(context.request, documentId);
|
||||||
|
} catch {
|
||||||
|
// 说明:移动后父级与目标级的清理顺序可能变化,这里忽略重复清理错误。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.close().catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caughtError) {
|
||||||
|
throw caughtError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
|
|||||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||||
const TEST_USERNAME_PREFIX = "测试用户";
|
const TEST_USERNAME_PREFIX = "测试用户";
|
||||||
|
const TEST_EMAIL = "test@example.com";
|
||||||
|
const TEST_PASSWORD = "Test123456";
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -65,15 +67,33 @@ async function requestJson(requestContext, path, init = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createTempDocument(requestContext, parentId = null) {
|
async function createTempDocument(requestContext, parentId = null) {
|
||||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
const payload = await requestJson(requestContext, "/api/tree/commands", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: { parentId },
|
data: {
|
||||||
|
action: "create",
|
||||||
|
parentId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
const result = payload && typeof payload.result === "object" ? payload.result : null;
|
||||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
const documentId =
|
||||||
|
result && typeof result.documentId === "string"
|
||||||
|
? result.documentId
|
||||||
|
: payload && typeof payload.id === "string"
|
||||||
|
? payload.id
|
||||||
|
: "";
|
||||||
|
const workspaceId =
|
||||||
|
result && typeof result.workspaceId === "string"
|
||||||
|
? result.workspaceId
|
||||||
|
: payload && typeof payload.workspace_id === "string"
|
||||||
|
? payload.workspace_id
|
||||||
|
: payload && typeof payload.workspaceId === "string"
|
||||||
|
? payload.workspaceId
|
||||||
|
: "";
|
||||||
|
assert(documentId, "创建临时页面失败:缺少 documentId/id");
|
||||||
|
assert(workspaceId, "创建临时页面失败:缺少 workspaceId/workspace_id");
|
||||||
return {
|
return {
|
||||||
documentId: payload.id,
|
documentId,
|
||||||
workspaceId: payload.workspace_id,
|
workspaceId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +121,23 @@ async function getViewerIdentity(requestContext) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryApiQuickLogin(requestContext) {
|
||||||
|
await requestJson(requestContext, "/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
action: "auth:signIn",
|
||||||
|
args: {
|
||||||
|
provider: "password",
|
||||||
|
params: {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
flow: "signIn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForAuthenticatedRedirect(page, timeout = 8_000) {
|
async function waitForAuthenticatedRedirect(page, timeout = 8_000) {
|
||||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||||
timeout,
|
timeout,
|
||||||
@@ -110,7 +147,16 @@ async function waitForAuthenticatedRedirect(page, timeout = 8_000) {
|
|||||||
|
|
||||||
async function isVisible(locator) {
|
async function isVisible(locator) {
|
||||||
try {
|
try {
|
||||||
return await locator.isVisible();
|
return await locator.evaluateAll((elements) =>
|
||||||
|
elements.some((element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -139,11 +185,11 @@ async function registerTestAccountIfNeeded(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await switchButton.click({ timeout: UI_TIMEOUT_MS });
|
await switchButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('input[name="email"]').fill("test@example.com", { timeout: UI_TIMEOUT_MS });
|
await page.locator('input[name="email"]').fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('input[name="username"]').fill(`${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`, {
|
await page.locator('input[name="username"]').fill(`${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`, {
|
||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
await page.locator('input[name="password"]').fill("Test123456", { timeout: UI_TIMEOUT_MS });
|
await page.locator('input[name="password"]').fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
|
||||||
await page.getByRole("button", { name: "注册" }).click({ timeout: UI_TIMEOUT_MS });
|
await page.getByRole("button", { name: "注册" }).click({ timeout: UI_TIMEOUT_MS });
|
||||||
try {
|
try {
|
||||||
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
||||||
@@ -166,21 +212,37 @@ async function waitForViewerIdentity(requestContext, attempts = 6) {
|
|||||||
throw lastError instanceof Error ? lastError : new Error("获取当前用户失败");
|
throw lastError instanceof Error ? lastError : new Error("获取当前用户失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureAuthenticated(page, requestContext) {
|
async function ensureAuthenticatedViaUi(page, requestContext) {
|
||||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
if (page.url().includes("/auth")) {
|
if (page.url().includes("/auth")) {
|
||||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||||
await Promise.race([
|
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||||
page.waitForURL((url) => !url.toString().includes("/auth"), {
|
while (page.url().includes("/auth") && Date.now() < deadline) {
|
||||||
timeout: UI_TIMEOUT_MS,
|
if (await isVisible(quickLoginButton)) {
|
||||||
waitUntil: "commit",
|
break;
|
||||||
}),
|
}
|
||||||
quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }),
|
await page.waitForTimeout(200);
|
||||||
]);
|
}
|
||||||
if (!page.url().includes("/auth")) {
|
if (!page.url().includes("/auth")) {
|
||||||
return await waitForViewerIdentity(requestContext);
|
return await waitForViewerIdentity(requestContext);
|
||||||
}
|
}
|
||||||
|
if (!(await isVisible(quickLoginButton))) {
|
||||||
|
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||||
|
const retryDeadline = Date.now() + Math.min(8_000, UI_TIMEOUT_MS);
|
||||||
|
while (page.url().includes("/auth") && Date.now() < retryDeadline) {
|
||||||
|
if (await isVisible(quickLoginButton)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!page.url().includes("/auth")) {
|
||||||
|
return await waitForViewerIdentity(requestContext);
|
||||||
|
}
|
||||||
|
if (!(await isVisible(quickLoginButton))) {
|
||||||
|
throw new Error("认证页未出现测试账号快速登录按钮");
|
||||||
|
}
|
||||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
try {
|
try {
|
||||||
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
||||||
@@ -198,6 +260,36 @@ async function ensureAuthenticated(page, requestContext) {
|
|||||||
return await waitForViewerIdentity(requestContext);
|
return await waitForViewerIdentity(requestContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureAuthenticated(page, requestContext) {
|
||||||
|
const existingViewer = await waitForViewerIdentity(requestContext, 1).catch(() => null);
|
||||||
|
if (existingViewer) {
|
||||||
|
return existingViewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferUiAuth = Boolean(process.env.DISPLAY);
|
||||||
|
if (preferUiAuth) {
|
||||||
|
try {
|
||||||
|
return await ensureAuthenticatedViaUi(page, requestContext);
|
||||||
|
} catch {
|
||||||
|
// 说明:有图形环境时优先走真实 UI 登录;如果异常,再回退到 API 登录。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiViewer = await (async () => {
|
||||||
|
try {
|
||||||
|
await tryApiQuickLogin(requestContext);
|
||||||
|
return await waitForViewerIdentity(requestContext, 3);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (apiViewer) {
|
||||||
|
return apiViewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ensureAuthenticatedViaUi(page, requestContext);
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareTempTreeFixture(requestContext) {
|
async function prepareTempTreeFixture(requestContext) {
|
||||||
const uniqueSuffix = Date.now().toString();
|
const uniqueSuffix = Date.now().toString();
|
||||||
const parentTitle = `task-tree-parent-${uniqueSuffix}`;
|
const parentTitle = `task-tree-parent-${uniqueSuffix}`;
|
||||||
@@ -228,28 +320,121 @@ async function cleanupDocuments(requestContext, createdIds) {
|
|||||||
|
|
||||||
async function openDocument(page, workspaceId, documentId) {
|
async function openDocument(page, workspaceId, documentId) {
|
||||||
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
|
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
|
||||||
await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
const hasSidebarControls = async () => {
|
||||||
|
const groupButton = page.getByRole("button", { name: "分组" });
|
||||||
|
const fileButton = page.getByRole("button", { name: "文件" });
|
||||||
|
return (await isVisible(groupButton)) || (await isVisible(fileButton));
|
||||||
|
};
|
||||||
|
|
||||||
|
const gotoDocument = async () => {
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ERR_ABORTED")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await gotoDocument();
|
||||||
|
const firstDeadline = Date.now() + Math.min(UI_TIMEOUT_MS, 10_000);
|
||||||
|
while (Date.now() < firstDeadline) {
|
||||||
|
if (await hasSidebarControls()) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||||
|
const secondDeadline = Date.now() + Math.min(UI_TIMEOUT_MS, 10_000);
|
||||||
|
while (Date.now() < secondDeadline) {
|
||||||
|
if (await hasSidebarControls()) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSectionView(page) {
|
async function openSectionView(page) {
|
||||||
const button = page.getByRole("button", { name: "分组" });
|
const button = page.getByRole("button", { name: "分组" });
|
||||||
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await button.click({ timeout: UI_TIMEOUT_MS });
|
const waitForHost = () =>
|
||||||
|
page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const pageHost = document.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
|
return pageHost instanceof HTMLElement && pageHost.getClientRects().length > 0;
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
await button.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
try {
|
||||||
|
await waitForHost();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFilesystemView(page) {
|
async function openFilesystemView(page) {
|
||||||
const button = page.getByRole("button", { name: "文件" });
|
const button = page.getByRole("button", { name: "文件" });
|
||||||
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await button.click({ timeout: UI_TIMEOUT_MS });
|
const waitForHost = () =>
|
||||||
|
page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const fileHost = document.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||||
|
return fileHost instanceof HTMLElement && fileHost.getClientRects().length > 0;
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
await button.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
try {
|
||||||
|
await waitForHost();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePageOptionsVisible(page) {
|
async function ensurePageOptionsVisible(page) {
|
||||||
const toggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ });
|
const toggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ });
|
||||||
await toggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await toggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const label = (await toggle.getAttribute("aria-label")) || "";
|
const readLabel = async () => (await toggle.getAttribute("aria-label")) || "";
|
||||||
|
const waitUntilExpanded = async () => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const buttons = Array.from(document.querySelectorAll("button"));
|
||||||
|
return buttons.some((button) => (button.getAttribute("aria-label") || "").includes("隐藏页面选项"));
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const label = await readLabel();
|
||||||
if (label.includes("显示")) {
|
if (label.includes("显示")) {
|
||||||
await toggle.click({ timeout: UI_TIMEOUT_MS });
|
await toggle.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
try {
|
||||||
|
await waitUntilExpanded();
|
||||||
|
} catch {
|
||||||
|
await toggle.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitUntilExpanded();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,32 @@ export function pickCanonicalDocumentRecord<T extends DocumentRecordLike>(record
|
|||||||
return [...records].sort(compareDocumentCanonicalOrder)[0] ?? null;
|
return [...records].sort(compareDocumentCanonicalOrder)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pickCanonicalDocumentRecordsByBusinessId<
|
||||||
|
T extends DocumentRecordLike & { id?: string | null },
|
||||||
|
>(records: readonly T[]): T[] {
|
||||||
|
const grouped = new Map<string, T[]>();
|
||||||
|
for (const record of records) {
|
||||||
|
const businessId = String(record.id ?? "").trim();
|
||||||
|
if (!businessId) continue;
|
||||||
|
|
||||||
|
const bucket = grouped.get(businessId);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push(record);
|
||||||
|
} else {
|
||||||
|
grouped.set(businessId, [record]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: T[] = [];
|
||||||
|
for (const bucket of grouped.values()) {
|
||||||
|
const canonical = pickCanonicalDocumentRecord([...bucket]);
|
||||||
|
if (canonical) {
|
||||||
|
result.push(canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCanonicalDocumentByBusinessId<T extends DocumentRecordLike>(
|
export async function getCanonicalDocumentByBusinessId<T extends DocumentRecordLike>(
|
||||||
ctx: any,
|
ctx: any,
|
||||||
documentId: string,
|
documentId: string,
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ export type ParentLinkedRow = {
|
|||||||
parent_id?: string | null;
|
parent_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function buildParentById<T extends ParentLinkedRow>(rows: readonly T[]): Map<string, string | null> {
|
||||||
|
const map = new Map<string, string | null>();
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(row.id, row.parent_id ?? null);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAncestorOf(
|
||||||
|
ancestorId: string,
|
||||||
|
nodeId: string,
|
||||||
|
parentById: Map<string, string | null>,
|
||||||
|
): boolean {
|
||||||
|
let current: string | null | undefined = nodeId;
|
||||||
|
while (current) {
|
||||||
|
const parentId = parentById.get(current);
|
||||||
|
if (!parentId) return false;
|
||||||
|
if (parentId === ancestorId) return true;
|
||||||
|
current = parentId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收集以 rootId 为根的整棵子树(包含根节点本身)。
|
* 收集以 rootId 为根的整棵子树(包含根节点本身)。
|
||||||
*
|
*
|
||||||
@@ -41,4 +64,3 @@ export function collectSubtree<T extends ParentLinkedRow>(rows: readonly T[], ro
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { api } from "./_generated/api";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireUserId } from "./_utils/auth";
|
import { requireUserId } from "./_utils/auth";
|
||||||
import { nowIso } from "./_utils/time";
|
import { nowIso } from "./_utils/time";
|
||||||
import { collectSubtree } from "./_utils/documentTree";
|
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
|
||||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||||
import { extractTextFromDocumentContent } from "./_utils/text";
|
import { extractTextFromDocumentContent } from "./_utils/text";
|
||||||
import { getCanonicalDocumentByBusinessId, getCanonicalParentDocumentId } from "./_utils/documentRecord";
|
import {
|
||||||
|
getCanonicalDocumentByBusinessId,
|
||||||
|
getCanonicalParentDocumentId,
|
||||||
|
pickCanonicalDocumentRecordsByBusinessId,
|
||||||
|
} from "./_utils/documentRecord";
|
||||||
|
|
||||||
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
||||||
|
|
||||||
@@ -1378,8 +1382,35 @@ export const move = mutation({
|
|||||||
|
|
||||||
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
|
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
|
||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
|
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== userId) throw new Error("无权限");
|
if (doc.user_id !== userId) throw new Error("无权限");
|
||||||
|
|
||||||
|
const toParentId = args.parentId;
|
||||||
|
if (toParentId === doc.id) {
|
||||||
|
throw new Error("不能把页面移动到自身下面");
|
||||||
|
}
|
||||||
|
if (toParentId) {
|
||||||
|
const targetParentDoc = await getCanonicalDocumentByBusinessId<any>(ctx, toParentId);
|
||||||
|
if (!targetParentDoc || targetParentDoc.deleted_at != null || targetParentDoc.user_id !== userId) {
|
||||||
|
throw new Error("目标父页面不存在或无权限");
|
||||||
|
}
|
||||||
|
if (targetParentDoc.workspace_id !== doc.workspace_id) {
|
||||||
|
throw new Error("暂不支持跨工作空间移动页面");
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceDocs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
|
||||||
|
.collect();
|
||||||
|
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
|
||||||
|
.filter((row) => row.user_id === userId)
|
||||||
|
.filter((row) => row.deleted_at == null);
|
||||||
|
const parentById = buildParentById(canonicalWorkspaceDocs);
|
||||||
|
if (isAncestorOf(doc.id, toParentId, parentById)) {
|
||||||
|
throw new Error("不能把页面移动到自己的后代下面");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
||||||
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
||||||
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
||||||
@@ -1433,7 +1464,6 @@ export const move = mutation({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fromParentId = (doc.parent_id ?? null) as string | null;
|
const fromParentId = (doc.parent_id ?? null) as string | null;
|
||||||
const toParentId = args.parentId;
|
|
||||||
|
|
||||||
if (fromParentId === toParentId) {
|
if (fromParentId === toParentId) {
|
||||||
const siblings = await fetchSiblings(toParentId);
|
const siblings = await fetchSiblings(toParentId);
|
||||||
@@ -1441,7 +1471,13 @@ export const move = mutation({
|
|||||||
const position = clampIndex(args.sortOrder, list.length);
|
const position = clampIndex(args.sortOrder, list.length);
|
||||||
list.splice(position, 0, doc);
|
list.splice(position, 0, doc);
|
||||||
await applyOrder(list, toParentId, doc._id);
|
await applyOrder(list, toParentId, doc._id);
|
||||||
return { ok: true, updated_at: ts };
|
return {
|
||||||
|
ok: true,
|
||||||
|
parent_id: toParentId,
|
||||||
|
sort_order: position,
|
||||||
|
workspace_id: doc.workspace_id,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先重排原父节点,确保移动后原列表连续。
|
// 先重排原父节点,确保移动后原列表连续。
|
||||||
@@ -1454,7 +1490,13 @@ export const move = mutation({
|
|||||||
newSiblings.splice(position, 0, doc);
|
newSiblings.splice(position, 0, doc);
|
||||||
await applyOrder(newSiblings, toParentId, doc._id);
|
await applyOrder(newSiblings, toParentId, doc._id);
|
||||||
|
|
||||||
return { ok: true, updated_at: ts };
|
return {
|
||||||
|
ok: true,
|
||||||
|
parent_id: toParentId,
|
||||||
|
sort_order: position,
|
||||||
|
workspace_id: doc.workspace_id,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
|
||||||
import {
|
|
||||||
assertDocumentId,
|
|
||||||
buildDocumentBridgeContext,
|
|
||||||
buildDocumentCommandEnvelope,
|
|
||||||
documentBridgeErrorResponse,
|
|
||||||
} from "@/lib/documents/bridge";
|
|
||||||
import {
|
|
||||||
executePageLifecycleBridgeCommand,
|
|
||||||
type DocumentCopyTreePayload,
|
|
||||||
} from "@/lib/documents/page-command-adapter";
|
|
||||||
import {
|
|
||||||
copyMindmapFilesIfExists,
|
|
||||||
ensureDocumentScaffold,
|
|
||||||
} from "@/lib/documents/page-lifecycle-side-effects";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type CopyTreeItem = {
|
type CopyTreeItem = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -25,102 +7,88 @@ type CopyTreeItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CopyTreePayload = {
|
type CopyTreePayload = {
|
||||||
items: CopyTreeItem[];
|
items?: CopyTreeItem[] | null;
|
||||||
targetParentId: string | null;
|
targetParentId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TreeCopyResponse = {
|
||||||
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
result?: {
|
||||||
|
items?: Array<{
|
||||||
|
oldId: string;
|
||||||
|
newId: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
try {
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
const { client } = await getAuthedConvexClient();
|
|
||||||
const payload = (await request.json()) as CopyTreePayload;
|
|
||||||
const normalizedItems = (payload.items ?? [])
|
|
||||||
.filter((item) => item?.documentId)
|
|
||||||
.map((item) => ({
|
|
||||||
documentId: assertDocumentId(item.documentId),
|
|
||||||
recursive: Boolean(item.recursive),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (normalizedItems.length === 0) {
|
|
||||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
|
|
||||||
let workspaceId: string | null = null;
|
|
||||||
|
|
||||||
if (normalizedTargetParentId) {
|
|
||||||
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
|
|
||||||
if (!targetDoc) {
|
|
||||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
workspaceId = targetDoc.workspace_id?.trim() || null;
|
|
||||||
} else {
|
|
||||||
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
|
|
||||||
if (!firstDoc) {
|
|
||||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
workspaceId = firstDoc.workspace_id?.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridgeContext = await buildDocumentBridgeContext({
|
|
||||||
request,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
const result = await executePageLifecycleBridgeCommand<
|
|
||||||
DocumentCopyTreePayload,
|
|
||||||
{
|
|
||||||
items: Array<{
|
|
||||||
oldId: string;
|
|
||||||
newId: string;
|
|
||||||
title?: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
context: bridgeContext,
|
|
||||||
envelope: buildDocumentCommandEnvelope({
|
|
||||||
name: "documents.copy_tree",
|
|
||||||
payload: {
|
|
||||||
workspaceId,
|
|
||||||
targetParentId: normalizedTargetParentId,
|
|
||||||
items: normalizedItems,
|
|
||||||
},
|
|
||||||
context: bridgeContext,
|
|
||||||
target: {
|
|
||||||
workspaceId,
|
|
||||||
pageId: normalizedTargetParentId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
result.result.items.map(async (item) => {
|
|
||||||
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
|
|
||||||
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
items: result.result.items.map((item) => ({
|
|
||||||
oldId: item.oldId,
|
|
||||||
newId: item.newId,
|
|
||||||
})),
|
|
||||||
meta: {
|
|
||||||
requestId: result.requestId,
|
|
||||||
traceId: result.traceId,
|
|
||||||
commandId: result.commandId,
|
|
||||||
commandName: result.commandName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return documentBridgeErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
try {
|
||||||
|
const payload = (await request.json()) as CopyTreePayload;
|
||||||
|
const normalizedItems = (payload.items ?? [])
|
||||||
|
.filter((item) => item?.documentId)
|
||||||
|
.map((item) => ({
|
||||||
|
documentId: item.documentId.trim(),
|
||||||
|
recursive: Boolean(item.recursive),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.documentId.length > 0);
|
||||||
|
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "copy",
|
||||||
|
targetParentId:
|
||||||
|
typeof payload.targetParentId === "string" ? payload.targetParentId.trim() || null : null,
|
||||||
|
items: normalizedItems,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (await response.json().catch(() => null)) as
|
||||||
|
| TreeCopyResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
result && typeof result === "object" && "error" in result && typeof result.error === "string"
|
||||||
|
? result.error
|
||||||
|
: "复制页面失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: result?.result?.items ?? [],
|
||||||
|
meta: {
|
||||||
|
requestId: result?.requestId,
|
||||||
|
traceId: result?.traceId,
|
||||||
|
commandName: "tree.subtree.copy",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "复制页面失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,59 +1,75 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import {
|
|
||||||
assertDocumentId,
|
type TreeArchiveResponse = {
|
||||||
buildDocumentBridgeContext,
|
requestId?: string;
|
||||||
buildDocumentCommandEnvelope,
|
traceId?: string;
|
||||||
documentBridgeErrorResponse,
|
};
|
||||||
} from "@/lib/documents/bridge";
|
|
||||||
import {
|
type DeletePayload = {
|
||||||
executePageLifecycleBridgeCommand,
|
documentId?: string | null;
|
||||||
type DocumentDeletePayload,
|
workspaceId?: string | null;
|
||||||
} from "@/lib/documents/page-command-adapter";
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
try {
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
|
|
||||||
const normalizedDocumentId = assertDocumentId(body.documentId);
|
|
||||||
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
|
|
||||||
const bridgeContext = await buildDocumentBridgeContext({
|
|
||||||
request,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
|
|
||||||
context: bridgeContext,
|
|
||||||
envelope: buildDocumentCommandEnvelope({
|
|
||||||
name: "documents.delete",
|
|
||||||
payload: {
|
|
||||||
documentId: normalizedDocumentId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
},
|
|
||||||
context: bridgeContext,
|
|
||||||
target: {
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
pageId: normalizedDocumentId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
meta: {
|
|
||||||
requestId: result.requestId,
|
|
||||||
traceId: result.traceId,
|
|
||||||
commandId: result.commandId,
|
|
||||||
commandName: result.commandName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return documentBridgeErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
try {
|
||||||
|
const { documentId, workspaceId }: DeletePayload = await request.json();
|
||||||
|
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
|
||||||
|
if (!normalizedDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "archive",
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| TreeArchiveResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "删除失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: payload?.requestId,
|
||||||
|
traceId: payload?.traceId,
|
||||||
|
commandName: "tree.node.archive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "删除失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,11 +1,74 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
|
|
||||||
|
type TreeEmbedResponse = {
|
||||||
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbedPayload = {
|
||||||
|
sourceId?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
return executeDocumentEmbedBridgeCommand(request);
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
try {
|
||||||
|
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||||
|
const normalizedSourceId = typeof sourceId === "string" ? sourceId.trim() : "";
|
||||||
|
const normalizedTargetId = typeof targetId === "string" ? targetId.trim() : "";
|
||||||
|
if (!normalizedSourceId || !normalizedTargetId) {
|
||||||
|
return NextResponse.json({ error: "缺少 sourceId 或 targetId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "embed",
|
||||||
|
sourceId: normalizedSourceId,
|
||||||
|
targetId: normalizedTargetId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| TreeEmbedResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "嵌入失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: payload?.requestId,
|
||||||
|
traceId: payload?.traceId,
|
||||||
|
commandName: "tree.node.embed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "嵌入失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,13 +1,77 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
|
|
||||||
|
type TreePurgeResponse = {
|
||||||
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
result?: {
|
||||||
|
purged?: boolean;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PurgePayload = {
|
||||||
|
documentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
return executeDocumentPurgeBridgeCommand(request);
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
try {
|
||||||
|
const { documentId }: PurgePayload = await request.json();
|
||||||
|
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
|
||||||
|
if (!normalizedDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "purge",
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| TreePurgeResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "彻底删除失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
purged: payload?.result?.purged ?? true,
|
||||||
|
meta: {
|
||||||
|
requestId: payload?.requestId,
|
||||||
|
traceId: payload?.traceId,
|
||||||
|
commandName: "tree.node.purge",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "彻底删除失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,59 +1,75 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import {
|
|
||||||
assertDocumentId,
|
type TreeRestoreResponse = {
|
||||||
buildDocumentBridgeContext,
|
requestId?: string;
|
||||||
buildDocumentCommandEnvelope,
|
traceId?: string;
|
||||||
documentBridgeErrorResponse,
|
};
|
||||||
} from "@/lib/documents/bridge";
|
|
||||||
import {
|
type RestorePayload = {
|
||||||
executePageLifecycleBridgeCommand,
|
documentId?: string | null;
|
||||||
type DocumentRestorePayload,
|
workspaceId?: string | null;
|
||||||
} from "@/lib/documents/page-command-adapter";
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
try {
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
|
|
||||||
const normalizedDocumentId = assertDocumentId(body.documentId);
|
|
||||||
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
|
|
||||||
const bridgeContext = await buildDocumentBridgeContext({
|
|
||||||
request,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
|
|
||||||
context: bridgeContext,
|
|
||||||
envelope: buildDocumentCommandEnvelope({
|
|
||||||
name: "documents.restore",
|
|
||||||
payload: {
|
|
||||||
documentId: normalizedDocumentId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
},
|
|
||||||
context: bridgeContext,
|
|
||||||
target: {
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
pageId: normalizedDocumentId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
meta: {
|
|
||||||
requestId: result.requestId,
|
|
||||||
traceId: result.traceId,
|
|
||||||
commandId: result.commandId,
|
|
||||||
commandName: result.commandName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return documentBridgeErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
try {
|
||||||
|
const { documentId, workspaceId }: RestorePayload = await request.json();
|
||||||
|
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
|
||||||
|
if (!normalizedDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "restore",
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| TreeRestoreResponse
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "恢复失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: payload?.requestId,
|
||||||
|
traceId: payload?.traceId,
|
||||||
|
commandName: "tree.node.restore",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "恢复失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -72,22 +72,23 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
|
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
|
||||||
|
import { POST as postDelete } from "@/app/api/documents/delete/route";
|
||||||
import { POST as postEmbed } from "@/app/api/documents/embed/route";
|
import { POST as postEmbed } from "@/app/api/documents/embed/route";
|
||||||
import { POST as postTemplate } from "@/app/api/documents/template/route";
|
import { POST as postTemplate } from "@/app/api/documents/template/route";
|
||||||
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
|
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
|
||||||
import { POST as postPurge } from "@/app/api/documents/purge/route";
|
import { POST as postPurge } from "@/app/api/documents/purge/route";
|
||||||
|
import { POST as postRestore } from "@/app/api/documents/restore/route";
|
||||||
import { POST as postTitle } from "@/app/api/documents/title/route";
|
import { POST as postTitle } from "@/app/api/documents/title/route";
|
||||||
import { POST as postOptions } from "@/app/api/documents/options/route";
|
import { POST as postOptions } from "@/app/api/documents/options/route";
|
||||||
import { POST as postSave } from "@/app/api/documents/save/route";
|
import { POST as postSave } from "@/app/api/documents/save/route";
|
||||||
|
import { POST as postCopyTree } from "@/app/api/documents/copy-tree/route";
|
||||||
import { POST as postCreate } from "@/app/api/documents/create/route";
|
import { POST as postCreate } from "@/app/api/documents/create/route";
|
||||||
import { POST as postMove } from "@/app/api/documents/move/route";
|
import { POST as postMove } from "@/app/api/documents/move/route";
|
||||||
import { GET as getPage } from "@/app/api/documents/page/route";
|
import { GET as getPage } from "@/app/api/documents/page/route";
|
||||||
import {
|
import {
|
||||||
executeDocumentCreateChildBridgeCommand,
|
executeDocumentCreateChildBridgeCommand,
|
||||||
executeDocumentEmbedBridgeCommand,
|
|
||||||
executeDocumentTemplateBridgeCommand,
|
executeDocumentTemplateBridgeCommand,
|
||||||
executeDocumentEmptyTrashBridgeCommand,
|
executeDocumentEmptyTrashBridgeCommand,
|
||||||
executeDocumentPurgeBridgeCommand,
|
|
||||||
} from "@/lib/documents/page-command-adapter";
|
} from "@/lib/documents/page-command-adapter";
|
||||||
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
|
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
|
||||||
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
|
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
|
||||||
@@ -246,12 +247,115 @@ describe("documents route adapters", () => {
|
|||||||
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("embed route delegates to unified adapter", async () => {
|
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
await postEmbed(new Request("http://localhost/api/documents/embed", {
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_archive_1",
|
||||||
|
traceId: "trace_tree_archive_1",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
|
||||||
|
}));
|
||||||
|
const payload = await response.json() as {
|
||||||
|
success: boolean;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "archive",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.node.archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_restore_1",
|
||||||
|
traceId: "trace_tree_restore_1",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
|
||||||
|
}));
|
||||||
|
const payload = await response.json() as {
|
||||||
|
success: boolean;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "restore",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.node.restore");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_embed_1",
|
||||||
|
traceId: "trace_tree_embed_1",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
||||||
}));
|
}));
|
||||||
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
|
const payload = await response.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "embed",
|
||||||
|
sourceId: "doc_1",
|
||||||
|
targetId: "doc_2",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.ok).toBe(true);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.node.embed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("template route delegates to unified adapter", async () => {
|
it("template route delegates to unified adapter", async () => {
|
||||||
@@ -270,12 +374,85 @@ describe("documents route adapters", () => {
|
|||||||
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("purge route delegates to unified adapter", async () => {
|
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
await postPurge(new Request("http://localhost/api/documents/purge", {
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_purge_1",
|
||||||
|
traceId: "trace_tree_purge_1",
|
||||||
|
result: {
|
||||||
|
purged: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ documentId: "doc_1" }),
|
body: JSON.stringify({ documentId: "doc_1" }),
|
||||||
}));
|
}));
|
||||||
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
|
const payload = await response.json() as {
|
||||||
|
success: boolean;
|
||||||
|
purged: boolean;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "purge",
|
||||||
|
documentId: "doc_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(payload.purged).toBe(true);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.node.purge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_copy_1",
|
||||||
|
traceId: "trace_tree_copy_1",
|
||||||
|
result: {
|
||||||
|
items: [{ oldId: "doc_1", newId: "doc_2" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
|
||||||
|
}));
|
||||||
|
const payload = await response.json() as {
|
||||||
|
items: Array<{ oldId: string; newId: string }>;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "copy",
|
||||||
|
targetParentId: null,
|
||||||
|
items: [{ documentId: "doc_1", recursive: true }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.subtree.copy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("page route delegates to unified aggregate loader", async () => {
|
it("page route delegates to unified aggregate loader", async () => {
|
||||||
|
|||||||
@@ -1,201 +1,100 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn();
|
||||||
const mockGetAuthedConvexClient = vi.fn();
|
const mockGetAuthedConvexClient = vi.fn();
|
||||||
const mockBuildDocumentBridgeContext = vi.fn();
|
const mockBuildDocumentBridgeContextWithActor = vi.fn();
|
||||||
const mockBuildDocumentQueryEnvelope = vi.fn();
|
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||||
|
const mockExecuteRustBridgeQuery = vi.fn();
|
||||||
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
||||||
const mockResolveRustBridgeQueryPlan = vi.fn();
|
const mockResolveRustBridgeQueryPlan = vi.fn();
|
||||||
const mockResolveKernelFileTreeProjection = vi.fn();
|
const mockResolveKernelFileTreeProjection = vi.fn();
|
||||||
const mockAttachKernelFileTreeProjection = vi.fn((input: { dataset: unknown; projection: unknown }) => ({
|
const mockStreamTreeFrames = vi.fn();
|
||||||
...(input.dataset as Record<string, unknown>),
|
|
||||||
kernel_file_tree_projection: input.projection,
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
}));
|
}));
|
||||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
|
||||||
Response.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("@/lib/convex/route", () => ({
|
vi.mock("@/lib/convex/route", () => ({
|
||||||
getAuthedConvexClient: mockGetAuthedConvexClient,
|
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/bridge", () => ({
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
|
||||||
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
|
mockBuildDocumentBridgeContextWithActor(...args),
|
||||||
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
|
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
|
executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
|
||||||
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
|
executeRustBridgeQueryTransport: (...args: unknown[]) => mockExecuteRustBridgeQueryTransport(...args),
|
||||||
|
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/server/kernel-file-tree", () => ({
|
vi.mock("@/lib/server/kernel-file-tree", async () => {
|
||||||
resolveKernelFileTreeProjection: (...args: unknown[]) => mockResolveKernelFileTreeProjection(...args),
|
const actual = await vi.importActual<typeof import("@/lib/server/kernel-file-tree")>(
|
||||||
attachKernelFileTreeProjection: (...args: unknown[]) => mockAttachKernelFileTreeProjection(...args),
|
"@/lib/server/kernel-file-tree",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveKernelFileTreeProjection: (...args: unknown[]) =>
|
||||||
|
mockResolveKernelFileTreeProjection(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/tree-stream/server", () => ({
|
||||||
|
streamTreeFrames: (...args: unknown[]) => mockStreamTreeFrames(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
async function* makeFrames() {
|
||||||
|
yield {
|
||||||
|
event: "snapshot",
|
||||||
|
payload: {
|
||||||
|
kind: "snapshot",
|
||||||
|
stream: "workspace",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: null,
|
||||||
|
cursor: "cursor_1",
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] } },
|
||||||
|
overview: { command_logs: [], domain_events: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("/api/mnote-web/stream route", () => {
|
describe("/api/mnote-web/stream route", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetAuthedConvexClient.mockReset();
|
vi.resetModules();
|
||||||
mockBuildDocumentBridgeContext.mockReset();
|
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||||
mockBuildDocumentQueryEnvelope.mockReset();
|
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
|
||||||
mockExecuteRustBridgeQueryTransport.mockReset();
|
auth: { userId: "user_1" },
|
||||||
mockResolveRustBridgeQueryPlan.mockReset();
|
client: { query: vi.fn(), mutation: vi.fn() },
|
||||||
mockResolveKernelFileTreeProjection.mockReset();
|
|
||||||
mockAttachKernelFileTreeProjection.mockClear();
|
|
||||||
mockDocumentBridgeErrorResponse.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
|
|
||||||
mockGetAuthedConvexClient.mockResolvedValue({
|
|
||||||
auth: {
|
|
||||||
userId: "user_1",
|
|
||||||
email: "dev@example.com",
|
|
||||||
name: "开发用户",
|
|
||||||
},
|
|
||||||
client: { query: vi.fn() },
|
|
||||||
});
|
});
|
||||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
|
||||||
requestId: "req_stream_1",
|
requestId: "req_1",
|
||||||
traceId: "trace_stream_1",
|
traceId: "trace_1",
|
||||||
workspaceId: "ws_1",
|
actor: { actorType: "user", actorId: "user_1", sessionId: null },
|
||||||
});
|
});
|
||||||
mockBuildDocumentQueryEnvelope
|
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
|
||||||
.mockReturnValueOnce({
|
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
|
||||||
name: "sidebar.dataset.list",
|
argsJson: { workspaceId: "ws_1" },
|
||||||
payload: { workspaceId: "ws_1" },
|
functionName: "bridgeLogs:listWorkspaceOverview",
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
name: "bridge.workspace.overview",
|
|
||||||
payload: {
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
limit: 20,
|
|
||||||
cursor: null,
|
|
||||||
commandStatus: null,
|
|
||||||
eventStatus: null,
|
|
||||||
targetPageId: null,
|
|
||||||
targetBlockId: null,
|
|
||||||
aggregateType: null,
|
|
||||||
aggregateId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockResolveRustBridgeQueryPlan
|
|
||||||
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
|
|
||||||
.mockResolvedValueOnce({ kind: "query", functionName: "bridgeLogs:listWorkspaceOverview", argsJson: { workspaceId: "ws_1" } });
|
|
||||||
mockExecuteRustBridgeQueryTransport
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
active_workspace_id: "ws_1",
|
|
||||||
workspaces: [],
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
id: "page_root",
|
|
||||||
workspace_id: "ws_1",
|
|
||||||
title: "工作区首页",
|
|
||||||
parent_id: null,
|
|
||||||
sort_order: 0,
|
|
||||||
is_starred: true,
|
|
||||||
is_template: false,
|
|
||||||
created_at: "2026-04-22T00:00:00Z",
|
|
||||||
updated_at: "2026-04-22T00:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
trashed_documents: [],
|
|
||||||
media_assets: [],
|
|
||||||
trashed_media_assets: [],
|
|
||||||
mindmap_assets: [],
|
|
||||||
trashed_mindmap_assets: [],
|
|
||||||
table_assets: [],
|
|
||||||
trashed_table_assets: [],
|
|
||||||
mindmap_docs: [],
|
|
||||||
mindmap_asset_children: {},
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
workspace_id: "ws_1",
|
|
||||||
command_logs: [],
|
|
||||||
domain_events: [],
|
|
||||||
counts: { command_logs: 0, domain_events: 0 },
|
|
||||||
filters: null,
|
|
||||||
next_cursor: null,
|
|
||||||
has_more: false,
|
|
||||||
generated_at: "2026-04-22T00:00:00Z",
|
|
||||||
});
|
|
||||||
mockResolveKernelFileTreeProjection.mockResolvedValue({
|
|
||||||
projectionId: "kernel_projection:file_tree:root",
|
|
||||||
projection: "file_tree",
|
|
||||||
rootNodeId: null,
|
|
||||||
items: [],
|
|
||||||
edges: [],
|
|
||||||
});
|
});
|
||||||
|
mockExecuteRustBridgeQuery.mockReset();
|
||||||
const { GET } = await import("./route");
|
mockExecuteRustBridgeQueryTransport.mockImplementation(async ({ plan }) => {
|
||||||
const response = await GET(
|
if (plan?.functionName === "bridgeLogs:listWorkspaceOverview") {
|
||||||
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
|
return {
|
||||||
method: "GET",
|
command_logs: [],
|
||||||
headers: { cookie: "a=1" },
|
domain_events: [],
|
||||||
}),
|
};
|
||||||
);
|
}
|
||||||
|
return {
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
||||||
const text = await response.text();
|
|
||||||
expect(text).toContain("event: snapshot");
|
|
||||||
expect(text).toContain('"kind":"snapshot"');
|
|
||||||
expect(text).toContain('"projection":"sidebar_tree"');
|
|
||||||
expect(text).toContain('"workspaceId":"ws_1"');
|
|
||||||
expect(text).toContain('"activeWorkspaceId":"ws_1"');
|
|
||||||
expect(text).toContain('"kernelFileTreeProjection"');
|
|
||||||
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("应把请求 cursor 继续透传到 overview query 和 snapshot envelope", async () => {
|
|
||||||
mockGetAuthedConvexClient.mockResolvedValue({
|
|
||||||
auth: {
|
|
||||||
userId: "user_1",
|
|
||||||
email: "dev@example.com",
|
|
||||||
name: "开发用户",
|
|
||||||
},
|
|
||||||
client: { query: vi.fn() },
|
|
||||||
});
|
|
||||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
||||||
requestId: "req_stream_2",
|
|
||||||
traceId: "trace_stream_2",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
});
|
|
||||||
mockBuildDocumentQueryEnvelope
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
name: "sidebar.dataset.list",
|
|
||||||
payload: { workspaceId: "ws_1" },
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
name: "bridge.workspace.overview",
|
|
||||||
payload: {
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
limit: 20,
|
|
||||||
cursor: "evt_9",
|
|
||||||
commandStatus: null,
|
|
||||||
eventStatus: null,
|
|
||||||
targetPageId: null,
|
|
||||||
targetBlockId: null,
|
|
||||||
aggregateType: null,
|
|
||||||
aggregateId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockResolveRustBridgeQueryPlan
|
|
||||||
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
kind: "query",
|
|
||||||
functionName: "bridgeLogs:listWorkspaceOverview",
|
|
||||||
argsJson: { workspaceId: "ws_1", cursor: "evt_9" },
|
|
||||||
});
|
|
||||||
mockExecuteRustBridgeQueryTransport
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
active_workspace_id: "ws_1",
|
active_workspace_id: "ws_1",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
@@ -208,43 +107,59 @@ describe("/api/mnote-web/stream route", () => {
|
|||||||
trashed_table_assets: [],
|
trashed_table_assets: [],
|
||||||
mindmap_docs: [],
|
mindmap_docs: [],
|
||||||
mindmap_asset_children: {},
|
mindmap_asset_children: {},
|
||||||
})
|
};
|
||||||
.mockResolvedValueOnce({
|
});
|
||||||
workspace_id: "ws_1",
|
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
|
||||||
command_logs: [],
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
||||||
domain_events: [],
|
|
||||||
counts: { command_logs: 0, domain_events: 0 },
|
|
||||||
filters: null,
|
|
||||||
next_cursor: "evt_10",
|
|
||||||
has_more: true,
|
|
||||||
generated_at: "2026-04-22T00:00:00Z",
|
|
||||||
});
|
|
||||||
mockResolveKernelFileTreeProjection.mockResolvedValue({
|
|
||||||
projectionId: "kernel_projection:file_tree:root",
|
|
||||||
projection: "file_tree",
|
projection: "file_tree",
|
||||||
rootNodeId: null,
|
rootNodeId: null,
|
||||||
items: [],
|
items: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
});
|
});
|
||||||
|
mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应在 3000 route 内直接生成 SSE,不再代理 mnote-web:3104", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
|
||||||
const { GET } = await import("./route");
|
const { GET } = await import("./route");
|
||||||
const response = await GET(
|
const response = await GET(
|
||||||
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9", {
|
new Request(
|
||||||
|
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9&rootNodeId=page_root&pollMs=500&maxPolls=0",
|
||||||
|
{ method: "GET" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.text()).toContain("event: snapshot");
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
|
||||||
|
expect(mockStreamTreeFrames).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
initialCursor: "evt_9",
|
||||||
|
pollMs: 500,
|
||||||
|
maxPolls: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Convex 未启用时应返回 501,而不是探测 3104", async () => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(false);
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET(
|
||||||
|
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(501);
|
||||||
const text = await response.text();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
expect(text).toContain('"cursor":"evt_9"');
|
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
|
||||||
expect(mockBuildDocumentQueryEnvelope).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({
|
|
||||||
payload: expect.objectContaining({
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
cursor: "evt_9",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,76 +1,72 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeContext,
|
buildDocumentBridgeContextWithActor,
|
||||||
buildDocumentQueryEnvelope,
|
buildDocumentQueryEnvelope,
|
||||||
documentBridgeErrorResponse,
|
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeQueryTransport,
|
executeRustBridgeQueryTransport,
|
||||||
resolveRustBridgeQueryPlan,
|
resolveRustBridgeQueryPlan,
|
||||||
} from "@/lib/documents/rust-runtime";
|
} from "@/lib/documents/rust-runtime";
|
||||||
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
|
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||||
|
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
|
||||||
import {
|
import {
|
||||||
attachKernelFileTreeProjection,
|
streamTreeFrames,
|
||||||
resolveKernelFileTreeProjection,
|
type TreeStreamOverview,
|
||||||
} from "@/lib/server/kernel-file-tree";
|
type TreeStreamSnapshotPayload,
|
||||||
|
} from "@/lib/tree-stream/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
function toSseFrame(event: string, data: unknown) {
|
function readNumberParam(url: URL, name: string): number | null {
|
||||||
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
|
const raw = url.searchParams.get(name);
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSseFrame(event: string, payload: unknown) {
|
||||||
|
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
if (!isConvexEnabled()) {
|
||||||
const requestUrl = new URL(request.url);
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
const workspaceId = String(requestUrl.searchParams.get("workspaceId") || "").trim();
|
}
|
||||||
const cursor = String(requestUrl.searchParams.get("cursor") || "").trim() || null;
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
const requestUrl = new URL(request.url);
|
||||||
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
|
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
|
||||||
}
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const context = await buildDocumentBridgeContext({
|
const actor = {
|
||||||
request,
|
actorType: "user",
|
||||||
workspaceId,
|
actorId: auth.userId,
|
||||||
});
|
sessionId: null,
|
||||||
|
};
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request,
|
||||||
|
actor,
|
||||||
|
workspaceId,
|
||||||
|
source: {
|
||||||
|
channel: "next_mnote_web_stream",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const sidebarEnvelope = buildDocumentQueryEnvelope({
|
const loadOverview = async (): Promise<TreeStreamOverview> => {
|
||||||
name: "sidebar.dataset.list",
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
payload: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const sidebarPlan = await resolveRustBridgeQueryPlan({
|
|
||||||
context,
|
|
||||||
envelope: sidebarEnvelope,
|
|
||||||
});
|
|
||||||
const sidebarDataset = await executeRustBridgeQueryTransport({
|
|
||||||
client,
|
|
||||||
plan: sidebarPlan,
|
|
||||||
});
|
|
||||||
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
|
|
||||||
dataset: sidebarDataset,
|
|
||||||
projection: await resolveKernelFileTreeProjection({
|
|
||||||
client,
|
|
||||||
request,
|
|
||||||
workspaceId,
|
|
||||||
actor: {
|
|
||||||
actorType: "user",
|
|
||||||
actorId: auth.userId,
|
|
||||||
sessionId: null,
|
|
||||||
},
|
|
||||||
dataset: sidebarDataset,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const overviewEnvelope = buildDocumentQueryEnvelope({
|
|
||||||
name: "bridge.workspace.overview",
|
name: "bridge.workspace.overview",
|
||||||
payload: {
|
payload: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
limit: 20,
|
limit: 50,
|
||||||
cursor,
|
cursor: null,
|
||||||
commandStatus: null,
|
commandStatus: null,
|
||||||
eventStatus: null,
|
eventStatus: null,
|
||||||
targetPageId: null,
|
targetPageId: null,
|
||||||
@@ -79,43 +75,88 @@ export async function GET(request: Request) {
|
|||||||
aggregateId: null,
|
aggregateId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const overviewPlan = await resolveRustBridgeQueryPlan({
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
context,
|
context,
|
||||||
envelope: overviewEnvelope,
|
envelope,
|
||||||
});
|
});
|
||||||
const overview = await executeRustBridgeQueryTransport({
|
return executeRustBridgeQueryTransport<TreeStreamOverview>({
|
||||||
client,
|
client,
|
||||||
plan: overviewPlan,
|
plan,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const payload = {
|
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
|
||||||
kind: "snapshot",
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
stream: "workspace",
|
name: "sidebar.dataset.list",
|
||||||
projection: "sidebar_tree",
|
payload: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
rootNodeId: null,
|
},
|
||||||
cursor,
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
const datasetWithFileTree = attachKernelFileTreeProjection({
|
||||||
|
dataset,
|
||||||
|
projection: await resolveKernelFileTreeProjection({
|
||||||
|
client,
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
actor,
|
||||||
|
dataset,
|
||||||
|
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
|
||||||
|
depth: readNumberParam(requestUrl, "depth"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return {
|
||||||
requestId: context.requestId,
|
requestId: context.requestId,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
|
data: datasetWithFileTree,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
dataset: sidebarDatasetWithFileTree,
|
dataset: datasetWithFileTree,
|
||||||
tree:
|
|
||||||
sidebarDatasetWithFileTree.kernel_sidebar_projection ??
|
|
||||||
sidebarDatasetWithFileTree.kernelSidebarProjection ??
|
|
||||||
null,
|
|
||||||
},
|
},
|
||||||
overview,
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return new Response(toSseFrame("snapshot", payload), {
|
const encoder = new TextEncoder();
|
||||||
status: 200,
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
headers: {
|
async start(controller) {
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
try {
|
||||||
"cache-control": "no-store",
|
for await (const frame of streamTreeFrames({
|
||||||
},
|
workspaceId,
|
||||||
});
|
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
|
||||||
} catch (error) {
|
initialCursor: requestUrl.searchParams.get("cursor"),
|
||||||
return documentBridgeErrorResponse(error);
|
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
|
||||||
}
|
maxPolls: readNumberParam(requestUrl, "maxPolls"),
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
})) {
|
||||||
|
if (request.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
controller.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"x-upstream": "next-tree-stream",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Json } from "@/types/supabase";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||||
import {
|
import {
|
||||||
assertDocumentId,
|
assertDocumentId,
|
||||||
assertTitle,
|
assertTitle,
|
||||||
@@ -10,21 +12,49 @@ import {
|
|||||||
buildDocumentCommandEnvelope,
|
buildDocumentCommandEnvelope,
|
||||||
documentBridgeErrorResponse,
|
documentBridgeErrorResponse,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
|
import {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import {
|
||||||
|
copyMindmapFilesIfExists,
|
||||||
|
ensureDocumentScaffold,
|
||||||
|
} from "@/lib/documents/page-lifecycle-side-effects";
|
||||||
|
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||||
|
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
} from "@/lib/documents/rust-runtime";
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type TreeCommandAction =
|
||||||
|
| "create"
|
||||||
|
| "rename"
|
||||||
|
| "move"
|
||||||
|
| "archive"
|
||||||
|
| "restore"
|
||||||
|
| "purge"
|
||||||
|
| "embed"
|
||||||
|
| "copy";
|
||||||
|
|
||||||
|
type TreeCopyItem = {
|
||||||
|
documentId: string;
|
||||||
|
recursive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type TreeCommandPayload = {
|
type TreeCommandPayload = {
|
||||||
action?: "create" | "move" | "rename";
|
action?: TreeCommandAction;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
documentId?: string | null;
|
documentId?: string | null;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
targetParentId?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
accessScope?: "private" | "shared" | "public" | null;
|
accessScope?: "private" | "shared" | "public" | null;
|
||||||
content?: unknown;
|
content?: unknown;
|
||||||
sortOrder?: number | null;
|
sortOrder?: number | null;
|
||||||
|
sourceId?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
items?: TreeCopyItem[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function trimOrNull(value: unknown) {
|
function trimOrNull(value: unknown) {
|
||||||
@@ -42,6 +72,150 @@ function normalizeSortOrder(value: unknown) {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
||||||
|
if (!streamDelta) {
|
||||||
|
return commandPayload;
|
||||||
|
}
|
||||||
|
if (isRecord(commandPayload)) {
|
||||||
|
return {
|
||||||
|
...commandPayload,
|
||||||
|
streamDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
payload: commandPayload,
|
||||||
|
streamDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordTreeCommandSuccess(args: {
|
||||||
|
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
||||||
|
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
||||||
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||||
|
commandPayload?: unknown;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context: args.context,
|
||||||
|
envelope: args.envelope,
|
||||||
|
client: args.client,
|
||||||
|
commandPayload: args.commandPayload,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[tree.commands] bridge success artifacts skipped:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTreeCommandSidebarSnapshot(args: {
|
||||||
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||||
|
auth: Awaited<ReturnType<typeof getAuthedConvexClient>>["auth"];
|
||||||
|
workspaceId: string | null;
|
||||||
|
}) {
|
||||||
|
if (!args.workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await loadSidebarDataFromConvex({
|
||||||
|
client: args.client,
|
||||||
|
auth: {
|
||||||
|
userId: args.auth.userId,
|
||||||
|
email: args.auth.email,
|
||||||
|
name: args.auth.name,
|
||||||
|
},
|
||||||
|
fallbackName: args.auth.email ?? args.auth.name ?? "我的空间",
|
||||||
|
requestedWorkspaceId: args.workspaceId,
|
||||||
|
});
|
||||||
|
return result.sidebarInitialData ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[tree.commands] sidebar snapshot skipped:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTreeCommandSnapshotDelta(
|
||||||
|
sidebarSnapshot: unknown,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!isRecord(sidebarSnapshot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(sidebarSnapshot.documents)) {
|
||||||
|
return {
|
||||||
|
op: "replace_documents",
|
||||||
|
documents: sidebarSnapshot.documents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
op: "replace_sidebar",
|
||||||
|
sidebar: sidebarSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTreeMovePreflightDataFromSidebarSnapshot(
|
||||||
|
sidebarSnapshot: unknown,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!isRecord(sidebarSnapshot) || !Array.isArray(sidebarSnapshot.documents)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
documents: sidebarSnapshot.documents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTreeMutationResult<TResult>(args: {
|
||||||
|
request: Request;
|
||||||
|
workspaceId: string | null;
|
||||||
|
commandName: string;
|
||||||
|
payload: unknown;
|
||||||
|
preflightData?: Record<string, unknown> | null;
|
||||||
|
pageId?: string | null;
|
||||||
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||||
|
}) {
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request: args.request,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: args.commandName,
|
||||||
|
payload: args.payload,
|
||||||
|
context,
|
||||||
|
preflightData: args.preflightData ?? null,
|
||||||
|
target: {
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
pageId: args.pageId ?? undefined,
|
||||||
|
},
|
||||||
|
reason: `tree-route ${args.commandName}`,
|
||||||
|
refs: ["next-tree-route"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await executeRustBridgeMutationTransport<TResult>({
|
||||||
|
client: args.client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client: args.client,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (!isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
@@ -51,11 +225,21 @@ export async function POST(request: Request) {
|
|||||||
const payload = (await request.json()) as TreeCommandPayload;
|
const payload = (await request.json()) as TreeCommandPayload;
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case "create":
|
case "create":
|
||||||
return handleCreate(request, payload);
|
return await handleCreate(request, payload);
|
||||||
case "move":
|
case "move":
|
||||||
return handleMove(request, payload);
|
return await handleMove(request, payload);
|
||||||
case "rename":
|
case "rename":
|
||||||
return handleRename(request, payload);
|
return await handleRename(request, payload);
|
||||||
|
case "archive":
|
||||||
|
return await handleArchive(request, payload);
|
||||||
|
case "restore":
|
||||||
|
return await handleRestore(request, payload);
|
||||||
|
case "purge":
|
||||||
|
return await handlePurge(request, payload);
|
||||||
|
case "embed":
|
||||||
|
return await handleEmbed(request, payload);
|
||||||
|
case "copy":
|
||||||
|
return await handleCopy(request, payload);
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
|
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -91,33 +275,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
|||||||
|
|
||||||
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
||||||
const title = normalizeTitle(payload.title);
|
const title = normalizeTitle(payload.title);
|
||||||
const context = await buildDocumentBridgeContext({
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
request,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
|
||||||
name: "tree.node.create",
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
workspaceId,
|
|
||||||
parentId,
|
|
||||||
title,
|
|
||||||
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
|
|
||||||
content: Array.isArray(payload.content) ? payload.content : [],
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
target: {
|
|
||||||
workspaceId,
|
|
||||||
pageId: documentId,
|
|
||||||
},
|
|
||||||
reason: "tree-route create",
|
|
||||||
refs: ["next-tree-route"],
|
|
||||||
});
|
|
||||||
const plan = await resolveRustBridgeCommandPlan({
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
});
|
|
||||||
const result = await executeRustBridgeMutationTransport<{
|
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
@@ -128,11 +286,42 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}>({
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.node.create",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title,
|
||||||
|
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
|
||||||
|
content: Array.isArray(payload.content) ? payload.content : [],
|
||||||
|
},
|
||||||
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
plan,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await ensureDocumentScaffold(result.id, result.title ?? title);
|
await ensureDocumentScaffold(result.id, result.title ?? title);
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
commandPayload: attachStreamDelta(envelope.payload, {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: result.id,
|
||||||
|
workspace_id: result.workspace_id,
|
||||||
|
title: result.title ?? title,
|
||||||
|
parent_id: result.parent_id ?? parentId,
|
||||||
|
sort_order: result.sort_order ?? 0,
|
||||||
|
access_scope: result.access_scope,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: result.is_template,
|
||||||
|
created_at: result.created_at,
|
||||||
|
updated_at: result.updated_at,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
requestId: context.requestId,
|
requestId: context.requestId,
|
||||||
@@ -151,7 +340,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMove(request: Request, payload: TreeCommandPayload) {
|
async function handleMove(request: Request, payload: TreeCommandPayload) {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const documentId = assertDocumentId(payload.documentId ?? null);
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
if (!sourceDoc) {
|
if (!sourceDoc) {
|
||||||
@@ -161,32 +350,44 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
|
|||||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
const parentId = trimOrNull(payload.parentId);
|
const parentId = trimOrNull(payload.parentId);
|
||||||
const sortOrder = normalizeSortOrder(payload.sortOrder);
|
const sortOrder = normalizeSortOrder(payload.sortOrder);
|
||||||
const context = await buildDocumentBridgeContext({
|
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||||
request,
|
client,
|
||||||
|
auth,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
|
||||||
name: "tree.subtree.move",
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
ok?: boolean;
|
||||||
|
parent_id?: string | null;
|
||||||
|
sort_order?: number | null;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
payload: {
|
payload: {
|
||||||
documentId,
|
documentId,
|
||||||
parentId,
|
parentId,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
},
|
},
|
||||||
context,
|
preflightData: movePreflightData,
|
||||||
target: {
|
pageId: documentId,
|
||||||
workspaceId,
|
client,
|
||||||
pageId: documentId,
|
|
||||||
},
|
|
||||||
reason: "tree-route move",
|
|
||||||
refs: ["next-tree-route"],
|
|
||||||
});
|
});
|
||||||
const plan = await resolveRustBridgeCommandPlan({
|
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||||
|
client,
|
||||||
|
auth,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
|
||||||
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
|
|
||||||
client,
|
client,
|
||||||
plan,
|
commandPayload: attachStreamDelta(
|
||||||
|
envelope.payload,
|
||||||
|
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -194,12 +395,23 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
|
|||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
result: {
|
result: {
|
||||||
action: "move",
|
action: "move",
|
||||||
workspaceId,
|
workspaceId: trimOrNull(result?.workspace_id) ?? workspaceId,
|
||||||
documentId,
|
documentId,
|
||||||
parentId,
|
parentId: trimOrNull(result?.parent_id) ?? parentId,
|
||||||
sortOrder,
|
sortOrder:
|
||||||
updatedAt: result?.updated_at ?? null,
|
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
|
||||||
execution: result ?? null,
|
? result.sort_order
|
||||||
|
: sortOrder,
|
||||||
|
updatedAt: trimOrNull(result?.updated_at) ?? null,
|
||||||
|
execution: {
|
||||||
|
...(result ?? null),
|
||||||
|
parent_id: trimOrNull(result?.parent_id) ?? parentId,
|
||||||
|
sort_order:
|
||||||
|
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
|
||||||
|
? result.sort_order
|
||||||
|
: sortOrder,
|
||||||
|
workspace_id: trimOrNull(result?.workspace_id) ?? workspaceId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -214,32 +426,33 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
|
|||||||
|
|
||||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
const title = assertTitle(payload.title ?? null);
|
const title = assertTitle(payload.title ?? null);
|
||||||
const context = await buildDocumentBridgeContext({
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
ok?: boolean;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}>({
|
||||||
request,
|
request,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
commandName: "tree.node.rename",
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
|
||||||
name: "tree.node.rename",
|
|
||||||
payload: {
|
payload: {
|
||||||
documentId,
|
documentId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
context,
|
pageId: documentId,
|
||||||
target: {
|
client,
|
||||||
workspaceId,
|
|
||||||
pageId: documentId,
|
|
||||||
},
|
|
||||||
reason: "tree-route rename",
|
|
||||||
refs: ["next-tree-route"],
|
|
||||||
});
|
});
|
||||||
const plan = await resolveRustBridgeCommandPlan({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
|
||||||
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
|
|
||||||
client,
|
client,
|
||||||
plan,
|
commandPayload: attachStreamDelta(envelope.payload, {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: documentId,
|
||||||
|
title,
|
||||||
|
updated_at: result?.updated_at ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -256,4 +469,338 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
ok?: boolean;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.node.archive",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
pageId: documentId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
commandPayload: attachStreamDelta(envelope.payload, {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "archive",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
updatedAt: result?.updated_at ?? null,
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
ok?: boolean;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.node.restore",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
pageId: documentId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||||
|
client,
|
||||||
|
auth,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
commandPayload: attachStreamDelta(
|
||||||
|
envelope.payload,
|
||||||
|
buildTreeCommandSnapshotDelta(sidebarSnapshot),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "restore",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
updatedAt: result?.updated_at ?? null,
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurge(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
ok?: boolean;
|
||||||
|
purged?: boolean;
|
||||||
|
purged_at?: string | null;
|
||||||
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.node.purge",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
pageId: documentId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
commandPayload: attachStreamDelta(envelope.payload, {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "purge",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
purged: result?.purged ?? true,
|
||||||
|
updatedAt: result?.purged_at ?? null,
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const sourceId = assertDocumentId(payload.sourceId ?? null);
|
||||||
|
const targetId = assertDocumentId(payload.targetId ?? null);
|
||||||
|
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetContent = await client.query(api.documents.getContent, { id: targetId });
|
||||||
|
if (!targetContent) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
|
||||||
|
|
||||||
|
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||||
|
const anchorId = trimOrNull(
|
||||||
|
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
|
||||||
|
);
|
||||||
|
const anchorIndex = anchorId
|
||||||
|
? currentBlocks.findIndex(
|
||||||
|
(block) =>
|
||||||
|
typeof block === "object" &&
|
||||||
|
block !== null &&
|
||||||
|
String((block as { id?: string }).id ?? "") === anchorId,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||||
|
const nextBlocks: Json[] = [
|
||||||
|
...currentBlocks.slice(0, insertIndex),
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
type: "pageReference",
|
||||||
|
props: {
|
||||||
|
pageId: sourceId,
|
||||||
|
title: sourceDoc.title ?? "无标题",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...currentBlocks.slice(insertIndex),
|
||||||
|
];
|
||||||
|
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||||
|
const workspaceId =
|
||||||
|
trimOrNull(sourceDoc.workspace_id) ??
|
||||||
|
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||||
|
|
||||||
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.node.embed",
|
||||||
|
payload: {
|
||||||
|
...buildDocumentSavePayload({
|
||||||
|
documentId: targetId,
|
||||||
|
workspaceId,
|
||||||
|
revision:
|
||||||
|
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
|
||||||
|
? targetContent.revision
|
||||||
|
: null,
|
||||||
|
content: nextContent,
|
||||||
|
conflictDetectionKey:
|
||||||
|
typeof targetContent.conflict_detection_key === "string"
|
||||||
|
? targetContent.conflict_detection_key
|
||||||
|
: null,
|
||||||
|
blockCount: nextBlocks.length,
|
||||||
|
}),
|
||||||
|
sourceDocumentId: sourceId,
|
||||||
|
targetDocumentId: targetId,
|
||||||
|
anchorBlockId: anchorId,
|
||||||
|
},
|
||||||
|
pageId: targetId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
commandPayload: attachStreamDelta(envelope.payload, {
|
||||||
|
op: "noop",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "embed",
|
||||||
|
workspaceId,
|
||||||
|
documentId: targetId,
|
||||||
|
sourceDocumentId: sourceId,
|
||||||
|
targetDocumentId: targetId,
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const normalizedItems = (payload.items ?? [])
|
||||||
|
.filter((item) => item?.documentId)
|
||||||
|
.map((item) => ({
|
||||||
|
documentId: assertDocumentId(item.documentId),
|
||||||
|
recursive: Boolean(item.recursive),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetParentId = trimOrNull(payload.targetParentId);
|
||||||
|
let workspaceId: string | null = null;
|
||||||
|
|
||||||
|
if (targetParentId) {
|
||||||
|
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
||||||
|
if (!targetDoc) {
|
||||||
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = trimOrNull(targetDoc.workspace_id);
|
||||||
|
} else {
|
||||||
|
const firstDoc = await client.query(api.documents.getMeta, {
|
||||||
|
id: normalizedItems[0]?.documentId ?? "",
|
||||||
|
});
|
||||||
|
if (!firstDoc) {
|
||||||
|
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = trimOrNull(firstDoc.workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||||
|
items: Array<{
|
||||||
|
oldId: string;
|
||||||
|
newId: string;
|
||||||
|
title?: string | null;
|
||||||
|
}>;
|
||||||
|
}>({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
commandName: "tree.subtree.copy",
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
targetParentId,
|
||||||
|
items: normalizedItems,
|
||||||
|
},
|
||||||
|
pageId: targetParentId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(result.items ?? []).map(async (item) => {
|
||||||
|
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
|
||||||
|
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||||
|
client,
|
||||||
|
auth,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
await recordTreeCommandSuccess({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client,
|
||||||
|
commandPayload: attachStreamDelta(
|
||||||
|
envelope.payload,
|
||||||
|
buildTreeCommandSnapshotDelta(sidebarSnapshot),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "copy",
|
||||||
|
workspaceId,
|
||||||
|
targetParentId,
|
||||||
|
items: (result.items ?? []).map((item) => ({
|
||||||
|
oldId: item.oldId,
|
||||||
|
newId: item.newId,
|
||||||
|
})),
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ReactNode } from "react";
|
import React, { type ReactNode } from "react";
|
||||||
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
|
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
|
||||||
|
|
||||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -21,6 +21,33 @@ const sidebarData = {
|
|||||||
trashedDocuments: [],
|
trashedDocuments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function buildSidebarNode(input: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
children?: Array<ReturnType<typeof buildSidebarNode>>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
access_scope: "private",
|
||||||
|
id: input.id,
|
||||||
|
workspace_id: "ws_test",
|
||||||
|
title: input.title,
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-24T00:00:00Z",
|
||||||
|
updated_at: "2026-04-24T00:00:00Z",
|
||||||
|
children: input.children ?? [],
|
||||||
|
kernel: {
|
||||||
|
nodeType: "page" as const,
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: input.children?.length ?? 0,
|
||||||
|
expandedByDefault: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock("@tanstack/react-query", () => ({
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||||
const key = Array.isArray(queryKey) ? queryKey[0] : queryKey;
|
const key = Array.isArray(queryKey) ? queryKey[0] : queryKey;
|
||||||
@@ -60,7 +87,9 @@ vi.mock("@/components/ui/dialog", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/ui/input", () => ({
|
vi.mock("@/components/ui/input", () => ({
|
||||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => (
|
||||||
|
<input ref={ref} {...props} />
|
||||||
|
)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/ui/tabs", () => ({
|
vi.mock("@/components/ui/tabs", () => ({
|
||||||
@@ -94,6 +123,9 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
|
treeRendererFamily: "react",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -101,6 +133,7 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
container.remove();
|
container.remove();
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUseDocumentSearch.mockReset();
|
mockUseDocumentSearch.mockReset();
|
||||||
mockUseDocumentSearch.mockReturnValue({
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
@@ -108,6 +141,7 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
sidebarData.kernelSidebarTree = [];
|
||||||
delete window.__MNOTE_RUNTIME_CONFIG__;
|
delete window.__MNOTE_RUNTIME_CONFIG__;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +175,73 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("空查询时应保留根节点并过滤 excludeIds", async () => {
|
||||||
|
sidebarData.kernelSidebarTree = [
|
||||||
|
buildSidebarNode({ id: "doc_hidden", title: "隐藏页面" }),
|
||||||
|
buildSidebarNode({ id: "doc_visible", title: "保留页面" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={["doc_hidden"]}
|
||||||
|
onPick={vi.fn(async () => undefined)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="tree-picker-root"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_hidden"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_visible"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空查询态应支持键盘高亮切换并按当前高亮项选中", async () => {
|
||||||
|
sidebarData.kernelSidebarTree = [
|
||||||
|
buildSidebarNode({ id: "doc_first", title: "第一页" }),
|
||||||
|
buildSidebarNode({ id: "doc_second", title: "第二页" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const onPick = vi.fn(async () => undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
expect(pickerRows).toHaveLength(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pickerRows[0]?.className ?? "").toContain("bg-[#e3ecff]");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onPick).toHaveBeenCalledWith("move", "doc_first");
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("搜索结果也应继续复用统一 picker surface", async () => {
|
it("搜索结果也应继续复用统一 picker surface", async () => {
|
||||||
mockUseDocumentSearch.mockReturnValue({
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
@@ -198,6 +299,141 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("搜索结果态应支持高亮切换并按当前高亮项选中", async () => {
|
||||||
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "doc_first",
|
||||||
|
title: "第一页",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_second",
|
||||||
|
title: "第二页",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPick = vi.fn(async () => undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||||
|
configurable: true,
|
||||||
|
value: "第",
|
||||||
|
});
|
||||||
|
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||||
|
expect(pickerRows).toHaveLength(2);
|
||||||
|
await act(async () => {
|
||||||
|
pickerRows[1]?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pickerRows[1]?.className ?? "").toContain("bg-[#e3ecff]");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pickerRows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("搜索结果态不应再注入根节点,且仍应支持多次 ArrowDown 后选中后续结果", async () => {
|
||||||
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "doc_first",
|
||||||
|
title: "第一页",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_second",
|
||||||
|
title: "第二页",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPick = vi.fn(async () => undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||||
|
configurable: true,
|
||||||
|
value: "第",
|
||||||
|
});
|
||||||
|
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||||
|
expect(container.querySelector('[data-testid="tree-picker-root"]')).toBeNull();
|
||||||
|
expect(pickerRows).toHaveLength(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.focus();
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
|
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
|
||||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
treeRendererFamily: "rust_family",
|
treeRendererFamily: "rust_family",
|
||||||
@@ -266,4 +502,165 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rust_family 配置下,输入框键盘命令应转发给 iframe,并用焦点回传更新 shell 状态", async () => {
|
||||||
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
|
treeRendererFamily: "rust_family",
|
||||||
|
};
|
||||||
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "doc_first",
|
||||||
|
title: "第一页",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_second",
|
||||||
|
title: "第二页",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={vi.fn(async () => undefined)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||||
|
configurable: true,
|
||||||
|
value: "第",
|
||||||
|
});
|
||||||
|
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
Object.defineProperty(iframe, "contentWindow", {
|
||||||
|
configurable: true,
|
||||||
|
value: window,
|
||||||
|
});
|
||||||
|
const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined);
|
||||||
|
postMessageMock.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postMessageMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.picker.command",
|
||||||
|
command: "next",
|
||||||
|
}),
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
|
||||||
|
postMessageMock.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.picker.focus.changed",
|
||||||
|
documentId: "doc_second",
|
||||||
|
itemKey: "doc_second",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postMessageMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.shell.state.patch",
|
||||||
|
activeDocumentId: "doc_second",
|
||||||
|
activePickerItemKey: "doc_second",
|
||||||
|
}),
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
|
||||||
|
postMessageMock.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postMessageMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.picker.command",
|
||||||
|
command: "pick",
|
||||||
|
}),
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => {
|
||||||
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
|
treeRendererFamily: "rust_family",
|
||||||
|
};
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPick = vi.fn(async () => undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot={false}
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
|
||||||
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface";
|
import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface";
|
||||||
|
import type { TreeShellPickerCommand } from "@/components/sidebar/tree-shell-host";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -14,6 +15,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
|
|||||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||||
|
|
||||||
export type MoveEmbedMode = "move" | "embed";
|
export type MoveEmbedMode = "move" | "embed";
|
||||||
|
const PICKER_ROOT_ITEM_KEY = "__root__";
|
||||||
|
|
||||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||||
titleOnly: true,
|
titleOnly: true,
|
||||||
@@ -106,7 +108,11 @@ function MoveEmbedPickerDialogBody({
|
|||||||
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlighted, setHighlighted] = useState(0);
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
const [pickerCommand, setPickerCommand] = useState<TreeShellPickerCommand | null>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const keyboardHighlightPendingRef = useRef(false);
|
||||||
|
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family";
|
||||||
|
const canDelegatePickerKeyboardToShell = treeRendererFamily === "rust_family" && Boolean(workspaceId);
|
||||||
|
|
||||||
const handleModeChange = useCallback((value: string) => {
|
const handleModeChange = useCallback((value: string) => {
|
||||||
setMode(value as MoveEmbedMode);
|
setMode(value as MoveEmbedMode);
|
||||||
@@ -119,6 +125,13 @@ function MoveEmbedPickerDialogBody({
|
|||||||
setHighlighted(0);
|
setHighlighted(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const queuePickerCommand = useCallback((kind: TreeShellPickerCommand["kind"]) => {
|
||||||
|
setPickerCommand((prev) => ({
|
||||||
|
kind,
|
||||||
|
seq: (prev?.seq ?? 0) + 1,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const payload = useMemo(() => {
|
const payload = useMemo(() => {
|
||||||
if (!workspaceId) return null;
|
if (!workspaceId) return null;
|
||||||
return {
|
return {
|
||||||
@@ -152,11 +165,11 @@ function MoveEmbedPickerDialogBody({
|
|||||||
|
|
||||||
const result: PickerItem[] = [];
|
const result: PickerItem[] = [];
|
||||||
|
|
||||||
if (allowRoot && mode === "move") {
|
|
||||||
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmptyQuery) {
|
if (isEmptyQuery) {
|
||||||
|
if (allowRoot && mode === "move") {
|
||||||
|
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
|
||||||
|
}
|
||||||
|
|
||||||
const tree = sidebarQuery.data?.kernelSidebarTree ?? [];
|
const tree = sidebarQuery.data?.kernelSidebarTree ?? [];
|
||||||
const flattened = buildPickerTreeItems(
|
const flattened = buildPickerTreeItems(
|
||||||
buildPageTreeProjectionItems(tree),
|
buildPageTreeProjectionItems(tree),
|
||||||
@@ -191,6 +204,14 @@ function MoveEmbedPickerDialogBody({
|
|||||||
return result;
|
return result;
|
||||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
||||||
|
|
||||||
|
const pickerItemIndexByKey = useMemo(() => {
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
result.set(item.kind === "root" ? PICKER_ROOT_ITEM_KEY : item.id, index);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||||
const handlePick = useCallback(
|
const handlePick = useCallback(
|
||||||
async (targetId: string | null) => {
|
async (targetId: string | null) => {
|
||||||
@@ -199,26 +220,161 @@ function MoveEmbedPickerDialogBody({
|
|||||||
},
|
},
|
||||||
[mode, onOpenChange, onPick],
|
[mode, onOpenChange, onPick],
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
if (highlighted !== 0) {
|
||||||
|
setHighlighted(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (highlighted >= items.length) {
|
||||||
|
setHighlighted(items.length - 1);
|
||||||
|
}
|
||||||
|
}, [highlighted, items.length]);
|
||||||
|
const handleShellPickerFocusChange = useCallback(
|
||||||
|
(payload: { itemKey: string | null; documentId: string | null }) => {
|
||||||
|
const nextKey = payload.itemKey ?? payload.documentId;
|
||||||
|
if (!nextKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = pickerItemIndexByKey.get(nextKey);
|
||||||
|
if (typeof nextIndex === "number") {
|
||||||
|
setHighlighted(nextIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pickerItemIndexByKey],
|
||||||
|
);
|
||||||
|
const handlePickerKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
keyboardHighlightPendingRef.current = true;
|
||||||
|
if (canDelegatePickerKeyboardToShell) {
|
||||||
|
queuePickerCommand("next");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
keyboardHighlightPendingRef.current = true;
|
||||||
|
if (canDelegatePickerKeyboardToShell) {
|
||||||
|
queuePickerCommand("previous");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlighted((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
keyboardHighlightPendingRef.current = true;
|
||||||
|
if (canDelegatePickerKeyboardToShell) {
|
||||||
|
queuePickerCommand("home");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlighted(0);
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
keyboardHighlightPendingRef.current = true;
|
||||||
|
if (canDelegatePickerKeyboardToShell) {
|
||||||
|
queuePickerCommand("end");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlighted(items.length - 1);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (canDelegatePickerKeyboardToShell) {
|
||||||
|
queuePickerCommand("pick");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = items[highlighted];
|
||||||
|
if (!picked) return;
|
||||||
|
void handlePick(picked.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canDelegatePickerKeyboardToShell, handlePick, highlighted, items, queuePickerCommand],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const input = searchInputRef.current;
|
||||||
|
if (!input) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
handlePickerKeyDown(event);
|
||||||
|
};
|
||||||
|
input.addEventListener("keydown", onKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
input.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [handlePickerKeyDown]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyboardHighlightPendingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keyboardHighlightPendingRef.current = false;
|
||||||
|
if (isEmptyQuery || treeRendererFamily !== "rust_family") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = searchInputRef.current;
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const current = searchInputRef.current;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 说明:same-origin picker shell 在搜索态会随高亮更新重新同步 iframe。
|
||||||
|
// 这里把焦点稳回搜索框,避免第二次 ArrowDown 丢到输入框外。
|
||||||
|
current.focus({ preventScroll: true });
|
||||||
|
const end = current.value.length;
|
||||||
|
try {
|
||||||
|
current.setSelectionRange(end, end);
|
||||||
|
} catch {
|
||||||
|
// 说明:部分输入实现不支持 selection range,这里静默忽略即可。
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [highlighted, isEmptyQuery, treeRendererFamily]);
|
||||||
|
const highlightedItem = items[highlighted] ?? null;
|
||||||
|
const highlightedDocumentId = highlightedItem?.kind === "doc" ? highlightedItem.id : null;
|
||||||
|
const activePickerItemKey =
|
||||||
|
highlightedItem?.kind === "root"
|
||||||
|
? PICKER_ROOT_ITEM_KEY
|
||||||
|
: highlightedItem?.kind === "doc"
|
||||||
|
? highlightedItem.id
|
||||||
|
: null;
|
||||||
const pickerFallback = (
|
const pickerFallback = (
|
||||||
sidebarQuery.isLoading ? (
|
sidebarQuery.isLoading ? (
|
||||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||||
) : sidebarQuery.error ? (
|
) : sidebarQuery.error ? (
|
||||||
<div className="p-4 text-sm text-red-600">{String(sidebarQuery.error)}</div>
|
<div className="p-4 text-sm text-red-600">{String(sidebarQuery.error)}</div>
|
||||||
) : items.length === 0 ? (
|
|
||||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
|
||||||
) : (
|
) : (
|
||||||
<TreePickerSurface
|
<TreePickerSurface
|
||||||
rendererFamily={treeRendererFamily}
|
rendererFamily={treeRendererFamily}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
treeShellEnabled={isEmptyQuery}
|
treeShellEnabled={isEmptyQuery}
|
||||||
|
activePickerItemKey={activePickerItemKey}
|
||||||
allowRootPick={allowRoot && mode === "move"}
|
allowRootPick={allowRoot && mode === "move"}
|
||||||
excludeIds={excludeIds}
|
excludeIds={excludeIds}
|
||||||
|
pickerCommand={canDelegatePickerKeyboardToShell ? pickerCommand : null}
|
||||||
|
treeShellItems={items}
|
||||||
items={items}
|
items={items}
|
||||||
highlighted={highlighted}
|
highlighted={highlighted}
|
||||||
onHighlight={setHighlighted}
|
onHighlight={setHighlighted}
|
||||||
onPick={(targetId) => {
|
onPick={(targetId) => {
|
||||||
void handlePick(targetId);
|
void handlePick(targetId);
|
||||||
}}
|
}}
|
||||||
|
onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -243,6 +399,7 @@ function MoveEmbedPickerDialogBody({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => handleQueryChange(e.target.value)}
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -253,26 +410,7 @@ function MoveEmbedPickerDialogBody({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex-1 overflow-y-auto">
|
||||||
className="flex-1 overflow-y-auto"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (isEmptyQuery) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
setHighlighted((prev) => Math.max(0, prev - 1));
|
|
||||||
} else if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
const picked = items[highlighted];
|
|
||||||
if (!picked) return;
|
|
||||||
void handlePick(picked.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!workspaceId ? (
|
{!workspaceId ? (
|
||||||
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
||||||
) : isEmptyQuery ? (
|
) : isEmptyQuery ? (
|
||||||
@@ -281,15 +419,16 @@ function MoveEmbedPickerDialogBody({
|
|||||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-4 text-sm text-red-600">{String(error)}</div>
|
<div className="p-4 text-sm text-red-600">{String(error)}</div>
|
||||||
) : items.length === 0 ? (
|
|
||||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
|
||||||
) : (
|
) : (
|
||||||
<TreePickerSurface
|
<TreePickerSurface
|
||||||
rendererFamily={treeRendererFamily}
|
rendererFamily={treeRendererFamily}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
treeShellEnabled={Boolean(workspaceId)}
|
treeShellEnabled={Boolean(workspaceId)}
|
||||||
|
activeDocumentId={highlightedDocumentId}
|
||||||
|
activePickerItemKey={activePickerItemKey}
|
||||||
allowRootPick={false}
|
allowRootPick={false}
|
||||||
excludeIds={excludeIds}
|
excludeIds={excludeIds}
|
||||||
|
pickerCommand={canDelegatePickerKeyboardToShell ? pickerCommand : null}
|
||||||
treeShellItems={items}
|
treeShellItems={items}
|
||||||
items={items}
|
items={items}
|
||||||
highlighted={highlighted}
|
highlighted={highlighted}
|
||||||
@@ -298,6 +437,7 @@ function MoveEmbedPickerDialogBody({
|
|||||||
onPick={(targetId) => {
|
onPick={(targetId) => {
|
||||||
void handlePick(targetId);
|
void handlePick(targetId);
|
||||||
}}
|
}}
|
||||||
|
onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
import { AudioLines, BookOpen, ChevronRight, FileImage, FileText, FileVideo, Folder, Paperclip, Plus } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||||
@@ -26,6 +26,47 @@ interface FileTreeProps {
|
|||||||
|
|
||||||
const INDENT = 16;
|
const INDENT = 16;
|
||||||
|
|
||||||
|
function resolveAssetIconKind(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
|
||||||
|
const assetType = String(row.asset.asset_type ?? "").trim().toLowerCase();
|
||||||
|
if (assetType === "mindmap") return "mindmap";
|
||||||
|
if (assetType === "luckysheet") return "table";
|
||||||
|
|
||||||
|
const mimeType = String(row.asset.mime_type ?? "").trim().toLowerCase();
|
||||||
|
const fileName = String(row.asset.file_name ?? "").trim().toLowerCase();
|
||||||
|
const ext = fileName.includes(".") ? fileName.split(".").pop() ?? "" : "";
|
||||||
|
|
||||||
|
if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
|
||||||
|
if (ext === "epub" || mimeType.includes("epub")) return "book";
|
||||||
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
|
if (mimeType.startsWith("audio/")) return "audio";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssetIcon(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
|
||||||
|
const iconKind = resolveAssetIconKind(row);
|
||||||
|
const baseClass = "w-4 h-4 shrink-0";
|
||||||
|
|
||||||
|
switch (iconKind) {
|
||||||
|
case "mindmap":
|
||||||
|
return <Folder className={`${baseClass} text-[#7c3aed]`} />;
|
||||||
|
case "table":
|
||||||
|
return <FileText className={`${baseClass} text-[#b45309]`} />;
|
||||||
|
case "pdf":
|
||||||
|
return <FileText className={`${baseClass} text-[#dc2626]`} />;
|
||||||
|
case "book":
|
||||||
|
return <BookOpen className={`${baseClass} text-[#0f766e]`} />;
|
||||||
|
case "image":
|
||||||
|
return <FileImage className={`${baseClass} text-[#0891b2]`} />;
|
||||||
|
case "video":
|
||||||
|
return <FileVideo className={`${baseClass} text-[#ea580c]`} />;
|
||||||
|
case "audio":
|
||||||
|
return <AudioLines className={`${baseClass} text-[#16a34a]`} />;
|
||||||
|
default:
|
||||||
|
return <Paperclip className={`${baseClass} text-wolai-text-secondary`} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function FileTree({
|
export function FileTree({
|
||||||
rows,
|
rows,
|
||||||
activeId,
|
activeId,
|
||||||
@@ -300,13 +341,13 @@ export function FileTree({
|
|||||||
) : (
|
) : (
|
||||||
<span className="w-5 h-5 shrink-0" />
|
<span className="w-5 h-5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<Folder className="w-4 h-4 text-[#2563eb] shrink-0" />
|
{renderAssetIcon(row)}
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="w-4 h-4 shrink-0" />
|
<span className="w-4 h-4 shrink-0" />
|
||||||
<Paperclip className="w-4 h-4 text-wolai-text-secondary shrink-0" />
|
{renderAssetIcon(row)}
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation";
|
||||||
|
|
||||||
|
describe("sidebar-navigation", () => {
|
||||||
|
it("页面树普通打开应留在当前窗口", () => {
|
||||||
|
expect(buildSidebarDocumentOpenTarget("doc_1", "main", "http://127.0.0.1:3000")).toEqual({
|
||||||
|
kind: "same-window",
|
||||||
|
path: "/documents/doc_1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显式侧栏预览打开才应使用新窗口 URL", () => {
|
||||||
|
expect(buildSidebarDocumentOpenTarget("doc_1", "sidebar", "http://127.0.0.1:3000")).toEqual({
|
||||||
|
kind: "new-window",
|
||||||
|
url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type SidebarDocumentOpenMode = "main" | "sidebar";
|
||||||
|
|
||||||
|
export type SidebarDocumentOpenTarget =
|
||||||
|
| { kind: "same-window"; path: string }
|
||||||
|
| { kind: "new-window"; url: string };
|
||||||
|
|
||||||
|
export function buildSidebarDocumentOpenTarget(
|
||||||
|
documentId: string,
|
||||||
|
mode: SidebarDocumentOpenMode,
|
||||||
|
origin?: string | null,
|
||||||
|
): SidebarDocumentOpenTarget {
|
||||||
|
const path = `/documents/${documentId}`;
|
||||||
|
if (mode === "main") {
|
||||||
|
return { kind: "same-window", path };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = origin ? `${origin}${path}` : path;
|
||||||
|
return { kind: "new-window", url: `${base}?preview=sidebar` };
|
||||||
|
}
|
||||||
@@ -54,9 +54,18 @@ import {
|
|||||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows";
|
||||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd";
|
||||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||||
|
import {
|
||||||
|
computeFileTreeShellDeleteTargets,
|
||||||
|
buildFileTreeShellRowById,
|
||||||
|
buildFileTreeShellVisibleRowIds,
|
||||||
|
type FileTreeShellRow,
|
||||||
|
inferFileTreeShellTargetDocumentId,
|
||||||
|
getOrderedFileTreeShellRows,
|
||||||
|
resolveFileTreeShellMindmapTargetId,
|
||||||
|
} from "@/lib/file-tree/shell";
|
||||||
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
||||||
import {
|
import {
|
||||||
computeTreePaneDeleteTargets,
|
computeTreePaneDeleteTargets,
|
||||||
@@ -69,6 +78,10 @@ import {
|
|||||||
type TreePaneSelectionState,
|
type TreePaneSelectionState,
|
||||||
writeTreePaneClipboardPayload,
|
writeTreePaneClipboardPayload,
|
||||||
} from "@/components/sidebar/tree-pane-bindings";
|
} from "@/components/sidebar/tree-pane-bindings";
|
||||||
|
import {
|
||||||
|
buildSidebarDocumentOpenTarget,
|
||||||
|
type SidebarDocumentOpenMode,
|
||||||
|
} from "@/components/sidebar/sidebar-navigation";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||||
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||||
@@ -105,8 +118,6 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
|
|||||||
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
|
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
|
|
||||||
|
|
||||||
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
|
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
|
||||||
const name = (fileName ?? "").trim().toLowerCase();
|
const name = (fileName ?? "").trim().toLowerCase();
|
||||||
const mt = (mimeType ?? "").trim().toLowerCase();
|
const mt = (mimeType ?? "").trim().toLowerCase();
|
||||||
@@ -122,27 +133,6 @@ const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | nul
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractMindmapIdFromStoragePath = (
|
|
||||||
storagePath: string | null | undefined,
|
|
||||||
): string | null => {
|
|
||||||
if (!storagePath) return null;
|
|
||||||
const normalized = normalizeStoragePath(storagePath);
|
|
||||||
|
|
||||||
const prefix = "mindmaps/";
|
|
||||||
if (normalized.startsWith(prefix)) {
|
|
||||||
const rest = normalized.slice(prefix.length);
|
|
||||||
const id = rest.split("/")[0];
|
|
||||||
return id ? id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = "/mindmaps/";
|
|
||||||
const idx = normalized.indexOf(marker);
|
|
||||||
if (idx === -1) return null;
|
|
||||||
const rest = normalized.slice(idx + marker.length);
|
|
||||||
const id = rest.split("/")[0];
|
|
||||||
return id ? id : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
initialData: SidebarInitialData;
|
initialData: SidebarInitialData;
|
||||||
sidebarData?: SidebarInitialData;
|
sidebarData?: SidebarInitialData;
|
||||||
@@ -212,7 +202,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const { signOut } = useAuthActions();
|
const { signOut } = useAuthActions();
|
||||||
const activeId = segments?.[1] ?? "";
|
const activeId = segments?.[1] ?? "";
|
||||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family";
|
||||||
|
const isRustFamilyTreeRenderer = treeRendererFamily === "rust_family";
|
||||||
|
|
||||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
@@ -282,6 +273,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
||||||
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
||||||
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
||||||
|
const pageTreeFocusedDocumentIdRef = useRef<string | null>(activeId || null);
|
||||||
|
|
||||||
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
|
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||||
@@ -301,6 +293,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
});
|
});
|
||||||
}, [sidebarData.kernelSidebarTree]);
|
}, [sidebarData.kernelSidebarTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextAssets = sidebarData.mediaAssets ?? [];
|
const nextAssets = sidebarData.mediaAssets ?? [];
|
||||||
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
||||||
@@ -588,80 +584,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
||||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
|
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
|
||||||
|
|
||||||
const mindmapChildrenSnapshot = useMemo(() => {
|
|
||||||
const mapping = sidebarData.mindmapAssetChildren ?? {};
|
|
||||||
const mindmapDocById = new Map<string, string>(
|
|
||||||
(mindmapAssets ?? [])
|
|
||||||
.filter((asset) => asset.asset_type === "mindmap")
|
|
||||||
.map((asset) => [asset.id, asset.document_id]),
|
|
||||||
);
|
|
||||||
const mindmapIds = new Set(mindmapDocById.keys());
|
|
||||||
|
|
||||||
const mediaById = new Map<string, MediaAsset>(
|
|
||||||
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
|
|
||||||
const childIds = new Set<string>();
|
|
||||||
const assigned = new Set<string>();
|
|
||||||
|
|
||||||
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
|
|
||||||
(mediaAssets ?? []).forEach((asset) => {
|
|
||||||
const sp = asset.storage_path;
|
|
||||||
if (!sp || typeof sp !== "string") return;
|
|
||||||
const mindmapId = extractMindmapIdFromStoragePath(sp);
|
|
||||||
if (!mindmapId) return;
|
|
||||||
if (!mindmapIds.has(mindmapId)) return;
|
|
||||||
const docId = mindmapDocById.get(mindmapId);
|
|
||||||
if (docId && asset.document_id !== docId) return;
|
|
||||||
if (assigned.has(asset.id)) return;
|
|
||||||
assigned.add(asset.id);
|
|
||||||
childIds.add(asset.id);
|
|
||||||
if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
|
|
||||||
childAssetsByMindmapId[mindmapId].push(asset);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重)
|
|
||||||
(mindmapAssets ?? []).forEach((mindmapAsset) => {
|
|
||||||
const ids = mapping[mindmapAsset.id] ?? [];
|
|
||||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
||||||
ids.forEach((id) => {
|
|
||||||
const asset = mediaById.get(id);
|
|
||||||
if (!asset) return;
|
|
||||||
if (asset.document_id !== mindmapAsset.document_id) return;
|
|
||||||
if (assigned.has(asset.id)) return;
|
|
||||||
assigned.add(asset.id);
|
|
||||||
childIds.add(asset.id);
|
|
||||||
if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
|
|
||||||
childAssetsByMindmapId[mindmapAsset.id].push(asset);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { childAssetsByMindmapId, childIds };
|
|
||||||
}, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);
|
|
||||||
|
|
||||||
const assetsByDoc = useMemo(() => {
|
|
||||||
const map: Record<string, MediaAsset[]> = {};
|
|
||||||
const assets = [
|
|
||||||
...((mediaAssets ?? []).filter(
|
|
||||||
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
|
|
||||||
)),
|
|
||||||
...(mindmapAssets ?? []),
|
|
||||||
...(tableAssets ?? []),
|
|
||||||
];
|
|
||||||
|
|
||||||
assets.forEach((asset) => {
|
|
||||||
if (!map[asset.document_id]) {
|
|
||||||
map[asset.document_id] = [];
|
|
||||||
}
|
|
||||||
const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type);
|
|
||||||
if (!exists) {
|
|
||||||
map[asset.document_id].push(asset);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
|
|
||||||
|
|
||||||
const assetById = useMemo(() => {
|
const assetById = useMemo(() => {
|
||||||
const map = new Map<string, MediaAsset>();
|
const map = new Map<string, MediaAsset>();
|
||||||
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
|
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
|
||||||
@@ -672,40 +594,81 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const resourceRows = useMemo(
|
const resourceTreeShellItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildVisibleRows({
|
filter.trim().length === 0
|
||||||
fileTreeItems:
|
? undefined
|
||||||
filter.trim().length === 0
|
: filterKernelFileTreeProjectionItems({
|
||||||
? sidebarData.kernelFileTreeProjection.items
|
fileTreeItems: sidebarData.kernelFileTreeProjection.items,
|
||||||
: undefined,
|
visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)),
|
||||||
pageRows: visibleFilteredPrivatePageRows,
|
expandedDocumentIds: expanded,
|
||||||
expanded,
|
expandedAssetFolderIds: expandedAssetFolders,
|
||||||
assetsByDoc,
|
}),
|
||||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
|
||||||
expandedAssetFolderIds: expandedAssetFolders,
|
|
||||||
nodeById,
|
|
||||||
assetById,
|
|
||||||
}),
|
|
||||||
[
|
[
|
||||||
assetById,
|
|
||||||
assetsByDoc,
|
|
||||||
expanded,
|
expanded,
|
||||||
expandedAssetFolders,
|
expandedAssetFolders,
|
||||||
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
|
||||||
nodeById,
|
|
||||||
sidebarData.kernelFileTreeProjection.items,
|
sidebarData.kernelFileTreeProjection.items,
|
||||||
visibleFilteredPrivatePageRows,
|
visibleFilteredPrivatePageRows,
|
||||||
filter,
|
filter,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectivePageTreeShellRows = useMemo(
|
||||||
|
() => (filter.trim().length > 0 ? filteredPrivatePageRows : privatePageRows),
|
||||||
|
[filter, filteredPrivatePageRows, privatePageRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveResourceTreeShellItems = useMemo(
|
||||||
|
() => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items,
|
||||||
|
[resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourceShellVisibleRowIds = useMemo(
|
||||||
|
() => buildFileTreeShellVisibleRowIds(effectiveResourceTreeShellItems),
|
||||||
|
[effectiveResourceTreeShellItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourceShellRowById = useMemo(
|
||||||
|
() =>
|
||||||
|
buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: effectiveResourceTreeShellItems,
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
}),
|
||||||
|
[assetById, effectiveResourceTreeShellItems, nodeById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourceRows = useMemo<TreePaneRow[]>(() => {
|
||||||
|
if (isRustFamilyTreeRenderer) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return buildVisibleRows({
|
||||||
|
fileTreeItems: effectiveResourceTreeShellItems,
|
||||||
|
expanded,
|
||||||
|
expandedAssetFolderIds: expandedAssetFolders,
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
assetById,
|
||||||
|
effectiveResourceTreeShellItems,
|
||||||
|
expanded,
|
||||||
|
expandedAssetFolders,
|
||||||
|
isRustFamilyTreeRenderer,
|
||||||
|
nodeById,
|
||||||
|
]);
|
||||||
|
|
||||||
const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
|
const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
|
||||||
const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
|
const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
|
||||||
|
const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer
|
||||||
|
? resourceShellVisibleRowIds
|
||||||
|
: resourceVisibleRowIds;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
|
setResourceSelection((prev) =>
|
||||||
}, [resourceVisibleRowIds]);
|
normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds),
|
||||||
|
);
|
||||||
|
}, [resourceSelectionVisibleRowIds]);
|
||||||
|
|
||||||
const docParentById = useMemo(
|
const docParentById = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -730,18 +693,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const activeWorkspace =
|
const activeWorkspace =
|
||||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||||
sidebarData.workspaces[0];
|
sidebarData.workspaces[0];
|
||||||
|
const pageTreeFocusedDocumentId = pageTreeFocusedDocumentIdRef.current ?? (activeId || null);
|
||||||
|
|
||||||
const handleOpenDocument = useCallback(
|
const handleOpenDocument = useCallback(
|
||||||
(documentId: string, mode: "main" | "sidebar") => {
|
(documentId: string, mode: SidebarDocumentOpenMode) => {
|
||||||
const targetPath = `/documents/${documentId}`;
|
const target = buildSidebarDocumentOpenTarget(
|
||||||
if (mode === "main") {
|
documentId,
|
||||||
router.push(targetPath);
|
mode,
|
||||||
|
typeof window !== "undefined" ? window.location.origin : null,
|
||||||
|
);
|
||||||
|
if (target.kind === "same-window") {
|
||||||
|
router.push(target.path);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`;
|
window.open(target.url, "_blank", "noopener,noreferrer");
|
||||||
window.open(sidebarUrl, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, setOpen],
|
[router, setOpen],
|
||||||
@@ -981,7 +948,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
const handlePageTreeShellNavigate = useCallback(
|
const handlePageTreeShellNavigate = useCallback(
|
||||||
(documentId: string) => {
|
(documentId: string) => {
|
||||||
handleOpenDocument(documentId, "sidebar");
|
pageTreeFocusedDocumentIdRef.current = documentId;
|
||||||
|
handleOpenDocument(documentId, "main");
|
||||||
},
|
},
|
||||||
[handleOpenDocument],
|
[handleOpenDocument],
|
||||||
);
|
);
|
||||||
@@ -1008,6 +976,31 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
[nodeById],
|
[nodeById],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePageTreeShellExpandChange = useCallback(
|
||||||
|
(payload: { documentId: string | null; expanded: boolean }) => {
|
||||||
|
if (!payload.documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (payload.expanded) {
|
||||||
|
next.add(payload.documentId!);
|
||||||
|
} else {
|
||||||
|
next.delete(payload.documentId!);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageTreeShellFocusChange = useCallback(
|
||||||
|
(payload: { documentId: string | null }) => {
|
||||||
|
pageTreeFocusedDocumentIdRef.current = payload.documentId;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileTreeShellContextMenu = useCallback(
|
const handleFileTreeShellContextMenu = useCallback(
|
||||||
(payload: {
|
(payload: {
|
||||||
documentId: string | null;
|
documentId: string | null;
|
||||||
@@ -1029,9 +1022,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = payload.rowId ? resourceRowById.get(payload.rowId) : null;
|
const row = payload.rowId ? resourceShellRowById.get(payload.rowId) : null;
|
||||||
const node =
|
const node =
|
||||||
row && (row.kind === "doc" || row.kind === "index")
|
row && (row.rowKind === "doc" || row.rowKind === "index")
|
||||||
? row.node
|
? row.node
|
||||||
: payload.documentId
|
: payload.documentId
|
||||||
? nodeById.get(payload.documentId) ?? null
|
? nodeById.get(payload.documentId) ?? null
|
||||||
@@ -1045,7 +1038,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
y: payload.y,
|
y: payload.y,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[assetById, nodeById, resourceRowById],
|
[assetById, nodeById, resourceShellRowById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileTreeShellSelectionChange = useCallback(
|
const handleFileTreeShellSelectionChange = useCallback(
|
||||||
@@ -1054,26 +1047,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
anchorRowId: string | null;
|
anchorRowId: string | null;
|
||||||
focusedRowId: string | null;
|
focusedRowId: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const visibleRowIds = resourceRows.map((row) => row.rowId);
|
|
||||||
const normalized = normalizeTreePaneSelectionForVisibleRows(
|
const normalized = normalizeTreePaneSelectionForVisibleRows(
|
||||||
{
|
{
|
||||||
selectedRowIds: new Set(
|
selectedRowIds: new Set(
|
||||||
payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)),
|
payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)),
|
||||||
),
|
),
|
||||||
anchorRowId:
|
anchorRowId:
|
||||||
payload.anchorRowId && resourceRowById.has(payload.anchorRowId)
|
payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId)
|
||||||
? payload.anchorRowId
|
? payload.anchorRowId
|
||||||
: null,
|
: null,
|
||||||
focusedRowId:
|
focusedRowId:
|
||||||
payload.focusedRowId && resourceRowById.has(payload.focusedRowId)
|
payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId)
|
||||||
? payload.focusedRowId
|
? payload.focusedRowId
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
visibleRowIds,
|
resourceShellVisibleRowIds,
|
||||||
);
|
);
|
||||||
setResourceSelection(normalized);
|
setResourceSelection(normalized);
|
||||||
},
|
},
|
||||||
[resourceRowById, resourceRows],
|
[resourceShellRowById, resourceShellVisibleRowIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileTreeShellAssetOpen = useCallback(
|
const handleFileTreeShellAssetOpen = useCallback(
|
||||||
@@ -1121,9 +1113,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const orderedRowIds = resourceRows
|
const orderedRowIds = resourceSelectionVisibleRowIds.filter((rowId) =>
|
||||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
resourceSelection.selectedRowIds.has(rowId),
|
||||||
.map((row) => row.rowId);
|
);
|
||||||
await writeTreePaneClipboardPayload({
|
await writeTreePaneClipboardPayload({
|
||||||
type: "mnote-file-tree",
|
type: "mnote-file-tree",
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -1140,30 +1132,68 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetDocId = inferPasteTargetDocId({
|
const targetDocId = isRustFamilyTreeRenderer
|
||||||
focusedRowId: resourceSelection.focusedRowId,
|
? inferFileTreeShellTargetDocumentId({
|
||||||
rowById: resourceRowById,
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
activeDocId: activeId || null,
|
rowById: resourceShellRowById,
|
||||||
});
|
activeDocId: activeId || null,
|
||||||
|
})
|
||||||
|
: inferPasteTargetDocId({
|
||||||
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
|
rowById: resourceRowById,
|
||||||
|
activeDocId: activeId || null,
|
||||||
|
});
|
||||||
if (!targetDocId) {
|
if (!targetDocId) {
|
||||||
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
|
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = payload.rowIds
|
|
||||||
.map((rowId) => resourceRowById.get(rowId as any))
|
|
||||||
.filter(Boolean) as TreePaneRow[];
|
|
||||||
|
|
||||||
const docItemsMap = new Map<string, boolean>();
|
const docItemsMap = new Map<string, boolean>();
|
||||||
rows.forEach((row) => {
|
const copyableAssetIds: string[] = [];
|
||||||
if (row.kind === "doc") {
|
|
||||||
docItemsMap.set(row.docId, true);
|
if (isRustFamilyTreeRenderer) {
|
||||||
} else if (row.kind === "index") {
|
const rows = getOrderedFileTreeShellRows({
|
||||||
if (!docItemsMap.has(row.docId)) {
|
rowIds: payload.rowIds,
|
||||||
docItemsMap.set(row.docId, false);
|
visibleRowIds: resourceShellVisibleRowIds,
|
||||||
|
rowById: resourceShellRowById,
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.rowKind === "doc") {
|
||||||
|
docItemsMap.set(row.documentId, true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (row.rowKind === "index" && !docItemsMap.has(row.documentId)) {
|
||||||
});
|
docItemsMap.set(row.documentId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.rowKind === "asset" && row.asset && isRealFileAsset(row.asset)) {
|
||||||
|
copyableAssetIds.push(row.asset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const rows = payload.rowIds
|
||||||
|
.map((rowId) => resourceRowById.get(rowId as any))
|
||||||
|
.filter(Boolean) as TreePaneRow[];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.kind === "doc") {
|
||||||
|
docItemsMap.set(row.docId, true);
|
||||||
|
} else if (row.kind === "index") {
|
||||||
|
if (!docItemsMap.has(row.docId)) {
|
||||||
|
docItemsMap.set(row.docId, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rows
|
||||||
|
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
|
||||||
|
.map((row) => row.asset)
|
||||||
|
.filter((asset) => isRealFileAsset(asset))
|
||||||
|
.forEach((asset) => {
|
||||||
|
copyableAssetIds.push(asset.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (docItemsMap.size > 0) {
|
if (docItemsMap.size > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -1183,9 +1213,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
emitDocumentsChanged(targetDocId);
|
emitDocumentsChanged(targetDocId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
|
|
||||||
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
|
|
||||||
|
|
||||||
if (copyableAssetIds.length > 0) {
|
if (copyableAssetIds.length > 0) {
|
||||||
const resp = await fetch("/api/media/batch", {
|
const resp = await fetch("/api/media/batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1213,10 +1240,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [
|
}, [
|
||||||
activeId,
|
activeId,
|
||||||
|
isRustFamilyTreeRenderer,
|
||||||
|
resourceSelectionVisibleRowIds,
|
||||||
|
resourceShellRowById,
|
||||||
resourceRowById,
|
resourceRowById,
|
||||||
resourceRows,
|
|
||||||
resourceSelection.focusedRowId,
|
resourceSelection.focusedRowId,
|
||||||
resourceSelection.selectedRowIds,
|
resourceSelection.selectedRowIds,
|
||||||
|
resourceShellVisibleRowIds,
|
||||||
sidebarQuery,
|
sidebarQuery,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1474,11 +1504,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteResourceSelection = useCallback(async () => {
|
const handleDeleteResourceSelection = useCallback(async () => {
|
||||||
const { docIds, assetIds } = computeTreePaneDeleteTargets({
|
const shellDeleteTargets = isRustFamilyTreeRenderer
|
||||||
visibleRows: resourceRows,
|
? computeFileTreeShellDeleteTargets({
|
||||||
selectedRowIds: resourceSelection.selectedRowIds,
|
visibleRowIds: resourceShellVisibleRowIds,
|
||||||
parentById: docParentById,
|
rowById: resourceShellRowById,
|
||||||
});
|
selectedRowIds: resourceSelection.selectedRowIds,
|
||||||
|
parentById: docParentById,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const legacyDeleteTargets = !isRustFamilyTreeRenderer
|
||||||
|
? computeTreePaneDeleteTargets({
|
||||||
|
visibleRows: resourceRows,
|
||||||
|
selectedRowIds: resourceSelection.selectedRowIds,
|
||||||
|
parentById: docParentById,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const docIds = shellDeleteTargets?.docIds ?? legacyDeleteTargets?.docIds ?? [];
|
||||||
|
const assetIds = shellDeleteTargets?.assetIds ?? legacyDeleteTargets?.assetIds ?? [];
|
||||||
|
|
||||||
if (docIds.length === 0 && assetIds.length === 0) {
|
if (docIds.length === 0 && assetIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1493,14 +1535,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
|
): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
|
||||||
row.kind === "asset" || row.kind === "asset-folder";
|
row.kind === "asset" || row.kind === "asset-folder";
|
||||||
|
|
||||||
const selectedAssetHints = Array.from(
|
const selectedAssetHints =
|
||||||
new Map(
|
shellDeleteTargets?.assetHints ??
|
||||||
resourceRows
|
Array.from(
|
||||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
new Map(
|
||||||
.filter(isAssetRow)
|
resourceRows
|
||||||
.map((row) => [row.asset.id, row.asset] as const),
|
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||||
).values(),
|
.filter(isAssetRow)
|
||||||
);
|
.map((row) => [row.asset.id, row.asset] as const),
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
|
|
||||||
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
|
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
|
||||||
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
|
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
|
||||||
@@ -1558,12 +1602,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}, [
|
}, [
|
||||||
activeId,
|
activeId,
|
||||||
docParentById,
|
docParentById,
|
||||||
|
isRustFamilyTreeRenderer,
|
||||||
resourceRows,
|
resourceRows,
|
||||||
|
resourceShellRowById,
|
||||||
|
resourceShellVisibleRowIds,
|
||||||
resourceSelection.selectedRowIds,
|
resourceSelection.selectedRowIds,
|
||||||
handleDeleteAssets,
|
handleDeleteAssets,
|
||||||
mediaAssets,
|
|
||||||
mindmapAssets,
|
|
||||||
tableAssets,
|
|
||||||
refreshTree,
|
refreshTree,
|
||||||
router,
|
router,
|
||||||
]);
|
]);
|
||||||
@@ -1710,31 +1754,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleResourcePaneDropFiles = useCallback(
|
const handleResourcePaneDropFiles = useCallback(
|
||||||
(docId: string, files: FileList, targetRow?: TreePaneRow) => {
|
(payload: {
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
targetRowKind: string | null;
|
||||||
|
targetAssetId: string | null;
|
||||||
|
files: FileList | File[];
|
||||||
|
}) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const droppedFiles = Array.from(files ?? []);
|
const droppedFiles = Array.from(payload.files ?? []);
|
||||||
if (droppedFiles.length === 0) return;
|
if (droppedFiles.length === 0) return;
|
||||||
|
const targetRow =
|
||||||
|
payload.targetRowId
|
||||||
|
? (resourceShellRowById.get(payload.targetRowId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const targetMindmapId = (() => {
|
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
|
||||||
if (!targetRow) return null;
|
|
||||||
if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
|
|
||||||
return targetRow.asset.id;
|
|
||||||
}
|
|
||||||
if (targetRow.kind === "asset") {
|
|
||||||
return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (targetMindmapId) {
|
if (targetMindmapId) {
|
||||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferredTargetDocId =
|
const inferredTargetDocId =
|
||||||
docId ||
|
payload.targetDocumentId ||
|
||||||
inferPasteTargetDocId({
|
inferFileTreeShellTargetDocumentId({
|
||||||
focusedRowId: resourceSelection.focusedRowId,
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
rowById: resourceRowById,
|
rowById: resourceShellRowById,
|
||||||
activeDocId: activeId || null,
|
activeDocId: activeId || null,
|
||||||
}) ||
|
}) ||
|
||||||
"";
|
"";
|
||||||
@@ -1799,7 +1844,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
[
|
[
|
||||||
activeId,
|
activeId,
|
||||||
editorBridge,
|
editorBridge,
|
||||||
resourceRowById,
|
resourceShellRowById,
|
||||||
resourceSelection.focusedRowId,
|
resourceSelection.focusedRowId,
|
||||||
sidebarData.activeWorkspaceId,
|
sidebarData.activeWorkspaceId,
|
||||||
sidebarData.documents,
|
sidebarData.documents,
|
||||||
@@ -1808,23 +1853,33 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleResourcePaneInternalDrop = useCallback(
|
const handleResourcePaneInternalDrop = useCallback(
|
||||||
(args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {
|
(payload: {
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
targetRowKind: string | null;
|
||||||
|
targetAssetId: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
copy: boolean;
|
||||||
|
}) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const targetDocId = inferDropTargetDocId(args.targetRow);
|
const targetRow =
|
||||||
|
payload.targetRowId
|
||||||
|
? (resourceShellRowById.get(payload.targetRowId) ?? null)
|
||||||
|
: null;
|
||||||
|
const targetDocId =
|
||||||
|
payload.targetDocumentId ??
|
||||||
|
targetRow?.documentId ??
|
||||||
|
inferFileTreeShellTargetDocumentId({
|
||||||
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
|
rowById: resourceShellRowById,
|
||||||
|
activeDocId: activeId || null,
|
||||||
|
});
|
||||||
if (!targetDocId) {
|
if (!targetDocId) {
|
||||||
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
|
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetMindmapId = (() => {
|
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
|
||||||
if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
|
|
||||||
return args.targetRow.asset.id;
|
|
||||||
}
|
|
||||||
if (args.targetRow.kind === "asset") {
|
|
||||||
return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
|
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
|
||||||
|
|
||||||
@@ -1834,26 +1889,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
const uniqueRowIds: string[] = [];
|
const uniqueRowIds: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
args.rowIds.forEach((id) => {
|
payload.rowIds.forEach((id) => {
|
||||||
if (!id || seen.has(id)) return;
|
if (!id || seen.has(id)) return;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
uniqueRowIds.push(id);
|
uniqueRowIds.push(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = uniqueRowIds
|
const rows = uniqueRowIds
|
||||||
.map((rowId) => resourceRowById.get(rowId as any))
|
.map((rowId) => resourceShellRowById.get(rowId) ?? null)
|
||||||
.filter(Boolean) as TreePaneRow[];
|
.filter((row): row is FileTreeShellRow => Boolean(row));
|
||||||
|
|
||||||
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
|
const docIds = rows.filter((row) => row.rowKind === "doc").map((row) => row.documentId);
|
||||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
|
const assetRows = rows.filter(
|
||||||
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
|
(row): row is FileTreeShellRow & { rowKind: "asset"; asset: MediaAsset } =>
|
||||||
|
row.rowKind === "asset" && Boolean(row.asset),
|
||||||
|
);
|
||||||
|
const copyableAssetIds = assetRows
|
||||||
|
.map((row) => row.asset)
|
||||||
|
.filter((asset) => isRealFileAsset(asset))
|
||||||
|
.map((asset) => asset.id);
|
||||||
|
|
||||||
if (docIds.length === 0 && copyableAssetIds.length === 0) {
|
if (docIds.length === 0 && copyableAssetIds.length === 0) {
|
||||||
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
|
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.copy) {
|
if (payload.copy) {
|
||||||
if (docIds.length > 0) {
|
if (docIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
await copyTreeCommand({
|
await copyTreeCommand({
|
||||||
@@ -1894,17 +1955,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
|
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
|
||||||
if (topLevelDocIds.length > 0) {
|
if (topLevelDocIds.length > 0) {
|
||||||
if (
|
|
||||||
isInvalidDocDrop({
|
|
||||||
sourceDocIds: topLevelDocIds,
|
|
||||||
targetParentId: targetDocId,
|
|
||||||
parentById: docParentById,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
|
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
|
||||||
setTree((prev) => {
|
setTree((prev) => {
|
||||||
let next = prev;
|
let next = prev;
|
||||||
@@ -1915,12 +1965,19 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
});
|
});
|
||||||
setExpanded((prev) => new Set(prev).add(targetDocId));
|
setExpanded((prev) => new Set(prev).add(targetDocId));
|
||||||
|
|
||||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
try {
|
||||||
await moveDocumentCommand({
|
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||||
documentId: topLevelDocIds[i],
|
await moveDocumentCommand({
|
||||||
parentId: targetDocId,
|
documentId: topLevelDocIds[i],
|
||||||
position: baseIndex + i,
|
parentId: targetDocId,
|
||||||
});
|
position: baseIndex + i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await refreshTree();
|
||||||
|
const message = error instanceof Error ? error.message : "移动页面失败";
|
||||||
|
setTimeout(() => window.alert(message), 0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await refreshTree();
|
await refreshTree();
|
||||||
emitDocumentsChanged(targetDocId);
|
emitDocumentsChanged(targetDocId);
|
||||||
@@ -1943,7 +2000,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id));
|
const sourceDocIds = new Set(
|
||||||
|
assetRows
|
||||||
|
.map((row) => row.asset?.document_id ?? null)
|
||||||
|
.filter((documentId): documentId is string => Boolean(documentId)),
|
||||||
|
);
|
||||||
sourceDocIds.forEach((id) => emitAssetsChanged(id));
|
sourceDocIds.forEach((id) => emitAssetsChanged(id));
|
||||||
emitAssetsChanged(targetDocId);
|
emitAssetsChanged(targetDocId);
|
||||||
}
|
}
|
||||||
@@ -1952,7 +2013,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
[
|
[
|
||||||
childrenCountByParentId,
|
childrenCountByParentId,
|
||||||
docParentById,
|
docParentById,
|
||||||
resourceRowById,
|
resourceSelection.focusedRowId,
|
||||||
|
activeId,
|
||||||
|
resourceShellRowById,
|
||||||
moveLocalNode,
|
moveLocalNode,
|
||||||
refreshTree,
|
refreshTree,
|
||||||
sidebarQuery,
|
sidebarQuery,
|
||||||
@@ -2719,17 +2782,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
mode="page"
|
mode="page"
|
||||||
rendererFamily={treeRendererFamily}
|
rendererFamily={treeRendererFamily}
|
||||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||||
treeShellEnabled={filter.trim().length === 0}
|
treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
rows={visibleFilteredPrivatePageRows}
|
rows={isRustFamilyTreeRenderer ? undefined : visibleFilteredPrivatePageRows}
|
||||||
|
treeShellRows={effectivePageTreeShellRows}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
|
focusedDocumentId={pageTreeFocusedDocumentId}
|
||||||
onToggleExpand={toggleExpand}
|
onToggleExpand={toggleExpand}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onCreateChild={handleCreate}
|
onCreateChild={handleCreate}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onNavigate={handlePageTreeShellNavigate}
|
onNavigate={handlePageTreeShellNavigate}
|
||||||
onPageContextMenu={handlePageTreeShellContextMenu}
|
onPageContextMenu={handlePageTreeShellContextMenu}
|
||||||
|
onPageExpandChange={handlePageTreeShellExpandChange}
|
||||||
|
onPageFocusChange={handlePageTreeShellFocusChange}
|
||||||
onTreeMutation={handleTreeShellMutation}
|
onTreeMutation={handleTreeShellMutation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2750,9 +2817,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
mode="filetree"
|
mode="filetree"
|
||||||
rendererFamily={treeRendererFamily}
|
rendererFamily={treeRendererFamily}
|
||||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||||
treeShellEnabled={filter.trim().length === 0}
|
treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
rows={resourceRows}
|
rows={isRustFamilyTreeRenderer ? undefined : resourceRows}
|
||||||
|
treeShellItems={effectiveResourceTreeShellItems}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
selectedRowIds={resourceSelection.selectedRowIds}
|
selectedRowIds={resourceSelection.selectedRowIds}
|
||||||
onRowClick={handleResourceRowClick}
|
onRowClick={handleResourceRowClick}
|
||||||
|
|||||||
@@ -5,31 +5,62 @@ import {
|
|||||||
TreeShellIframeHost,
|
TreeShellIframeHost,
|
||||||
type TreeShellPickerItem,
|
type TreeShellPickerItem,
|
||||||
} from "@/components/sidebar/tree-shell-iframe-host";
|
} from "@/components/sidebar/tree-shell-iframe-host";
|
||||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type TreeRendererFamily = "react" | "rust_family";
|
export type TreeRendererFamily = "react" | "rust_family";
|
||||||
|
|
||||||
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
||||||
|
|
||||||
|
export type TreeShellPickerCommand = {
|
||||||
|
kind: "next" | "previous" | "home" | "end" | "pick";
|
||||||
|
seq: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellInternalDropPayload = {
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
targetRowKind: string | null;
|
||||||
|
targetAssetId: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
copy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellExternalDropPayload = {
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
targetRowKind: string | null;
|
||||||
|
targetAssetId: string | null;
|
||||||
|
files: FileList | File[];
|
||||||
|
};
|
||||||
|
|
||||||
type TreeShellHostProps = {
|
type TreeShellHostProps = {
|
||||||
mode: TreeShellHostMode;
|
mode: TreeShellHostMode;
|
||||||
surfaceTestId: string;
|
surfaceTestId: string;
|
||||||
rendererFamily?: TreeRendererFamily;
|
rendererFamily?: TreeRendererFamily;
|
||||||
className?: string;
|
className?: string;
|
||||||
treeShellEnabled?: boolean;
|
treeShellEnabled?: boolean;
|
||||||
|
fallbackImplementation?: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
rootNodeId?: string | null;
|
rootNodeId?: string | null;
|
||||||
activeDocumentId?: string | null;
|
activeDocumentId?: string | null;
|
||||||
|
focusedDocumentId?: string | null;
|
||||||
|
activePickerItemKey?: string | null;
|
||||||
allowRootPick?: boolean;
|
allowRootPick?: boolean;
|
||||||
excludeIds?: string[];
|
excludeIds?: string[];
|
||||||
|
pickerCommand?: TreeShellPickerCommand | null;
|
||||||
pickerItems?: TreeShellPickerItem[];
|
pickerItems?: TreeShellPickerItem[];
|
||||||
fileTreeRows?: FileTreeRow[];
|
pageTreeItems?: PageTreeProjectionItem[];
|
||||||
|
inlineFileTreeItems?: KernelFileTreeProjectionItem[];
|
||||||
channel?: string;
|
channel?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
onNavigate?: (documentId: string) => void;
|
onNavigate?: (documentId: string) => void;
|
||||||
onPick?: (targetId: string | null) => void;
|
onPick?: (targetId: string | null) => void;
|
||||||
|
onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void;
|
||||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||||
|
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
|
||||||
|
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||||
onFileTreeContextMenu?: (payload: {
|
onFileTreeContextMenu?: (payload: {
|
||||||
documentId: string | null;
|
documentId: string | null;
|
||||||
assetId: string | null;
|
assetId: string | null;
|
||||||
@@ -43,8 +74,8 @@ type TreeShellHostProps = {
|
|||||||
anchorRowId: string | null;
|
anchorRowId: string | null;
|
||||||
focusedRowId: string | null;
|
focusedRowId: string | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -56,18 +87,26 @@ export function TreeShellHost({
|
|||||||
rendererFamily = "react",
|
rendererFamily = "react",
|
||||||
className,
|
className,
|
||||||
treeShellEnabled = true,
|
treeShellEnabled = true,
|
||||||
|
fallbackImplementation,
|
||||||
workspaceId = null,
|
workspaceId = null,
|
||||||
rootNodeId = null,
|
rootNodeId = null,
|
||||||
activeDocumentId = null,
|
activeDocumentId = null,
|
||||||
|
focusedDocumentId = null,
|
||||||
|
activePickerItemKey = null,
|
||||||
allowRootPick = false,
|
allowRootPick = false,
|
||||||
excludeIds = [],
|
excludeIds = [],
|
||||||
pickerItems = [],
|
pickerCommand = null,
|
||||||
fileTreeRows = [],
|
pickerItems,
|
||||||
|
pageTreeItems,
|
||||||
|
inlineFileTreeItems,
|
||||||
channel,
|
channel,
|
||||||
host,
|
host,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onPick,
|
onPick,
|
||||||
|
onPickerFocusChange,
|
||||||
onPageContextMenu,
|
onPageContextMenu,
|
||||||
|
onPageExpandChange,
|
||||||
|
onPageFocusChange,
|
||||||
onFileTreeContextMenu,
|
onFileTreeContextMenu,
|
||||||
onFileTreeSelectionChange,
|
onFileTreeSelectionChange,
|
||||||
onInternalDrop,
|
onInternalDrop,
|
||||||
@@ -77,13 +116,12 @@ export function TreeShellHost({
|
|||||||
children,
|
children,
|
||||||
}: TreeShellHostProps) {
|
}: TreeShellHostProps) {
|
||||||
const useRustHost = rendererFamily === "rust_family";
|
const useRustHost = rendererFamily === "rust_family";
|
||||||
const useIframeHost = useRustHost && treeShellEnabled && Boolean(workspaceId?.trim());
|
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
|
||||||
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
||||||
const implementation = useIframeHost
|
const implementation = useIframeHost
|
||||||
? "mnote_web_iframe_proxy"
|
? "mnote_web_iframe_proxy"
|
||||||
: rendererFamily === "rust_family"
|
: fallbackImplementation ??
|
||||||
? "react_fallback"
|
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
|
||||||
: "react_primary";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -92,6 +130,7 @@ export function TreeShellHost({
|
|||||||
data-renderer-family={rendererFamily}
|
data-renderer-family={rendererFamily}
|
||||||
data-tree-host-kind={hostKind}
|
data-tree-host-kind={hostKind}
|
||||||
data-tree-host-implementation={implementation}
|
data-tree-host-implementation={implementation}
|
||||||
|
data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
>
|
>
|
||||||
{useRustHost ? (
|
{useRustHost ? (
|
||||||
@@ -109,15 +148,22 @@ export function TreeShellHost({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
rootNodeId={rootNodeId}
|
rootNodeId={rootNodeId}
|
||||||
activeDocumentId={activeDocumentId}
|
activeDocumentId={activeDocumentId}
|
||||||
|
focusedDocumentId={focusedDocumentId}
|
||||||
|
activePickerItemKey={activePickerItemKey}
|
||||||
allowRootPick={allowRootPick}
|
allowRootPick={allowRootPick}
|
||||||
excludeIds={excludeIds}
|
excludeIds={excludeIds}
|
||||||
|
pickerCommand={pickerCommand}
|
||||||
pickerItems={pickerItems}
|
pickerItems={pickerItems}
|
||||||
fileTreeRows={fileTreeRows}
|
pageTreeItems={pageTreeItems}
|
||||||
|
inlineFileTreeItems={inlineFileTreeItems}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
host={host}
|
host={host}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
|
onPickerFocusChange={onPickerFocusChange}
|
||||||
onPageContextMenu={onPageContextMenu}
|
onPageContextMenu={onPageContextMenu}
|
||||||
|
onPageExpandChange={onPageExpandChange}
|
||||||
|
onPageFocusChange={onPageFocusChange}
|
||||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||||
onInternalDrop={onInternalDrop}
|
onInternalDrop={onInternalDrop}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import {
|
import {
|
||||||
TreeShellIframeHost,
|
TreeShellIframeHost,
|
||||||
|
type TreeShellPickerItem,
|
||||||
|
buildTreeShellInlineKernelFileTreeItems,
|
||||||
|
buildTreeShellInlinePageItems,
|
||||||
buildTreeShellIframeSrc,
|
buildTreeShellIframeSrc,
|
||||||
buildTreeShellInlinePickerItems,
|
buildTreeShellInlinePickerItems,
|
||||||
injectTreeShellInlineOverrides,
|
injectTreeShellInlineOverrides,
|
||||||
@@ -24,6 +31,7 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
|
vi.restoreAllMocks();
|
||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,6 +40,8 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
mode: "picker",
|
mode: "picker",
|
||||||
workspaceId: "ws_picker",
|
workspaceId: "ws_picker",
|
||||||
activeDocumentId: "doc_active",
|
activeDocumentId: "doc_active",
|
||||||
|
focusedDocumentId: "doc_focus",
|
||||||
|
activePickerItemKey: "__root__",
|
||||||
allowRootPick: true,
|
allowRootPick: true,
|
||||||
excludeIds: ["doc_hidden", "doc_other"],
|
excludeIds: ["doc_hidden", "doc_other"],
|
||||||
channel: "tree-picker-surface",
|
channel: "tree-picker-surface",
|
||||||
@@ -43,6 +53,8 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
expect(url.searchParams.get("workspaceId")).toBe("ws_picker");
|
expect(url.searchParams.get("workspaceId")).toBe("ws_picker");
|
||||||
expect(url.searchParams.get("mode")).toBe("picker");
|
expect(url.searchParams.get("mode")).toBe("picker");
|
||||||
expect(url.searchParams.get("activeDocumentId")).toBe("doc_active");
|
expect(url.searchParams.get("activeDocumentId")).toBe("doc_active");
|
||||||
|
expect(url.searchParams.get("focusedDocumentId")).toBe("doc_focus");
|
||||||
|
expect(url.searchParams.get("activePickerItemKey")).toBe("__root__");
|
||||||
expect(url.searchParams.get("allowRootPick")).toBe("1");
|
expect(url.searchParams.get("allowRootPick")).toBe("1");
|
||||||
expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other");
|
expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other");
|
||||||
expect(url.searchParams.get("channel")).toBe("tree-picker-surface");
|
expect(url.searchParams.get("channel")).toBe("tree-picker-surface");
|
||||||
@@ -85,9 +97,409 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("page tree 提供 inline items 时应直接生成 srcDoc,不再 fetch /api/tree/shell", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const pageItems: PageTreeProjectionItem[] = [
|
||||||
|
{
|
||||||
|
rowId: "page:doc_parent",
|
||||||
|
nodeId: "doc_parent",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "page_tree",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
title: "父页面",
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["expand", "open"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "doc_parent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
node: {
|
||||||
|
id: "doc_parent",
|
||||||
|
title: "父页面",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_archived: false,
|
||||||
|
is_deleted: false,
|
||||||
|
is_published: false,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
created_at: "2026-04-24T00:00:00.000Z",
|
||||||
|
updated_at: "2026-04-24T00:00:00.000Z",
|
||||||
|
children: [],
|
||||||
|
kernel: {
|
||||||
|
nodeType: "page",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: 1,
|
||||||
|
expandedByDefault: false,
|
||||||
|
},
|
||||||
|
} as unknown as SidebarTreeNode,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildTreeShellInlinePageItems(pageItems, new Set(["doc_parent"]))).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "doc_parent",
|
||||||
|
parentNodeId: null,
|
||||||
|
title: "父页面",
|
||||||
|
childCount: 1,
|
||||||
|
expandedByDefault: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="page"
|
||||||
|
surfaceTestId="sidebar-page-tree-shell"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_parent"
|
||||||
|
pageTreeItems={pageItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SSR 时 inline tree shell 应先输出稳定 loading srcDoc,避免大模板属性水合不一致", () => {
|
||||||
|
const pageItems: PageTreeProjectionItem[] = [
|
||||||
|
{
|
||||||
|
rowId: "page:doc_parent",
|
||||||
|
nodeId: "doc_parent",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "page_tree",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
title: "父页面",
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "doc_parent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
node: {} as SidebarTreeNode,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = renderToString(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="page"
|
||||||
|
surfaceTestId="sidebar-page-tree-shell"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_parent"
|
||||||
|
pageTreeItems={pageItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Tree Shell Loading");
|
||||||
|
expect(html).not.toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||||
|
expect(html).not.toContain("父页面");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page tree 提供空 inline items 时也应直接生成 srcDoc", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="page"
|
||||||
|
surfaceTestId="sidebar-page-tree-shell"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId={null}
|
||||||
|
pageTreeItems={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fileTreeItems: KernelFileTreeProjectionItem[] = [
|
||||||
|
{
|
||||||
|
rowId: "doc:doc_a",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "doc_a",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "文档 A",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: 2,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: ["expand", "open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "doc_a",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:asset_pdf",
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: "asset:asset_pdf",
|
||||||
|
parentNodeId: "doc_a",
|
||||||
|
nodeType: "pdf",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "guide.pdf",
|
||||||
|
depth: 1,
|
||||||
|
position: 1,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "pdf",
|
||||||
|
documentId: "doc_a",
|
||||||
|
assetId: "asset_pdf",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "pdf",
|
||||||
|
iconHint: "pdf",
|
||||||
|
},
|
||||||
|
iconHint: "pdf",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildTreeShellInlineKernelFileTreeItems(fileTreeItems)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "doc_a",
|
||||||
|
rowKind: "document",
|
||||||
|
iconHint: "page",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "asset:asset_pdf",
|
||||||
|
rowKind: "asset",
|
||||||
|
iconHint: "pdf",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="filetree"
|
||||||
|
surfaceTestId="sidebar-file-tree-shell"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_a"
|
||||||
|
inlineFileTreeItems={fileTreeItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picker inline override 在高亮变化时应复用 bootstrap 文档,并通过 postMessage 同步状态", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const pickerItems: TreeShellPickerItem[] = [{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="picker"
|
||||||
|
surfaceTestId="tree-picker-surface"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_1"
|
||||||
|
activePickerItemKey="doc_1"
|
||||||
|
pickerItems={pickerItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
Object.defineProperty(iframe, "contentWindow", {
|
||||||
|
configurable: true,
|
||||||
|
value: { postMessage },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="picker"
|
||||||
|
surfaceTestId="tree-picker-surface"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_2"
|
||||||
|
activePickerItemKey="doc_2"
|
||||||
|
pickerItems={pickerItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(postMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.shell.state.patch",
|
||||||
|
activeDocumentId: "doc_2",
|
||||||
|
activePickerItemKey: "doc_2",
|
||||||
|
}),
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picker 宿主应向 iframe 下发键盘命令,并接回焦点变化事件", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ status: 200, headers: { "content-type": "text/html" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const onPickerFocusChange = vi.fn();
|
||||||
|
const pickerItems: TreeShellPickerItem[] = [
|
||||||
|
{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 },
|
||||||
|
{ kind: "doc", id: "doc_2", title: "页面 2", depth: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="picker"
|
||||||
|
surfaceTestId="tree-picker-surface"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_1"
|
||||||
|
activePickerItemKey="doc_1"
|
||||||
|
pickerItems={pickerItems}
|
||||||
|
onPickerFocusChange={onPickerFocusChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
Object.defineProperty(iframe, "contentWindow", {
|
||||||
|
configurable: true,
|
||||||
|
value: window,
|
||||||
|
});
|
||||||
|
vi.spyOn(window, "postMessage").mockImplementation(postMessage);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="picker"
|
||||||
|
surfaceTestId="tree-picker-surface"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_1"
|
||||||
|
activePickerItemKey="doc_1"
|
||||||
|
pickerItems={pickerItems}
|
||||||
|
pickerCommand={{ kind: "next", seq: 1 }}
|
||||||
|
onPickerFocusChange={onPickerFocusChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(postMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.picker.command",
|
||||||
|
command: "next",
|
||||||
|
}),
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
type: "tree.picker.focus.changed",
|
||||||
|
documentId: "doc_2",
|
||||||
|
itemKey: "doc_2",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onPickerFocusChange).toHaveBeenCalledWith({
|
||||||
|
documentId: "doc_2",
|
||||||
|
itemKey: "doc_2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => {
|
it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => {
|
||||||
const onNavigate = vi.fn();
|
const onNavigate = vi.fn();
|
||||||
const onPageContextMenu = vi.fn();
|
const onPageContextMenu = vi.fn();
|
||||||
|
const onPageExpandChange = vi.fn();
|
||||||
|
const onPageFocusChange = vi.fn();
|
||||||
const onPick = vi.fn();
|
const onPick = vi.fn();
|
||||||
const onFileTreeContextMenu = vi.fn();
|
const onFileTreeContextMenu = vi.fn();
|
||||||
const onFileTreeSelectionChange = vi.fn();
|
const onFileTreeSelectionChange = vi.fn();
|
||||||
@@ -95,20 +507,6 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
const onTreeMutation = vi.fn();
|
const onTreeMutation = vi.fn();
|
||||||
const onInternalDrop = vi.fn();
|
const onInternalDrop = vi.fn();
|
||||||
const onDropFiles = vi.fn();
|
const onDropFiles = vi.fn();
|
||||||
const targetRow = {
|
|
||||||
kind: "doc",
|
|
||||||
rowId: "doc:doc_target",
|
|
||||||
depth: 0,
|
|
||||||
docId: "doc_target",
|
|
||||||
parentDocId: null,
|
|
||||||
node: {
|
|
||||||
_id: "doc_target",
|
|
||||||
id: "doc_target",
|
|
||||||
title: "目标页面",
|
|
||||||
},
|
|
||||||
hasChildren: false,
|
|
||||||
isExpanded: false,
|
|
||||||
};
|
|
||||||
const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -122,6 +520,8 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
host="sidebar-file-tree-shell"
|
host="sidebar-file-tree-shell"
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onPageContextMenu={onPageContextMenu}
|
onPageContextMenu={onPageContextMenu}
|
||||||
|
onPageExpandChange={onPageExpandChange}
|
||||||
|
onPageFocusChange={onPageFocusChange}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||||
@@ -178,6 +578,27 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
source: window,
|
source: window,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.page.expand.changed",
|
||||||
|
documentId: "doc_2",
|
||||||
|
expanded: true,
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.page.focus.changed",
|
||||||
|
documentId: "doc_3",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new MessageEvent("message", {
|
new MessageEvent("message", {
|
||||||
data: {
|
data: {
|
||||||
@@ -244,6 +665,13 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
x: 12,
|
x: 12,
|
||||||
y: 34,
|
y: 34,
|
||||||
});
|
});
|
||||||
|
expect(onPageExpandChange).toHaveBeenCalledWith({
|
||||||
|
documentId: "doc_2",
|
||||||
|
expanded: true,
|
||||||
|
});
|
||||||
|
expect(onPageFocusChange).toHaveBeenCalledWith({
|
||||||
|
documentId: "doc_3",
|
||||||
|
});
|
||||||
expect(onPick).toHaveBeenCalledWith(null);
|
expect(onPick).toHaveBeenCalledWith(null);
|
||||||
expect(onFileTreeContextMenu).toHaveBeenCalledWith({
|
expect(onFileTreeContextMenu).toHaveBeenCalledWith({
|
||||||
documentId: "doc_2",
|
documentId: "doc_2",
|
||||||
@@ -275,7 +703,9 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
type: "tree.filetree.internal-drop",
|
type: "tree.filetree.internal-drop",
|
||||||
rowIds: ["doc:doc_source", "asset:asset_source"],
|
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||||
copy: true,
|
copy: true,
|
||||||
targetRow,
|
rowId: "doc:doc_target",
|
||||||
|
rowKind: "doc",
|
||||||
|
documentId: "doc_target",
|
||||||
},
|
},
|
||||||
source: window,
|
source: window,
|
||||||
}),
|
}),
|
||||||
@@ -286,7 +716,8 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
channel: "sidebar-file-tree-shell",
|
channel: "sidebar-file-tree-shell",
|
||||||
type: "tree.filetree.drop-files",
|
type: "tree.filetree.drop-files",
|
||||||
documentId: "doc_target",
|
documentId: "doc_target",
|
||||||
targetRow,
|
rowId: "doc:doc_target",
|
||||||
|
rowKind: "doc",
|
||||||
files: [droppedFile],
|
files: [droppedFile],
|
||||||
},
|
},
|
||||||
source: window,
|
source: window,
|
||||||
@@ -295,10 +726,19 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(onInternalDrop).toHaveBeenCalledWith({
|
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||||
targetRow,
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: "doc:doc_target",
|
||||||
|
targetRowKind: "doc",
|
||||||
|
targetAssetId: null,
|
||||||
rowIds: ["doc:doc_source", "asset:asset_source"],
|
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||||
copy: true,
|
copy: true,
|
||||||
});
|
});
|
||||||
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
expect(onDropFiles).toHaveBeenCalledWith({
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: "doc:doc_target",
|
||||||
|
targetRowKind: "doc",
|
||||||
|
targetAssetId: null,
|
||||||
|
files: [droppedFile],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,6 @@ import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "
|
|||||||
|
|
||||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
vi.mock("@/components/sidebar/private-tree", () => ({
|
|
||||||
PrivateTree: () => <div data-testid="private-tree-fallback" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/sidebar/file-tree", () => ({
|
|
||||||
FileTree: () => <div data-testid="file-tree-fallback" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("tree-shell-surface", () => {
|
describe("tree-shell-surface", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
@@ -30,7 +22,7 @@ describe("tree-shell-surface", () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderPageSurface(rendererFamily: TreeRendererFamily) {
|
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<SidebarTreeSurface
|
<SidebarTreeSurface
|
||||||
@@ -41,6 +33,7 @@ describe("tree-shell-surface", () => {
|
|||||||
rows={[]}
|
rows={[]}
|
||||||
expanded={new Set<string>()}
|
expanded={new Set<string>()}
|
||||||
activeId=""
|
activeId=""
|
||||||
|
focusedDocumentId={focusedDocumentId}
|
||||||
onToggleExpand={() => undefined}
|
onToggleExpand={() => undefined}
|
||||||
onMove={() => undefined}
|
onMove={() => undefined}
|
||||||
onCreateChild={() => undefined}
|
onCreateChild={() => undefined}
|
||||||
@@ -64,11 +57,21 @@ describe("tree-shell-surface", () => {
|
|||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="private-tree-fallback"]')).toBeNull();
|
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("page tree 在禁用 tree shell 时应安全回退到 React fallback", () => {
|
it("page tree surface 在 rust_family 下应把 focusedDocumentId 透传到 iframe", () => {
|
||||||
act(() => {
|
renderPageSurface("rust_family", "doc_focus");
|
||||||
|
|
||||||
|
const iframe = container.querySelector(
|
||||||
|
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
|
||||||
|
) as HTMLIFrameElement | null;
|
||||||
|
|
||||||
|
expect(iframe?.getAttribute("src")).toContain("focusedDocumentId=doc_focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||||
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<SidebarTreeSurface
|
<SidebarTreeSurface
|
||||||
mode="page"
|
mode="page"
|
||||||
@@ -87,8 +90,33 @@ describe("tree-shell-surface", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
expect(container.querySelector('[data-testid="private-tree-fallback"]')).not.toBeNull();
|
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="page"
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId={null}
|
||||||
|
treeShellEnabled
|
||||||
|
rows={[]}
|
||||||
|
expanded={new Set<string>()}
|
||||||
|
activeId=""
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onMove={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
onContextMenu={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("page_tree_renderer_removed");
|
||||||
|
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
|
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
|
||||||
@@ -122,26 +150,63 @@ describe("tree-shell-surface", () => {
|
|||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="file-tree-fallback"]')).toBeNull();
|
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="filetree"
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled={false}
|
||||||
|
rows={[]}
|
||||||
|
activeId=""
|
||||||
|
selectedRowIds={new Set<string>()}
|
||||||
|
onRowClick={() => undefined}
|
||||||
|
onRowDoubleClick={() => undefined}
|
||||||
|
onRowContextMenu={() => undefined}
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("file tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="filetree"
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId={null}
|
||||||
|
treeShellEnabled
|
||||||
|
rows={[]}
|
||||||
|
activeId=""
|
||||||
|
selectedRowIds={new Set<string>()}
|
||||||
|
onRowClick={() => undefined}
|
||||||
|
onRowDoubleClick={() => undefined}
|
||||||
|
onRowContextMenu={() => undefined}
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("filetree_renderer_removed");
|
||||||
|
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
|
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
|
||||||
const onInternalDrop = vi.fn();
|
const onInternalDrop = vi.fn();
|
||||||
const onDropFiles = vi.fn();
|
const onDropFiles = vi.fn();
|
||||||
const targetRow = {
|
|
||||||
kind: "doc",
|
|
||||||
rowId: "doc:doc_target",
|
|
||||||
depth: 0,
|
|
||||||
docId: "doc_target",
|
|
||||||
parentDocId: null,
|
|
||||||
node: {
|
|
||||||
_id: "doc_target",
|
|
||||||
id: "doc_target",
|
|
||||||
title: "目标页面",
|
|
||||||
},
|
|
||||||
hasChildren: false,
|
|
||||||
isExpanded: false,
|
|
||||||
};
|
|
||||||
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
|
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -151,7 +216,7 @@ describe("tree-shell-surface", () => {
|
|||||||
rendererFamily="rust_family"
|
rendererFamily="rust_family"
|
||||||
workspaceId="ws_1"
|
workspaceId="ws_1"
|
||||||
treeShellEnabled
|
treeShellEnabled
|
||||||
rows={[targetRow]}
|
rows={[]}
|
||||||
activeId=""
|
activeId=""
|
||||||
selectedRowIds={new Set<string>()}
|
selectedRowIds={new Set<string>()}
|
||||||
onRowClick={() => undefined}
|
onRowClick={() => undefined}
|
||||||
@@ -182,7 +247,9 @@ describe("tree-shell-surface", () => {
|
|||||||
type: "tree.filetree.internal-drop",
|
type: "tree.filetree.internal-drop",
|
||||||
rowIds: ["doc:doc_source"],
|
rowIds: ["doc:doc_source"],
|
||||||
copy: false,
|
copy: false,
|
||||||
targetRow,
|
rowId: "doc:doc_target",
|
||||||
|
rowKind: "doc",
|
||||||
|
documentId: "doc_target",
|
||||||
},
|
},
|
||||||
source: window,
|
source: window,
|
||||||
}),
|
}),
|
||||||
@@ -193,7 +260,8 @@ describe("tree-shell-surface", () => {
|
|||||||
channel: "sidebar-file-tree-shell",
|
channel: "sidebar-file-tree-shell",
|
||||||
type: "tree.filetree.external-drop",
|
type: "tree.filetree.external-drop",
|
||||||
documentId: "doc_target",
|
documentId: "doc_target",
|
||||||
targetRow,
|
rowId: "doc:doc_target",
|
||||||
|
rowKind: "doc",
|
||||||
files: [droppedFile],
|
files: [droppedFile],
|
||||||
},
|
},
|
||||||
source: window,
|
source: window,
|
||||||
@@ -202,11 +270,20 @@ describe("tree-shell-surface", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(onInternalDrop).toHaveBeenCalledWith({
|
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||||
targetRow,
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: "doc:doc_target",
|
||||||
|
targetRowKind: "doc",
|
||||||
|
targetAssetId: null,
|
||||||
rowIds: ["doc:doc_source"],
|
rowIds: ["doc:doc_source"],
|
||||||
copy: false,
|
copy: false,
|
||||||
});
|
});
|
||||||
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
expect(onDropFiles).toHaveBeenCalledWith({
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: "doc:doc_target",
|
||||||
|
targetRowKind: "doc",
|
||||||
|
targetAssetId: null,
|
||||||
|
files: [droppedFile],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
|
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
|
||||||
@@ -238,8 +315,8 @@ describe("tree-shell-surface", () => {
|
|||||||
expect(onPick).not.toHaveBeenCalled();
|
expect(onPick).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picker 在 rust_family 但 tree shell 不可用时仍应保留 React fallback", () => {
|
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||||
act(() => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<TreePickerSurface
|
<TreePickerSurface
|
||||||
rendererFamily="rust_family"
|
rendererFamily="rust_family"
|
||||||
@@ -254,13 +331,17 @@ describe("tree-shell-surface", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||||
const row = container.querySelector('[data-testid="tree-picker-row"]');
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(row).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DragEvent, MouseEvent } from "react";
|
import type { DragEvent, MouseEvent } from "react";
|
||||||
import { FileTree } from "@/components/sidebar/file-tree";
|
import {
|
||||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
TreeShellHost,
|
||||||
import { TreeShellHost, type TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
|
type FileTreeShellExternalDropPayload,
|
||||||
|
type FileTreeShellInternalDropPayload,
|
||||||
|
type TreeShellPickerCommand,
|
||||||
|
type TreeRendererFamily,
|
||||||
|
} from "@/components/sidebar/tree-shell-host";
|
||||||
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
@@ -16,9 +21,11 @@ type SidebarPageTreeSurfaceProps = {
|
|||||||
rendererFamily?: TreeRendererFamily;
|
rendererFamily?: TreeRendererFamily;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
treeShellEnabled?: boolean;
|
treeShellEnabled?: boolean;
|
||||||
rows: PageTreeProjectionItem[];
|
rows?: PageTreeProjectionItem[];
|
||||||
|
treeShellRows?: PageTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
|
focusedDocumentId?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
||||||
@@ -26,6 +33,8 @@ type SidebarPageTreeSurfaceProps = {
|
|||||||
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
|
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
|
||||||
onNavigate?: (documentId: string) => void;
|
onNavigate?: (documentId: string) => void;
|
||||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||||
|
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
|
||||||
|
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +43,8 @@ type SidebarFileTreeSurfaceProps = {
|
|||||||
rendererFamily?: TreeRendererFamily;
|
rendererFamily?: TreeRendererFamily;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
treeShellEnabled?: boolean;
|
treeShellEnabled?: boolean;
|
||||||
rows: FileTreeRow[];
|
rows?: FileTreeRow[];
|
||||||
|
treeShellItems?: KernelFileTreeProjectionItem[];
|
||||||
activeId: string;
|
activeId: string;
|
||||||
selectedRowIds: Set<string>;
|
selectedRowIds: Set<string>;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -46,8 +56,8 @@ type SidebarFileTreeSurfaceProps = {
|
|||||||
onToggleAssetFolderExpand?: (assetId: string) => void;
|
onToggleAssetFolderExpand?: (assetId: string) => void;
|
||||||
onCreateChild: (parentId: string | null) => void;
|
onCreateChild: (parentId: string | null) => void;
|
||||||
onBlankMouseDown?: (event: MouseEvent) => void;
|
onBlankMouseDown?: (event: MouseEvent) => void;
|
||||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||||
onNavigate?: (documentId: string) => void;
|
onNavigate?: (documentId: string) => void;
|
||||||
onFileTreeContextMenu?: (payload: {
|
onFileTreeContextMenu?: (payload: {
|
||||||
documentId: string | null;
|
documentId: string | null;
|
||||||
@@ -79,8 +89,10 @@ type TreePickerSurfaceProps = {
|
|||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
treeShellEnabled?: boolean;
|
treeShellEnabled?: boolean;
|
||||||
activeDocumentId?: string | null;
|
activeDocumentId?: string | null;
|
||||||
|
activePickerItemKey?: string | null;
|
||||||
allowRootPick?: boolean;
|
allowRootPick?: boolean;
|
||||||
excludeIds?: string[];
|
excludeIds?: string[];
|
||||||
|
pickerCommand?: TreeShellPickerCommand | null;
|
||||||
treeShellItems?: TreePickerSurfaceItem[];
|
treeShellItems?: TreePickerSurfaceItem[];
|
||||||
items: TreePickerSurfaceItem[];
|
items: TreePickerSurfaceItem[];
|
||||||
highlighted: number;
|
highlighted: number;
|
||||||
@@ -88,6 +100,7 @@ type TreePickerSurfaceProps = {
|
|||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
onHighlight: (index: number) => void;
|
onHighlight: (index: number) => void;
|
||||||
onPick: (targetId: string | null) => void;
|
onPick: (targetId: string | null) => void;
|
||||||
|
onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
||||||
@@ -96,33 +109,27 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
|||||||
? "sidebar-page-tree-shell"
|
? "sidebar-page-tree-shell"
|
||||||
: "sidebar-file-tree-shell";
|
: "sidebar-file-tree-shell";
|
||||||
const rendererFamily = props.rendererFamily ?? "react";
|
const rendererFamily = props.rendererFamily ?? "react";
|
||||||
|
const pageTreeFallback = (
|
||||||
|
<div
|
||||||
|
data-testid="page-tree-renderer-removed"
|
||||||
|
className="flex h-full items-center justify-center px-4 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
页面树旧 React renderer 已退出;请使用 Rust tree shell 宿主。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const fileTreeFallback = (
|
||||||
|
<div
|
||||||
|
data-testid="file-tree-renderer-removed"
|
||||||
|
className="flex h-full items-center justify-center px-4 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
文件树旧 React renderer 已退出;请使用 Rust tree shell 宿主。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
const fallbackContent =
|
const fallbackContent =
|
||||||
props.mode === "page" ? (
|
props.mode === "page" ? (
|
||||||
<PrivateTree
|
pageTreeFallback
|
||||||
rows={props.rows}
|
|
||||||
expanded={props.expanded}
|
|
||||||
activeId={props.activeId}
|
|
||||||
onToggleExpand={props.onToggleExpand}
|
|
||||||
onMove={props.onMove}
|
|
||||||
onCreateChild={props.onCreateChild}
|
|
||||||
onContextMenu={props.onContextMenu}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<FileTree
|
fileTreeFallback
|
||||||
rows={props.rows}
|
|
||||||
activeId={props.activeId}
|
|
||||||
selectedRowIds={props.selectedRowIds}
|
|
||||||
onRowClick={props.onRowClick}
|
|
||||||
onRowDoubleClick={props.onRowDoubleClick}
|
|
||||||
onRowContextMenu={props.onRowContextMenu}
|
|
||||||
onRowDragStart={props.onRowDragStart}
|
|
||||||
onToggleExpand={props.onToggleExpand}
|
|
||||||
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
|
|
||||||
onCreateChild={props.onCreateChild}
|
|
||||||
onBlankMouseDown={props.onBlankMouseDown}
|
|
||||||
onDropFiles={props.onDropFiles}
|
|
||||||
onInternalDrop={props.onInternalDrop}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,11 +138,20 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
|||||||
surfaceTestId={surfaceTestId}
|
surfaceTestId={surfaceTestId}
|
||||||
rendererFamily={rendererFamily}
|
rendererFamily={rendererFamily}
|
||||||
treeShellEnabled={props.treeShellEnabled}
|
treeShellEnabled={props.treeShellEnabled}
|
||||||
|
fallbackImplementation={
|
||||||
|
props.mode === "page"
|
||||||
|
? "page_tree_renderer_removed"
|
||||||
|
: "filetree_renderer_removed"
|
||||||
|
}
|
||||||
workspaceId={props.workspaceId}
|
workspaceId={props.workspaceId}
|
||||||
activeDocumentId={props.activeId}
|
activeDocumentId={props.activeId}
|
||||||
fileTreeRows={props.mode === "filetree" ? props.rows : undefined}
|
focusedDocumentId={props.mode === "page" ? (props.focusedDocumentId ?? null) : undefined}
|
||||||
|
pageTreeItems={props.mode === "page" ? props.treeShellRows : undefined}
|
||||||
|
inlineFileTreeItems={props.mode === "filetree" ? props.treeShellItems : undefined}
|
||||||
onNavigate={props.onNavigate}
|
onNavigate={props.onNavigate}
|
||||||
onPageContextMenu={props.mode === "page" ? props.onPageContextMenu : undefined}
|
onPageContextMenu={props.mode === "page" ? props.onPageContextMenu : undefined}
|
||||||
|
onPageExpandChange={props.mode === "page" ? props.onPageExpandChange : undefined}
|
||||||
|
onPageFocusChange={props.mode === "page" ? props.onPageFocusChange : undefined}
|
||||||
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
|
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
|
||||||
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
|
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
|
||||||
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
|
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
|
||||||
@@ -157,8 +173,10 @@ export function TreePickerSurface({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
treeShellEnabled = true,
|
treeShellEnabled = true,
|
||||||
activeDocumentId = null,
|
activeDocumentId = null,
|
||||||
|
activePickerItemKey = null,
|
||||||
allowRootPick = false,
|
allowRootPick = false,
|
||||||
excludeIds = [],
|
excludeIds = [],
|
||||||
|
pickerCommand = null,
|
||||||
treeShellItems,
|
treeShellItems,
|
||||||
items,
|
items,
|
||||||
highlighted,
|
highlighted,
|
||||||
@@ -166,8 +184,11 @@ export function TreePickerSurface({
|
|||||||
emptyText = "没有匹配结果",
|
emptyText = "没有匹配结果",
|
||||||
onHighlight,
|
onHighlight,
|
||||||
onPick,
|
onPick,
|
||||||
|
onPickerFocusChange,
|
||||||
}: TreePickerSurfaceProps) {
|
}: TreePickerSurfaceProps) {
|
||||||
const hasItems = items.length > 0;
|
const hasItems = items.length > 0;
|
||||||
|
const effectiveTreeShellItems =
|
||||||
|
rendererFamily === "rust_family" ? (treeShellItems ?? items) : treeShellItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeShellHost
|
<TreeShellHost
|
||||||
@@ -177,10 +198,13 @@ export function TreePickerSurface({
|
|||||||
treeShellEnabled={treeShellEnabled}
|
treeShellEnabled={treeShellEnabled}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
activeDocumentId={activeDocumentId}
|
activeDocumentId={activeDocumentId}
|
||||||
|
activePickerItemKey={activePickerItemKey}
|
||||||
allowRootPick={allowRootPick}
|
allowRootPick={allowRootPick}
|
||||||
excludeIds={excludeIds}
|
excludeIds={excludeIds}
|
||||||
pickerItems={treeShellItems}
|
pickerCommand={pickerCommand}
|
||||||
|
pickerItems={effectiveTreeShellItems}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
|
onPickerFocusChange={onPickerFocusChange}
|
||||||
className={cn(hasItems ? "py-2" : null, className)}
|
className={cn(hasItems ? "py-2" : null, className)}
|
||||||
>
|
>
|
||||||
{!hasItems ? (
|
{!hasItems ? (
|
||||||
|
|||||||
@@ -2,20 +2,25 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
return (
|
({ className, type, ...props }, ref) => {
|
||||||
<input
|
return (
|
||||||
type={type}
|
<input
|
||||||
data-slot="input"
|
ref={ref}
|
||||||
className={cn(
|
type={type}
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
data-slot="input"
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
className={cn(
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
)}
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{...props}
|
className
|
||||||
/>
|
)}
|
||||||
)
|
{...props}
|
||||||
}
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<T>;
|
envelope: CommandEnvelope<T>;
|
||||||
client?: ConvexHttpClient;
|
client?: ConvexHttpClient;
|
||||||
|
commandPayload?: unknown;
|
||||||
status?: BridgeCommandLogStatus;
|
status?: BridgeCommandLogStatus;
|
||||||
eventStatus?: BridgeDomainEventStatus;
|
eventStatus?: BridgeDomainEventStatus;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
@@ -35,7 +36,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
const commandLogId = `clog_${input.envelope.commandId}`;
|
const commandLogId = `clog_${input.envelope.commandId}`;
|
||||||
const eventId = `evt_${input.envelope.commandId}`;
|
const eventId = `evt_${input.envelope.commandId}`;
|
||||||
const now = input.now ?? new Date().toISOString();
|
const now = input.now ?? new Date().toISOString();
|
||||||
const payload = input.envelope.payload as Record<string, unknown>;
|
const payload = input.commandPayload ?? input.envelope.payload;
|
||||||
const status = input.status ?? "succeeded";
|
const status = input.status ?? "succeeded";
|
||||||
const eventStatus =
|
const eventStatus =
|
||||||
input.eventStatus ??
|
input.eventStatus ??
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type CommandEnvelope<T> = {
|
|||||||
source: BridgeSource;
|
source: BridgeSource;
|
||||||
target: BridgeTarget | null;
|
target: BridgeTarget | null;
|
||||||
payload: T;
|
payload: T;
|
||||||
|
preflightData?: Record<string, unknown> | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
refs: string[];
|
refs: string[];
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
@@ -275,6 +276,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
|
|||||||
payload: T;
|
payload: T;
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
target?: BridgeTarget | null;
|
target?: BridgeTarget | null;
|
||||||
|
preflightData?: Record<string, unknown> | null;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
refs?: string[];
|
refs?: string[];
|
||||||
}): CommandEnvelope<T> {
|
}): CommandEnvelope<T> {
|
||||||
@@ -286,6 +288,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
|
|||||||
source: input.context.source,
|
source: input.context.source,
|
||||||
target: input.target ?? null,
|
target: input.target ?? null,
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
|
preflightData: input.preflightData ?? null,
|
||||||
reason: input.reason ?? null,
|
reason: input.reason ?? null,
|
||||||
refs: input.refs ?? [],
|
refs: input.refs ?? [],
|
||||||
dryRun: input.context.dryRun,
|
dryRun: input.context.dryRun,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
compareDocumentCanonicalOrder,
|
compareDocumentCanonicalOrder,
|
||||||
getCanonicalDocumentByBusinessId,
|
getCanonicalDocumentByBusinessId,
|
||||||
getCanonicalParentDocumentId,
|
getCanonicalParentDocumentId,
|
||||||
|
pickCanonicalDocumentRecordsByBusinessId,
|
||||||
pickCanonicalDocumentRecord,
|
pickCanonicalDocumentRecord,
|
||||||
} from "../../../convex/_utils/documentRecord";
|
} from "../../../convex/_utils/documentRecord";
|
||||||
|
|
||||||
@@ -176,3 +177,37 @@ describe("canonical document helper", () => {
|
|||||||
expect(parentId).toBe("parent_alive");
|
expect(parentId).toBe("parent_alive");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pickCanonicalDocumentRecordsByBusinessId", () => {
|
||||||
|
it("同一 workspace 扫描结果里 business id 重复时应先折叠成 canonical 记录,供树命令写链复用", () => {
|
||||||
|
const records = pickCanonicalDocumentRecordsByBusinessId([
|
||||||
|
{
|
||||||
|
_id: "doc_old",
|
||||||
|
id: "doc_1",
|
||||||
|
parent_id: "parent_old",
|
||||||
|
deleted_at: null,
|
||||||
|
created_at: "2026-04-14T00:00:00.000Z",
|
||||||
|
updated_at: "2026-04-14T00:00:01.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "doc_new",
|
||||||
|
id: "doc_1",
|
||||||
|
parent_id: "parent_new",
|
||||||
|
deleted_at: null,
|
||||||
|
created_at: "2026-04-14T00:00:02.000Z",
|
||||||
|
updated_at: "2026-04-14T00:00:03.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "doc_2",
|
||||||
|
id: "doc_2",
|
||||||
|
parent_id: null,
|
||||||
|
deleted_at: null,
|
||||||
|
created_at: "2026-04-14T00:00:04.000Z",
|
||||||
|
updated_at: "2026-04-14T00:00:05.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
expect(records.map((record) => record._id)).toEqual(["doc_new", "doc_2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildParentById, isAncestorOf } from "../../../convex/_utils/documentTree";
|
||||||
|
|
||||||
|
describe("document tree helper", () => {
|
||||||
|
it("构建父链映射时保留页面与父页面关系,供树命令 legality 复用", () => {
|
||||||
|
const parentById = buildParentById([
|
||||||
|
{ id: "root", parent_id: null },
|
||||||
|
{ id: "child", parent_id: "root" },
|
||||||
|
{ id: "leaf", parent_id: "child" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(parentById.get("root")).toBeNull();
|
||||||
|
expect(parentById.get("child")).toBe("root");
|
||||||
|
expect(parentById.get("leaf")).toBe("child");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("祖先判断应能识别多级后代,避免把页面移动到自己的子树下面", () => {
|
||||||
|
const parentById = buildParentById([
|
||||||
|
{ id: "root", parent_id: null },
|
||||||
|
{ id: "child", parent_id: "root" },
|
||||||
|
{ id: "leaf", parent_id: "child" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(isAncestorOf("root", "leaf", parentById)).toBe(true);
|
||||||
|
expect(isAncestorOf("child", "leaf", parentById)).toBe(true);
|
||||||
|
expect(isAncestorOf("leaf", "root", parentById)).toBe(false);
|
||||||
|
expect(isAncestorOf("missing", "leaf", parentById)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { buildParentById } from "@/lib/file-tree/dnd";
|
import { buildParentById } from "@/lib/file-tree/dnd";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContextWithActor,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
vi.mock("next/server", () => ({
|
vi.mock("next/server", () => ({
|
||||||
NextResponse: {
|
NextResponse: {
|
||||||
@@ -42,6 +55,7 @@ vi.mock("@/lib/server/local-paths", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
handleDocumentMoveRequest,
|
||||||
normalizeDocumentCopyTreePayload,
|
normalizeDocumentCopyTreePayload,
|
||||||
normalizeDocumentMovePayload,
|
normalizeDocumentMovePayload,
|
||||||
resolveSubtreeMoveLegality,
|
resolveSubtreeMoveLegality,
|
||||||
@@ -128,4 +142,244 @@ describe("page-lifecycle-command-adapter", () => {
|
|||||||
isInvalid: false,
|
isInvalid: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("documents.move 应把 movePreflight 透传给 Rust plan", async () => {
|
||||||
|
const client = {
|
||||||
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
||||||
|
if (name !== "documents:getMeta") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (args.id === "doc_1") {
|
||||||
|
return {
|
||||||
|
id: "doc_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
parent_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args.id === "child_1") {
|
||||||
|
return {
|
||||||
|
id: "child_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
parent_id: "doc_1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
mutation: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
|
client: client as never,
|
||||||
|
});
|
||||||
|
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_move_1",
|
||||||
|
traceId: "trace_move_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.move",
|
||||||
|
commandId: "cmd_move_1",
|
||||||
|
functionName: "documents:move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_move_1",
|
||||||
|
traceId: "trace_move_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
parentId: "child_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true } as never);
|
||||||
|
|
||||||
|
const response = await handleDocumentMoveRequest(
|
||||||
|
new Request("http://127.0.0.1:3000/api/documents/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "child_1",
|
||||||
|
position: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
|
context: expect.objectContaining({
|
||||||
|
requestId: "req_move_1",
|
||||||
|
}),
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.move",
|
||||||
|
preflightData: {
|
||||||
|
sourceDocument: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
targetParentDocument: {
|
||||||
|
id: "child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "doc_1",
|
||||||
|
},
|
||||||
|
targetAncestorIds: ["doc_1"],
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "child_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
movePreflight: {
|
||||||
|
sourceDocument: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
targetParentDocument: {
|
||||||
|
id: "child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "doc_1",
|
||||||
|
},
|
||||||
|
targetAncestorIds: ["doc_1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents.move 失败时记录的 failure artifact 仍应保持 move envelope", async () => {
|
||||||
|
const client = {
|
||||||
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
||||||
|
if (name !== "documents:getMeta") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (args.id === "doc_1") {
|
||||||
|
return {
|
||||||
|
id: "doc_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
parent_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args.id === "child_1") {
|
||||||
|
return {
|
||||||
|
id: "child_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
parent_id: "doc_1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
mutation: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
|
client: client as never,
|
||||||
|
});
|
||||||
|
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_move_2",
|
||||||
|
traceId: "trace_move_2",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockRejectedValue(new Error("move failed"));
|
||||||
|
vi.mocked(documentBridgeErrorResponse).mockImplementation((error: unknown) => ({
|
||||||
|
body: { error: error instanceof Error ? error.message : String(error) },
|
||||||
|
status: 500,
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
const response = await handleDocumentMoveRequest(
|
||||||
|
new Request("http://127.0.0.1:3000/api/documents/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "child_1",
|
||||||
|
position: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(recordBridgeCommandFailureArtifacts).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.move",
|
||||||
|
preflightData: {
|
||||||
|
sourceDocument: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
targetParentDocument: {
|
||||||
|
id: "child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "doc_1",
|
||||||
|
},
|
||||||
|
targetAncestorIds: ["doc_1"],
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "child_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
movePreflight: {
|
||||||
|
sourceDocument: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
targetParentDocument: {
|
||||||
|
id: "child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
parentId: "doc_1",
|
||||||
|
},
|
||||||
|
targetAncestorIds: ["doc_1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ type MovePayload = {
|
|||||||
position?: number | null;
|
position?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MovePreflightDocument = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MovePreflightPayload = {
|
||||||
|
sourceDocument: MovePreflightDocument;
|
||||||
|
targetParentDocument: MovePreflightDocument | null;
|
||||||
|
targetAncestorIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type DeletePayload = {
|
type DeletePayload = {
|
||||||
documentId?: string | null;
|
documentId?: string | null;
|
||||||
};
|
};
|
||||||
@@ -136,6 +148,41 @@ export function resolveSubtreeMoveLegality(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildMovePreflight(args: {
|
||||||
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||||
|
sourceDocument: MovePreflightDocument;
|
||||||
|
targetParentId: string | null;
|
||||||
|
}) : Promise<MovePreflightPayload> {
|
||||||
|
let targetParentDocument: MovePreflightDocument | null = null;
|
||||||
|
const targetAncestorIds: string[] = [];
|
||||||
|
if (args.targetParentId) {
|
||||||
|
const targetParentDoc = await args.client.query(api.documents.getMeta, { id: args.targetParentId });
|
||||||
|
if (!targetParentDoc) {
|
||||||
|
throw new Error("目标父页面不存在或无权限");
|
||||||
|
}
|
||||||
|
targetParentDocument = {
|
||||||
|
id: targetParentDoc.id,
|
||||||
|
workspaceId: trimOrNull(targetParentDoc.workspace_id),
|
||||||
|
parentId: trimOrNull(targetParentDoc.parent_id),
|
||||||
|
};
|
||||||
|
let cursor = trimOrNull(targetParentDoc.parent_id);
|
||||||
|
let depth = 0;
|
||||||
|
while (cursor && depth < 256) {
|
||||||
|
targetAncestorIds.push(cursor);
|
||||||
|
const parentDoc = await args.client.query(api.documents.getMeta, { id: cursor });
|
||||||
|
if (!parentDoc) break;
|
||||||
|
cursor = trimOrNull(parentDoc.parent_id);
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceDocument: args.sourceDocument,
|
||||||
|
targetParentDocument,
|
||||||
|
targetAncestorIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function safeRandomId() {
|
function safeRandomId() {
|
||||||
return typeof crypto.randomUUID === "function"
|
return typeof crypto.randomUUID === "function"
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
@@ -328,28 +375,47 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
|||||||
|
|
||||||
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||||
assertServerEnvironment();
|
assertServerEnvironment();
|
||||||
|
const requestClone = request.clone();
|
||||||
|
let normalizedMove: NormalizedDocumentMovePayload | null = null;
|
||||||
|
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
|
||||||
|
let failureAuthUserId: string | null = null;
|
||||||
|
let failureSourceDocument: MovePreflightDocument | null = null;
|
||||||
try {
|
try {
|
||||||
const payload = (await request.json()) as MovePayload;
|
const payload = (await request.json()) as MovePayload;
|
||||||
const normalizedMove = normalizeDocumentMovePayload(payload);
|
normalizedMove = normalizeDocumentMovePayload(payload);
|
||||||
const documentId = normalizedMove.documentId;
|
const documentId = normalizedMove.documentId;
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
failureClient = client;
|
||||||
|
failureAuthUserId = auth.userId;
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
if (!sourceDoc) {
|
if (!sourceDoc) {
|
||||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
failureSourceDocument = {
|
||||||
|
id: sourceDoc.id,
|
||||||
|
workspaceId: trimOrNull(sourceDoc.workspace_id),
|
||||||
|
parentId: trimOrNull(sourceDoc.parent_id),
|
||||||
|
};
|
||||||
|
|
||||||
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||||
|
const movePreflight = await buildMovePreflight({
|
||||||
|
client,
|
||||||
|
sourceDocument: failureSourceDocument,
|
||||||
|
targetParentId: normalizedMove.parentId,
|
||||||
|
});
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.move",
|
name: "documents.move",
|
||||||
payload: {
|
payload: {
|
||||||
documentId,
|
documentId,
|
||||||
parentId: normalizedMove.parentId,
|
parentId: normalizedMove.parentId,
|
||||||
sortOrder: normalizedMove.sortOrder,
|
sortOrder: normalizedMove.sortOrder,
|
||||||
|
movePreflight,
|
||||||
},
|
},
|
||||||
|
preflightData: movePreflight,
|
||||||
context,
|
context,
|
||||||
target: {
|
target: {
|
||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
@@ -373,18 +439,49 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
|
const fallbackMove = normalizedMove
|
||||||
const documentId = trimOrNull(payload.documentId);
|
?? normalizeDocumentMovePayload(
|
||||||
|
(await requestClone.json().catch(() => ({}))) as MovePayload,
|
||||||
|
);
|
||||||
|
const documentId = fallbackMove.documentId;
|
||||||
if (documentId) {
|
if (documentId) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
let client = failureClient;
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
let authUserId = failureAuthUserId;
|
||||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
if (!client || !authUserId) {
|
||||||
|
const authedClient = await getAuthedConvexClient();
|
||||||
|
client = authedClient.client;
|
||||||
|
authUserId = authedClient.auth.userId;
|
||||||
|
}
|
||||||
|
let sourceDocument = failureSourceDocument;
|
||||||
|
if (!sourceDocument) {
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
sourceDocument = {
|
||||||
|
id: sourceDoc.id,
|
||||||
|
workspaceId: trimOrNull(sourceDoc.workspace_id),
|
||||||
|
parentId: trimOrNull(sourceDoc.parent_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const context = await buildBridgeContext(request, sourceDocument.workspaceId ?? null, authUserId);
|
||||||
|
const movePreflight = await buildMovePreflight({
|
||||||
|
client,
|
||||||
|
sourceDocument,
|
||||||
|
targetParentId: fallbackMove.parentId,
|
||||||
|
});
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.delete",
|
name: "documents.move",
|
||||||
payload: { documentId },
|
payload: {
|
||||||
|
documentId,
|
||||||
|
parentId: fallbackMove.parentId,
|
||||||
|
sortOrder: fallbackMove.sortOrder,
|
||||||
|
movePreflight,
|
||||||
|
},
|
||||||
|
preflightData: movePreflight,
|
||||||
context,
|
context,
|
||||||
target: {
|
target: {
|
||||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
workspaceId: sourceDocument.workspaceId ?? null,
|
||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const mockContext: BridgeContext = {
|
|||||||
describe("page-write-command-adapter", () => {
|
describe("page-write-command-adapter", () => {
|
||||||
it("标题命令应走 rust bridge transport", async () => {
|
it("标题命令应走 rust bridge transport", async () => {
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
const {
|
const {
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
@@ -94,7 +95,10 @@ describe("page-write-command-adapter", () => {
|
|||||||
title: "新标题",
|
title: "新标题",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-24T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
const result = await executePageWriteBridgeCommand({
|
const result = await executePageWriteBridgeCommand({
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
@@ -116,6 +120,25 @@ describe("page-write-command-adapter", () => {
|
|||||||
name: "page.head.updateTitle",
|
name: "page.head.updateTitle",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "page.head.updateTitle",
|
||||||
|
}),
|
||||||
|
commandPayload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
title: "新标题",
|
||||||
|
streamDelta: {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: "doc_1",
|
||||||
|
title: "新标题",
|
||||||
|
updated_at: "2026-04-24T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(result.commandName).toBe("page.head.updateTitle");
|
expect(result.commandName).toBe("page.head.updateTitle");
|
||||||
expect(result.revision).toBeNull();
|
expect(result.revision).toBeNull();
|
||||||
expect(result.conflictDetectionKey).toBeNull();
|
expect(result.conflictDetectionKey).toBeNull();
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ export type PageWriteCommandExecutionResult = {
|
|||||||
conflictDetectionKey: string | null;
|
conflictDetectionKey: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
||||||
|
if (!streamDelta) {
|
||||||
|
return commandPayload;
|
||||||
|
}
|
||||||
|
if (isRecord(commandPayload)) {
|
||||||
|
return {
|
||||||
|
...commandPayload,
|
||||||
|
streamDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
payload: commandPayload,
|
||||||
|
streamDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||||
return {
|
return {
|
||||||
id: payload.documentId,
|
id: payload.documentId,
|
||||||
@@ -98,6 +118,33 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUpdatedAt(result: unknown): string | null {
|
||||||
|
const record = isRecord(result) ? result : null;
|
||||||
|
const updatedAt = record?.updated_at;
|
||||||
|
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
|
||||||
|
envelope: CommandEnvelope<TPayload>;
|
||||||
|
transportResult?: unknown;
|
||||||
|
}) {
|
||||||
|
if (input.envelope.name !== "page.head.updateTitle") {
|
||||||
|
return input.envelope.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
|
||||||
|
const updatedAt = normalizeUpdatedAt(input.transportResult);
|
||||||
|
|
||||||
|
return attachStreamDelta(payload, {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: payload.documentId,
|
||||||
|
title: payload.title,
|
||||||
|
...(updatedAt ? { updated_at: updatedAt } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<TPayload>;
|
envelope: CommandEnvelope<TPayload>;
|
||||||
@@ -120,6 +167,10 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
|
|||||||
await recordBridgeCommandArtifacts({
|
await recordBridgeCommandArtifacts({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
|
commandPayload: buildPageWriteCommandPayload({
|
||||||
|
envelope: input.envelope,
|
||||||
|
transportResult,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -479,7 +479,10 @@ export async function resolveRustBridgeCommandPlan<TPayload>(input: {
|
|||||||
const response = await runRustRuntime({
|
const response = await runRustRuntime({
|
||||||
kind: "command",
|
kind: "command",
|
||||||
context: input.context,
|
context: input.context,
|
||||||
command: input.envelope,
|
command: {
|
||||||
|
...input.envelope,
|
||||||
|
preflightData: input.envelope.preflightData ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!("plan" in response) || response.plan.kind !== "command") {
|
if (!("plan" in response) || response.plan.kind !== "command") {
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ describe("tree-command-client", () => {
|
|||||||
"/api/tree/commands",
|
"/api/tree/commands",
|
||||||
"/api/tree/commands",
|
"/api/tree/commands",
|
||||||
"/api/tree/commands",
|
"/api/tree/commands",
|
||||||
"/api/documents/delete",
|
"/api/tree/commands",
|
||||||
"/api/documents/restore",
|
"/api/tree/commands",
|
||||||
"/api/documents/purge",
|
"/api/tree/commands",
|
||||||
"/api/documents/embed",
|
"/api/tree/commands",
|
||||||
"/api/documents/copy-tree",
|
"/api/tree/commands",
|
||||||
"/api/documents/title",
|
"/api/documents/title",
|
||||||
"/api/documents/options",
|
"/api/documents/options",
|
||||||
]);
|
]);
|
||||||
@@ -70,6 +70,30 @@ describe("tree-command-client", () => {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
workspaceId: null,
|
workspaceId: null,
|
||||||
});
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({
|
||||||
|
action: "archive",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: null,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
|
||||||
|
action: "restore",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: null,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[5]?.[1]?.body))).toEqual({
|
||||||
|
action: "purge",
|
||||||
|
documentId: "doc_1",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[6]?.[1]?.body))).toEqual({
|
||||||
|
action: "embed",
|
||||||
|
sourceId: "doc_1",
|
||||||
|
targetId: "doc_2",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[7]?.[1]?.body))).toEqual({
|
||||||
|
action: "copy",
|
||||||
|
targetParentId: null,
|
||||||
|
items: [{ documentId: "doc_1", recursive: true }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("在后端返回错误时抛出统一异常", async () => {
|
it("在后端返回错误时抛出统一异常", async () => {
|
||||||
|
|||||||
@@ -96,7 +96,15 @@ type MoveDocumentInput = {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TreeCommandAction = "create" | "rename" | "move";
|
type TreeCommandAction =
|
||||||
|
| "create"
|
||||||
|
| "rename"
|
||||||
|
| "move"
|
||||||
|
| "archive"
|
||||||
|
| "restore"
|
||||||
|
| "purge"
|
||||||
|
| "embed"
|
||||||
|
| "copy";
|
||||||
|
|
||||||
type DeleteDocumentInput = {
|
type DeleteDocumentInput = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -161,12 +169,16 @@ type TreeCommandResponse = {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
sortOrder?: number | null;
|
sortOrder?: number | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
execution?: {
|
items?: Array<{ oldId: string; newId: string }>;
|
||||||
access_scope?: "private" | "shared" | "public";
|
execution?:
|
||||||
is_template?: boolean;
|
| ({
|
||||||
created_at?: string | null;
|
access_scope?: "private" | "shared" | "public";
|
||||||
updated_at?: string | null;
|
is_template?: boolean;
|
||||||
} | null;
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
purged?: boolean;
|
||||||
|
} & Record<string, unknown>)
|
||||||
|
| null;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,52 +287,92 @@ export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ o
|
|||||||
export async function deleteDocumentCommand(
|
export async function deleteDocumentCommand(
|
||||||
input: DeleteDocumentInput,
|
input: DeleteDocumentInput,
|
||||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
"/api/documents/delete",
|
|
||||||
{
|
{
|
||||||
|
action: "archive",
|
||||||
documentId: input.documentId,
|
documentId: input.documentId,
|
||||||
workspaceId: input.workspaceId ?? null,
|
workspaceId: input.workspaceId ?? null,
|
||||||
},
|
},
|
||||||
"删除失败,请稍后再试",
|
"删除失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.archive.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreDocumentCommand(
|
export async function restoreDocumentCommand(
|
||||||
input: RestoreDocumentInput,
|
input: RestoreDocumentInput,
|
||||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
"/api/documents/restore",
|
|
||||||
{
|
{
|
||||||
|
action: "restore",
|
||||||
documentId: input.documentId,
|
documentId: input.documentId,
|
||||||
workspaceId: input.workspaceId ?? null,
|
workspaceId: input.workspaceId ?? null,
|
||||||
},
|
},
|
||||||
"恢复失败,请稍后再试",
|
"恢复失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.restore.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function purgeDocumentCommand(
|
export async function purgeDocumentCommand(
|
||||||
input: PurgeDocumentInput,
|
input: PurgeDocumentInput,
|
||||||
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
|
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
|
||||||
return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>(
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
"/api/documents/purge",
|
|
||||||
{
|
{
|
||||||
|
action: "purge",
|
||||||
documentId: input.documentId,
|
documentId: input.documentId,
|
||||||
},
|
},
|
||||||
"彻底删除失败,请稍后再试",
|
"彻底删除失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
purged:
|
||||||
|
typeof response.result?.execution?.purged === "boolean"
|
||||||
|
? response.result.execution.purged
|
||||||
|
: undefined,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.purge.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function embedDocumentCommand(
|
export async function embedDocumentCommand(
|
||||||
input: EmbedDocumentInput,
|
input: EmbedDocumentInput,
|
||||||
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
"/api/documents/embed",
|
|
||||||
{
|
{
|
||||||
|
action: "embed",
|
||||||
sourceId: input.sourceId,
|
sourceId: input.sourceId,
|
||||||
targetId: input.targetId,
|
targetId: input.targetId,
|
||||||
},
|
},
|
||||||
"嵌入失败,请稍后再试",
|
"嵌入失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.embed.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyTreeCommand(
|
export async function copyTreeCommand(
|
||||||
@@ -329,15 +381,21 @@ export async function copyTreeCommand(
|
|||||||
items: Array<{ oldId: string; newId: string }>;
|
items: Array<{ oldId: string; newId: string }>;
|
||||||
meta?: DocumentCommandMeta;
|
meta?: DocumentCommandMeta;
|
||||||
}> {
|
}> {
|
||||||
return postDocumentCommand<{
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
items: Array<{ oldId: string; newId: string }>;
|
|
||||||
meta?: DocumentCommandMeta;
|
|
||||||
}>(
|
|
||||||
"/api/documents/copy-tree",
|
|
||||||
{
|
{
|
||||||
|
action: "copy",
|
||||||
targetParentId: input.targetParentId,
|
targetParentId: input.targetParentId,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
},
|
},
|
||||||
"复制页面失败,请稍后再试",
|
"复制页面失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: response.result?.items ?? [],
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.copy.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||||
import { buildVisibleRows } from "./rows";
|
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
|
||||||
import { parseFileTreeRowId } from "./types";
|
import { parseFileTreeRowId } from "./types";
|
||||||
|
|
||||||
describe("buildVisibleRows", () => {
|
describe("buildVisibleRows", () => {
|
||||||
@@ -283,6 +283,176 @@ describe("buildVisibleRows", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
|
||||||
|
const fileTreeItems = [
|
||||||
|
{
|
||||||
|
rowId: "doc:page_root",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "page_root",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "根页面",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: 3,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: ["expand", "open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "page_root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:page_root",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:page_root",
|
||||||
|
parentNodeId: "page_root",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "page_root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset_folder",
|
||||||
|
nodeId: "asset-folder:mind_1",
|
||||||
|
parentNodeId: "page_root",
|
||||||
|
nodeType: "mindmap",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "头脑风暴",
|
||||||
|
depth: 1,
|
||||||
|
position: 1,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["expand", "open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "mindmap",
|
||||||
|
documentId: "page_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "mindmap",
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:asset_child_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: "asset:asset_child_1",
|
||||||
|
parentNodeId: "asset-folder:mind_1",
|
||||||
|
nodeType: "asset",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "节点图片.png",
|
||||||
|
depth: 2,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "asset",
|
||||||
|
documentId: "page_root",
|
||||||
|
assetId: "asset_child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "image",
|
||||||
|
iconHint: "image",
|
||||||
|
},
|
||||||
|
iconHint: "image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "doc:page_child",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "page_child",
|
||||||
|
parentNodeId: "page_root",
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "子页面",
|
||||||
|
depth: 1,
|
||||||
|
position: 2,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["expand", "open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "page_child",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:page_child",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:page_child",
|
||||||
|
parentNodeId: "page_child",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 2,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "page_child",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const filteredItems = filterKernelFileTreeProjectionItems({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
visibleDocumentIds: new Set(["page_root", "page_child"]),
|
||||||
|
expandedDocumentIds: new Set(["page_root"]),
|
||||||
|
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filteredItems.map((item) => item.rowId)).toEqual([
|
||||||
|
"doc:page_root",
|
||||||
|
"index:page_root",
|
||||||
|
"asset-folder:mind_1",
|
||||||
|
"asset:asset_child_1",
|
||||||
|
"doc:page_child",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = buildVisibleRows({
|
||||||
|
fileTreeItems: filteredItems,
|
||||||
|
expanded: new Set(["page_root"]),
|
||||||
|
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
|
||||||
|
"doc:doc:page_root",
|
||||||
|
"index:index:page_root",
|
||||||
|
"asset-folder:asset-folder:mind_1",
|
||||||
|
"asset:asset:asset_child_1",
|
||||||
|
"doc:doc:page_child",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseFileTreeRowId", () => {
|
describe("parseFileTreeRowId", () => {
|
||||||
|
|||||||
@@ -12,6 +12,40 @@ import type { MediaAsset } from "@/types/media";
|
|||||||
import type { FileTreeRow } from "./types";
|
import type { FileTreeRow } from "./types";
|
||||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||||
|
|
||||||
|
export function filterKernelFileTreeProjectionItems(input: {
|
||||||
|
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||||
|
visibleDocumentIds: ReadonlySet<string>;
|
||||||
|
expandedDocumentIds: ReadonlySet<string>;
|
||||||
|
expandedAssetFolderIds?: ReadonlySet<string>;
|
||||||
|
}): KernelFileTreeProjectionItem[] {
|
||||||
|
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
|
||||||
|
|
||||||
|
return input.fileTreeItems.filter((item) => {
|
||||||
|
const docId = getDocIdFromFileTreeItem(item);
|
||||||
|
if (!input.visibleDocumentIds.has(docId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (item.rowKind) {
|
||||||
|
case "document":
|
||||||
|
return true;
|
||||||
|
case "index":
|
||||||
|
case "asset_folder":
|
||||||
|
return input.expandedDocumentIds.has(docId);
|
||||||
|
case "asset": {
|
||||||
|
if (!input.expandedDocumentIds.has(docId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parentNodeId = String(item.parentNodeId ?? "").trim();
|
||||||
|
if (parentNodeId.startsWith("asset-folder:")) {
|
||||||
|
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildRowsFromKernelFileTreeProjection(input: {
|
function buildRowsFromKernelFileTreeProjection(input: {
|
||||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
import {
|
||||||
|
buildFileTreeShellRowById,
|
||||||
|
buildFileTreeShellVisibleRowIds,
|
||||||
|
computeFileTreeShellDeleteTargets,
|
||||||
|
getOrderedFileTreeShellRows,
|
||||||
|
inferFileTreeShellTargetDocumentId,
|
||||||
|
resolveFileTreeShellMindmapTargetId,
|
||||||
|
} from "./shell";
|
||||||
|
|
||||||
|
describe("file-tree shell helpers", () => {
|
||||||
|
const nodeById = new Map<string, SidebarTreeNode>([
|
||||||
|
[
|
||||||
|
"doc_root",
|
||||||
|
{
|
||||||
|
id: "doc_root",
|
||||||
|
title: "根页面",
|
||||||
|
} as SidebarTreeNode,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assetById = new Map<string, MediaAsset>([
|
||||||
|
[
|
||||||
|
"mind_1",
|
||||||
|
{
|
||||||
|
id: "mind_1",
|
||||||
|
document_id: "doc_root",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_name: "mindmap.json",
|
||||||
|
storage_path: "mindmaps/mind_1/mindmap.json",
|
||||||
|
} as MediaAsset,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"asset_child_1",
|
||||||
|
{
|
||||||
|
id: "asset_child_1",
|
||||||
|
document_id: "doc_root",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
asset_type: "file",
|
||||||
|
file_name: "node.png",
|
||||||
|
storage_path: "mindmaps/mind_1/assets/node.png",
|
||||||
|
} as MediaAsset,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"pdf_1",
|
||||||
|
{
|
||||||
|
id: "pdf_1",
|
||||||
|
document_id: "doc_root",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
asset_type: "file",
|
||||||
|
file_name: "guide.pdf",
|
||||||
|
storage_path: "uploads/guide.pdf",
|
||||||
|
} as MediaAsset,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fileTreeItems: KernelFileTreeProjectionItem[] = [
|
||||||
|
{
|
||||||
|
rowId: "doc:doc_root",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "doc_root",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "根页面",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: 3,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: ["expand", "open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "doc_root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:doc_root",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:doc_root",
|
||||||
|
parentNodeId: "doc_root",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "doc_root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset_folder",
|
||||||
|
nodeId: "asset-folder:mind_1",
|
||||||
|
parentNodeId: "doc_root",
|
||||||
|
nodeType: "mindmap",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "mindmap",
|
||||||
|
depth: 1,
|
||||||
|
position: 1,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["expand", "open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "mindmap",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "mindmap",
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:asset_child_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: "asset:asset_child_1",
|
||||||
|
parentNodeId: "asset-folder:mind_1",
|
||||||
|
nodeType: "asset",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "node.png",
|
||||||
|
depth: 2,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "asset",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "asset_child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "image",
|
||||||
|
iconHint: "image",
|
||||||
|
},
|
||||||
|
iconHint: "image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:pdf_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: "asset:pdf_1",
|
||||||
|
parentNodeId: "doc_root",
|
||||||
|
nodeType: "pdf",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "guide.pdf",
|
||||||
|
depth: 1,
|
||||||
|
position: 2,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "pdf",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "pdf_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "pdf",
|
||||||
|
iconHint: "pdf",
|
||||||
|
},
|
||||||
|
iconHint: "pdf",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("应直接从 kernel file_tree items 构造宿主 row map", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildFileTreeShellVisibleRowIds([...fileTreeItems])).toEqual([
|
||||||
|
"doc:doc_root",
|
||||||
|
"index:doc_root",
|
||||||
|
"asset-folder:mind_1",
|
||||||
|
"asset:asset_child_1",
|
||||||
|
"asset:pdf_1",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(rowById.get("doc:doc_root")).toMatchObject({
|
||||||
|
rowId: "doc:doc_root",
|
||||||
|
rowKind: "doc",
|
||||||
|
documentId: "doc_root",
|
||||||
|
node: expect.objectContaining({
|
||||||
|
id: "doc_root",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(rowById.get("asset-folder:mind_1")).toMatchObject({
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset-folder",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
asset: expect.objectContaining({
|
||||||
|
id: "mind_1",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应正确解析 file tree shell 的导图投放目标", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset-folder:mind_1") ?? null)).toBe(
|
||||||
|
"mind_1",
|
||||||
|
);
|
||||||
|
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:asset_child_1") ?? null)).toBe(
|
||||||
|
"mind_1",
|
||||||
|
);
|
||||||
|
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:pdf_1") ?? null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应能仅凭 focusedRowId 从 shell row map 推回目标页面", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
inferFileTreeShellTargetDocumentId({
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
rowById,
|
||||||
|
activeDocId: null,
|
||||||
|
}),
|
||||||
|
).toBe("doc_root");
|
||||||
|
expect(
|
||||||
|
inferFileTreeShellTargetDocumentId({
|
||||||
|
focusedRowId: "asset:pdf_1",
|
||||||
|
rowById,
|
||||||
|
activeDocId: null,
|
||||||
|
}),
|
||||||
|
).toBe("doc_root");
|
||||||
|
expect(
|
||||||
|
inferFileTreeShellTargetDocumentId({
|
||||||
|
focusedRowId: null,
|
||||||
|
rowById,
|
||||||
|
activeDocId: "doc_root",
|
||||||
|
}),
|
||||||
|
).toBe("doc_root");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应按当前可见顺序返回选中的 shell rows", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getOrderedFileTreeShellRows({
|
||||||
|
rowIds: ["asset:pdf_1", "doc:doc_root", "missing"],
|
||||||
|
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
|
||||||
|
rowById,
|
||||||
|
}).map((row) => row.rowId),
|
||||||
|
).toEqual(["doc:doc_root", "asset:pdf_1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("删除目标计算应跳过被父页面覆盖的附件", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeFileTreeShellDeleteTargets({
|
||||||
|
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
|
||||||
|
rowById,
|
||||||
|
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
|
||||||
|
parentById: new Map([["doc_root", null]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.docIds).toEqual(["doc_root"]);
|
||||||
|
expect(result.assetIds).toEqual([]);
|
||||||
|
expect(result.assetHints).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
|
import {
|
||||||
|
getDocIdFromFileTreeItem,
|
||||||
|
resolveFileTreeRowAsset,
|
||||||
|
resolveFileTreeRowNode,
|
||||||
|
} from "@/lib/kernel-file-tree";
|
||||||
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
import { filterTopLevelDocIds } from "./dnd";
|
||||||
|
|
||||||
|
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
|
||||||
|
|
||||||
|
export type FileTreeShellRow = {
|
||||||
|
rowId: string;
|
||||||
|
rowKind: FileTreeShellRowKind;
|
||||||
|
documentId: string;
|
||||||
|
assetId: string | null;
|
||||||
|
node: SidebarTreeNode | null;
|
||||||
|
asset: MediaAsset | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellDeleteTargets = {
|
||||||
|
docIds: string[];
|
||||||
|
assetIds: string[];
|
||||||
|
assetHints: MediaAsset[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
|
||||||
|
switch (item.rowKind) {
|
||||||
|
case "document":
|
||||||
|
return "doc";
|
||||||
|
case "asset_folder":
|
||||||
|
return "asset-folder";
|
||||||
|
default:
|
||||||
|
return item.rowKind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeShellVisibleRowIds(
|
||||||
|
fileTreeItems: readonly KernelFileTreeProjectionItem[],
|
||||||
|
): string[] {
|
||||||
|
return fileTreeItems
|
||||||
|
.map((item) => item.rowId)
|
||||||
|
.filter((rowId): rowId is string => typeof rowId === "string" && rowId.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeShellRowById(input: {
|
||||||
|
fileTreeItems: readonly KernelFileTreeProjectionItem[];
|
||||||
|
nodeById?: Map<string, SidebarTreeNode>;
|
||||||
|
assetById?: Map<string, MediaAsset>;
|
||||||
|
}): Map<string, FileTreeShellRow> {
|
||||||
|
const rowById = new Map<string, FileTreeShellRow>();
|
||||||
|
|
||||||
|
input.fileTreeItems.forEach((item) => {
|
||||||
|
const rowId = typeof item.rowId === "string" ? item.rowId.trim() : "";
|
||||||
|
if (!rowId || rowById.has(rowId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowKind = toShellRowKind(item);
|
||||||
|
const documentId = getDocIdFromFileTreeItem(item);
|
||||||
|
const isDocumentRow = rowKind === "doc" || rowKind === "index";
|
||||||
|
|
||||||
|
rowById.set(rowId, {
|
||||||
|
rowId,
|
||||||
|
rowKind,
|
||||||
|
documentId,
|
||||||
|
assetId: isDocumentRow ? null : item.resourceMeta.assetId ?? null,
|
||||||
|
node: isDocumentRow ? resolveFileTreeRowNode(item, input.nodeById) : null,
|
||||||
|
asset: isDocumentRow ? null : resolveFileTreeRowAsset(item, input.assetById),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rowById;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderedFileTreeShellRows(input: {
|
||||||
|
rowIds: Iterable<string>;
|
||||||
|
visibleRowIds: readonly string[];
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
}): FileTreeShellRow[] {
|
||||||
|
const selectedRowIds = new Set<string>();
|
||||||
|
for (const rowId of input.rowIds) {
|
||||||
|
if (typeof rowId !== "string" || rowId.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!input.rowById.has(rowId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selectedRowIds.add(rowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.visibleRowIds
|
||||||
|
.map((rowId) => (selectedRowIds.has(rowId) ? input.rowById.get(rowId) ?? null : null))
|
||||||
|
.filter((row): row is FileTreeShellRow => Boolean(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMindmapAssetIdFromStoragePath(
|
||||||
|
storagePath: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!storagePath) return null;
|
||||||
|
const normalized = storagePath.replaceAll("\\", "/");
|
||||||
|
|
||||||
|
const prefix = "mindmaps/";
|
||||||
|
if (normalized.startsWith(prefix)) {
|
||||||
|
const rest = normalized.slice(prefix.length);
|
||||||
|
const id = rest.split("/")[0];
|
||||||
|
return id ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = "/mindmaps/";
|
||||||
|
const idx = normalized.indexOf(marker);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const rest = normalized.slice(idx + marker.length);
|
||||||
|
const id = rest.split("/")[0];
|
||||||
|
return id ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFileTreeShellMindmapTargetId(
|
||||||
|
row: FileTreeShellRow | null,
|
||||||
|
): string | null {
|
||||||
|
if (!row?.asset) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (row.rowKind === "asset-folder" && row.asset.asset_type === "mindmap") {
|
||||||
|
return row.asset.id;
|
||||||
|
}
|
||||||
|
if (row.rowKind === "asset") {
|
||||||
|
return extractMindmapAssetIdFromStoragePath(row.asset.storage_path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFileTreeShellDeleteTargets(input: {
|
||||||
|
visibleRowIds: readonly string[];
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
selectedRowIds: ReadonlySet<string>;
|
||||||
|
parentById: Map<string, string | null>;
|
||||||
|
}): FileTreeShellDeleteTargets {
|
||||||
|
const rows = getOrderedFileTreeShellRows({
|
||||||
|
rowIds: input.selectedRowIds,
|
||||||
|
visibleRowIds: input.visibleRowIds,
|
||||||
|
rowById: input.rowById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docCandidates: string[] = [];
|
||||||
|
const assetCandidates: string[] = [];
|
||||||
|
const assetDocIdByAssetId = new Map<string, string>();
|
||||||
|
const assetHintById = new Map<string, MediaAsset>();
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.rowKind === "doc" || row.rowKind === "index") {
|
||||||
|
docCandidates.push(row.documentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
|
||||||
|
assetCandidates.push(row.assetId);
|
||||||
|
assetDocIdByAssetId.set(row.assetId, row.documentId);
|
||||||
|
if (row.asset) {
|
||||||
|
assetHintById.set(row.assetId, row.asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
|
||||||
|
const docIdSet = new Set(docIds);
|
||||||
|
const seenAssets = new Set<string>();
|
||||||
|
const assetIds: string[] = [];
|
||||||
|
const assetHints: MediaAsset[] = [];
|
||||||
|
|
||||||
|
assetCandidates.forEach((assetId) => {
|
||||||
|
if (!assetId || seenAssets.has(assetId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenAssets.add(assetId);
|
||||||
|
|
||||||
|
const ownerDocId = assetDocIdByAssetId.get(assetId);
|
||||||
|
if (ownerDocId && docIdSet.has(ownerDocId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assetIds.push(assetId);
|
||||||
|
const assetHint = assetHintById.get(assetId);
|
||||||
|
if (assetHint) {
|
||||||
|
assetHints.push(assetHint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { docIds, assetIds, assetHints };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferFileTreeShellTargetDocumentId(input: {
|
||||||
|
focusedRowId: string | null;
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
activeDocId: string | null;
|
||||||
|
}): string | null {
|
||||||
|
if (input.focusedRowId) {
|
||||||
|
const row = input.rowById.get(input.focusedRowId) ?? null;
|
||||||
|
if (row?.documentId) {
|
||||||
|
return row.documentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input.activeDocId || null;
|
||||||
|
}
|
||||||
@@ -31,10 +31,10 @@ describe("runtime-config public projection", () => {
|
|||||||
expect(runtime.treeRendererFamily).toBe("rust_family");
|
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("树 renderer family 缺省时应回落到 react", () => {
|
it("树 renderer family 缺省时应回落到 rust_family", () => {
|
||||||
const runtime = getMnoteRuntimeConfig();
|
const runtime = getMnoteRuntimeConfig();
|
||||||
|
|
||||||
expect(runtime.treeRendererFamily).toBe("react");
|
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export type MnoteRuntimeConfig = {
|
|||||||
documentEditorBlocknoteKillSwitch?: boolean;
|
documentEditorBlocknoteKillSwitch?: boolean;
|
||||||
/**
|
/**
|
||||||
* 树域 renderer family 选择。
|
* 树域 renderer family 选择。
|
||||||
* 说明:默认仍为 react;`rust_family` 只作为渐进切流开关,不代表已完全切主路径。
|
* 说明:默认主路径已切到 rust_family;React fallback 仍作为过渡兜底保留。
|
||||||
*/
|
*/
|
||||||
treeRendererFamily?: "react" | "rust_family";
|
treeRendererFamily?: "react" | "rust_family";
|
||||||
/**
|
/**
|
||||||
@@ -292,7 +292,7 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
|||||||
const documentEditorBlocknoteKillSwitch =
|
const documentEditorBlocknoteKillSwitch =
|
||||||
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
|
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
|
||||||
const treeRendererFamily =
|
const treeRendererFamily =
|
||||||
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "react";
|
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { streamTreeFrames } from "./server";
|
||||||
|
import type { TreeStreamCommandLogCursorRow } from "./server";
|
||||||
|
|
||||||
|
function buildOverview(rows: TreeStreamCommandLogCursorRow[]) {
|
||||||
|
return {
|
||||||
|
command_logs: rows,
|
||||||
|
domain_events: [],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectFrames<T>(generator: AsyncGenerator<T>) {
|
||||||
|
const frames: T[] = [];
|
||||||
|
for await (const frame of generator) {
|
||||||
|
frames.push(frame);
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tree-stream/server", () => {
|
||||||
|
it("workspace scope 首帧应发 snapshot,并固定 sidebar_tree + cursor", async () => {
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 0,
|
||||||
|
loadOverview: vi.fn().mockResolvedValue(
|
||||||
|
buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
),
|
||||||
|
loadSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||||
|
}),
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0]).toMatchObject({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: {
|
||||||
|
kind: "snapshot",
|
||||||
|
stream: "workspace",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: null,
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:01Z",
|
||||||
|
id: "clog_2",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtree scope 首帧应切到 subtree + page_tree", async () => {
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 0,
|
||||||
|
loadOverview: vi.fn().mockResolvedValue(
|
||||||
|
buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
),
|
||||||
|
loadSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_subtree_1",
|
||||||
|
traceId: "trace_subtree_1",
|
||||||
|
data: { nodes: [] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { nodes: [] } },
|
||||||
|
}),
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0]).toMatchObject({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: {
|
||||||
|
kind: "snapshot",
|
||||||
|
stream: "subtree",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
projection: "page_tree",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("检测到 cursor 之后出现新命令时,应发 resync 而不是静默结束", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([
|
||||||
|
{ id: "clog_2", created_at: "2026-04-24T00:00:02Z" },
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const loadSnapshot = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_2",
|
||||||
|
traceId: "trace_2",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||||
|
snapshot: {
|
||||||
|
dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||||
|
tree: { items: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[0]).toMatchObject({ event: "snapshot" });
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "resync",
|
||||||
|
payload: {
|
||||||
|
kind: "resync",
|
||||||
|
stream: "workspace",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:02Z",
|
||||||
|
id: "clog_2",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("检测到带 streamDelta 的单条新命令时,应直接发 delta", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "tree.node.archive",
|
||||||
|
payload: {
|
||||||
|
documentId: "page_2",
|
||||||
|
streamDelta: {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId: "page_2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:02Z",
|
||||||
|
id: "clog_2",
|
||||||
|
}),
|
||||||
|
data: {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId: "page_2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只有 domain event 推进时,也应刷新 cursor 并触发 resync", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:02Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_2",
|
||||||
|
traceId: "trace_2",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "resync",
|
||||||
|
payload: {
|
||||||
|
kind: "resync",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:02Z",
|
||||||
|
id: "domain_event:evt_2",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "tree.subtree.move",
|
||||||
|
payload: {
|
||||||
|
documentId: "page_2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const loadSnapshot = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_2",
|
||||||
|
traceId: "trace_2",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||||
|
snapshot: {
|
||||||
|
dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||||
|
tree: { items: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "resync",
|
||||||
|
payload: {
|
||||||
|
kind: "resync",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("正文保存这类无树结构影响的命令应降级为 noop delta,而不是触发 resync", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "page.body.save",
|
||||||
|
payload: {
|
||||||
|
documentId: "page_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
data: {
|
||||||
|
op: "noop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("move 这类附带 replace_documents 的新命令应直接发 delta,而不是触发 resync", async () => {
|
||||||
|
const sidebarSnapshot = {
|
||||||
|
activeWorkspaceId: "ws_1",
|
||||||
|
workspaces: [],
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: "page_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "页面 1",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
access_scope: "private",
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-24T00:00:00Z",
|
||||||
|
updated_at: "2026-04-24T00:01:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kernelSidebarProjection: {
|
||||||
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
kernelSidebarTree: [],
|
||||||
|
trashedDocuments: [],
|
||||||
|
mediaAssets: [],
|
||||||
|
mindmapDocs: [],
|
||||||
|
mindmapAssets: [],
|
||||||
|
mindmapAssetChildren: {},
|
||||||
|
tableAssets: [],
|
||||||
|
};
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "tree.subtree.move",
|
||||||
|
payload: {
|
||||||
|
documentId: "page_1",
|
||||||
|
streamDelta: {
|
||||||
|
op: "replace_documents",
|
||||||
|
documents: sidebarSnapshot.documents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
data: {
|
||||||
|
op: "replace_documents",
|
||||||
|
documents: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "page_1",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("轮询期间没有新 cursor 时,不应额外发 resync", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]));
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||||
|
}),
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0]).toMatchObject({ event: "snapshot" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import type { TreeStreamDeltaEvent } from "./tree-delta";
|
||||||
|
|
||||||
|
export type TreeStreamScope = "workspace" | "subtree";
|
||||||
|
export type TreeStreamProjection = "sidebar_tree" | "page_tree";
|
||||||
|
export type TreeStreamEventName = "snapshot" | "delta" | "resync";
|
||||||
|
|
||||||
|
export interface TreeStreamCommandLogCursorRow {
|
||||||
|
id?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
command_name?: string | null;
|
||||||
|
commandName?: string | null;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeStreamOverview {
|
||||||
|
command_logs?: TreeStreamCommandLogCursorRow[] | null;
|
||||||
|
domain_events?: unknown[] | null;
|
||||||
|
next_cursor?: string | null;
|
||||||
|
has_more?: boolean | null;
|
||||||
|
generated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeStreamSnapshotPayload {
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
data: unknown;
|
||||||
|
snapshot: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeStreamEnvelope {
|
||||||
|
kind: TreeStreamEventName;
|
||||||
|
stream: TreeStreamScope;
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId: string | null;
|
||||||
|
cursor: string | null;
|
||||||
|
projection: TreeStreamProjection;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
data: unknown;
|
||||||
|
snapshot: unknown;
|
||||||
|
overview: TreeStreamOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeStreamFrame {
|
||||||
|
event: TreeStreamEventName;
|
||||||
|
payload: TreeStreamEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamTreeFramesInput {
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
initialCursor?: string | null;
|
||||||
|
pollMs?: number;
|
||||||
|
maxPolls?: number | null;
|
||||||
|
loadOverview: () => Promise<TreeStreamOverview>;
|
||||||
|
loadSnapshot: () => Promise<TreeStreamSnapshotPayload>;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecodedTreeStreamCursor = {
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeStreamDomainEventCursorRow = {
|
||||||
|
id?: string | null;
|
||||||
|
event_id?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TREE_STREAM_NOOP_COMMANDS = new Set([
|
||||||
|
"page.body.save",
|
||||||
|
"page.layout.updateOptions",
|
||||||
|
"documents.stats.update",
|
||||||
|
"blocks.patch",
|
||||||
|
"blocks.move",
|
||||||
|
"blocks.embed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeNodeId(value: string | null | undefined) {
|
||||||
|
const normalized = typeof value === "string" ? value.trim() : "";
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTreeStreamContract(input: {
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
}): {
|
||||||
|
stream: TreeStreamScope;
|
||||||
|
projection: TreeStreamProjection;
|
||||||
|
rootNodeId: string | null;
|
||||||
|
} {
|
||||||
|
const rootNodeId = normalizeNodeId(input.rootNodeId);
|
||||||
|
if (rootNodeId) {
|
||||||
|
return {
|
||||||
|
stream: "subtree",
|
||||||
|
projection: "page_tree",
|
||||||
|
rootNodeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stream: "workspace",
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTreeStreamCursor(row: TreeStreamCommandLogCursorRow | null | undefined) {
|
||||||
|
const id = typeof row?.id === "string" ? row.id.trim() : "";
|
||||||
|
const createdAt = typeof row?.created_at === "string" ? row.created_at.trim() : "";
|
||||||
|
if (!id || !createdAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeTreeStreamDomainEventCursor(
|
||||||
|
row: TreeStreamDomainEventCursorRow | null | undefined,
|
||||||
|
) {
|
||||||
|
const rawId =
|
||||||
|
typeof row?.event_id === "string"
|
||||||
|
? row.event_id.trim()
|
||||||
|
: typeof row?.id === "string"
|
||||||
|
? row.id.trim()
|
||||||
|
: "";
|
||||||
|
const createdAt =
|
||||||
|
typeof row?.created_at === "string"
|
||||||
|
? row.created_at.trim()
|
||||||
|
: typeof row?.createdAt === "string"
|
||||||
|
? row.createdAt.trim()
|
||||||
|
: "";
|
||||||
|
if (!rawId || !createdAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
createdAt,
|
||||||
|
id: `domain_event:${rawId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTreeStreamCursor(raw: string | null | undefined): DecodedTreeStreamCursor | null {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
createdAt?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
};
|
||||||
|
const createdAt = typeof parsed.createdAt === "string" ? parsed.createdAt.trim() : "";
|
||||||
|
const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
|
||||||
|
return createdAt && id ? { createdAt, id } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOverviewCursor(
|
||||||
|
overview: TreeStreamOverview,
|
||||||
|
fallback?: string | null,
|
||||||
|
) {
|
||||||
|
const commandRows = Array.isArray(overview.command_logs) ? overview.command_logs : [];
|
||||||
|
const eventRows = Array.isArray(overview.domain_events)
|
||||||
|
? (overview.domain_events as TreeStreamDomainEventCursorRow[])
|
||||||
|
: [];
|
||||||
|
const commandCursor = encodeTreeStreamCursor(commandRows[0] ?? null);
|
||||||
|
const domainEventCursor = encodeTreeStreamDomainEventCursor(eventRows[0] ?? null);
|
||||||
|
|
||||||
|
if (!commandCursor) {
|
||||||
|
return domainEventCursor ?? fallback ?? null;
|
||||||
|
}
|
||||||
|
if (!domainEventCursor) {
|
||||||
|
return commandCursor ?? fallback ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCommandCursor = decodeTreeStreamCursor(commandCursor);
|
||||||
|
const decodedDomainEventCursor = decodeTreeStreamCursor(domainEventCursor);
|
||||||
|
if (!decodedCommandCursor) {
|
||||||
|
return domainEventCursor;
|
||||||
|
}
|
||||||
|
if (!decodedDomainEventCursor) {
|
||||||
|
return commandCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedDomainEventCursor.createdAt > decodedCommandCursor.createdAt
|
||||||
|
? domainEventCursor
|
||||||
|
: commandCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCommandPayloadDelta(row: TreeStreamCommandLogCursorRow): TreeStreamDeltaEvent | null {
|
||||||
|
const commandName =
|
||||||
|
typeof row.command_name === "string"
|
||||||
|
? row.command_name.trim()
|
||||||
|
: typeof row.commandName === "string"
|
||||||
|
? row.commandName.trim()
|
||||||
|
: "";
|
||||||
|
if (commandName && TREE_STREAM_NOOP_COMMANDS.has(commandName)) {
|
||||||
|
return {
|
||||||
|
op: "noop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isRecord(row.payload) || !("streamDelta" in row.payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidate = row.payload.streamDelta;
|
||||||
|
if (!isRecord(candidate) || typeof candidate.op !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: candidate.op as TreeStreamDeltaEvent["op"],
|
||||||
|
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
|
||||||
|
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
|
||||||
|
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
|
||||||
|
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||||
|
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNewCommandLogs(input: {
|
||||||
|
rows: TreeStreamCommandLogCursorRow[];
|
||||||
|
previousCursor: string | null;
|
||||||
|
}) {
|
||||||
|
const previousCursor = decodeTreeStreamCursor(input.previousCursor);
|
||||||
|
if (!previousCursor) {
|
||||||
|
return {
|
||||||
|
rows: input.rows,
|
||||||
|
drifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIndex = input.rows.findIndex((row) => {
|
||||||
|
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||||
|
const createdAt = typeof row.created_at === "string" ? row.created_at.trim() : "";
|
||||||
|
return id === previousCursor.id && createdAt === previousCursor.createdAt;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousIndex >= 0) {
|
||||||
|
return {
|
||||||
|
rows: input.rows.slice(0, previousIndex),
|
||||||
|
drifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: input.rows,
|
||||||
|
drifted: input.rows.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTreeStreamEnvelope(input: {
|
||||||
|
kind: TreeStreamEventName;
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId: string | null;
|
||||||
|
projection: TreeStreamProjection;
|
||||||
|
cursor: string | null;
|
||||||
|
overview: TreeStreamOverview;
|
||||||
|
snapshot: TreeStreamSnapshotPayload;
|
||||||
|
}): TreeStreamEnvelope {
|
||||||
|
return {
|
||||||
|
kind: input.kind,
|
||||||
|
stream: input.rootNodeId ? "subtree" : "workspace",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: input.rootNodeId,
|
||||||
|
cursor: input.cursor,
|
||||||
|
projection: input.projection,
|
||||||
|
requestId: input.snapshot.requestId,
|
||||||
|
traceId: input.snapshot.traceId,
|
||||||
|
data: input.snapshot.data,
|
||||||
|
snapshot: input.snapshot.snapshot,
|
||||||
|
overview: input.overview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTreeStreamDeltaEnvelope(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId: string | null;
|
||||||
|
projection: TreeStreamProjection;
|
||||||
|
cursor: string | null;
|
||||||
|
overview: TreeStreamOverview;
|
||||||
|
snapshot: TreeStreamSnapshotPayload;
|
||||||
|
delta: TreeStreamDeltaEvent;
|
||||||
|
}): TreeStreamEnvelope {
|
||||||
|
return {
|
||||||
|
kind: "delta",
|
||||||
|
stream: input.rootNodeId ? "subtree" : "workspace",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: input.rootNodeId,
|
||||||
|
cursor: input.cursor,
|
||||||
|
projection: input.projection,
|
||||||
|
requestId: input.snapshot.requestId,
|
||||||
|
traceId: input.snapshot.traceId,
|
||||||
|
data: input.delta,
|
||||||
|
snapshot: null,
|
||||||
|
overview: input.overview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultSleep(ms: number) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamTreeFrames(
|
||||||
|
input: StreamTreeFramesInput,
|
||||||
|
): AsyncGenerator<TreeStreamFrame> {
|
||||||
|
const contract = resolveTreeStreamContract({
|
||||||
|
rootNodeId: input.rootNodeId,
|
||||||
|
});
|
||||||
|
const pollMs = Math.max(250, Math.floor(input.pollMs ?? 2000));
|
||||||
|
const maxPolls =
|
||||||
|
typeof input.maxPolls === "number" && Number.isFinite(input.maxPolls)
|
||||||
|
? Math.max(0, Math.floor(input.maxPolls))
|
||||||
|
: null;
|
||||||
|
const sleep = input.sleep ?? defaultSleep;
|
||||||
|
|
||||||
|
let snapshot = await input.loadSnapshot();
|
||||||
|
let overview = await input.loadOverview();
|
||||||
|
let cursor = resolveOverviewCursor(overview, input.initialCursor ?? null);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
event: "snapshot",
|
||||||
|
payload: buildTreeStreamEnvelope({
|
||||||
|
kind: "snapshot",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: contract.rootNodeId,
|
||||||
|
projection: contract.projection,
|
||||||
|
cursor,
|
||||||
|
overview,
|
||||||
|
snapshot,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let polls = 0;
|
||||||
|
while (maxPolls === null || polls < maxPolls) {
|
||||||
|
polls += 1;
|
||||||
|
await sleep(pollMs);
|
||||||
|
|
||||||
|
overview = await input.loadOverview();
|
||||||
|
const nextCursor = resolveOverviewCursor(overview, cursor);
|
||||||
|
if (nextCursor === cursor) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(overview.command_logs) ? overview.command_logs : [];
|
||||||
|
const newRows = collectNewCommandLogs({
|
||||||
|
rows,
|
||||||
|
previousCursor: cursor,
|
||||||
|
});
|
||||||
|
if (!newRows.drifted && newRows.rows.length === 1) {
|
||||||
|
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
|
||||||
|
if (delta) {
|
||||||
|
cursor = nextCursor;
|
||||||
|
yield {
|
||||||
|
event: "delta",
|
||||||
|
payload: buildTreeStreamDeltaEnvelope({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: contract.rootNodeId,
|
||||||
|
projection: contract.projection,
|
||||||
|
cursor,
|
||||||
|
overview,
|
||||||
|
snapshot,
|
||||||
|
delta,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = await input.loadSnapshot();
|
||||||
|
cursor = nextCursor;
|
||||||
|
|
||||||
|
yield {
|
||||||
|
event: "resync",
|
||||||
|
payload: buildTreeStreamEnvelope({
|
||||||
|
kind: "resync",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: contract.rootNodeId,
|
||||||
|
projection: contract.projection,
|
||||||
|
cursor,
|
||||||
|
overview,
|
||||||
|
snapshot,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import { applyTreeStreamDelta } from "./tree-delta";
|
import {
|
||||||
|
applyTreeStreamDelta,
|
||||||
|
applyTreeStreamDeltaToProjectionState,
|
||||||
|
} from "./tree-delta";
|
||||||
|
|
||||||
const baseSidebarData: SidebarInitialData = {
|
const baseSidebarData: SidebarInitialData = {
|
||||||
activeWorkspaceId: "ws_1",
|
activeWorkspaceId: "ws_1",
|
||||||
@@ -174,6 +177,119 @@ const baseSidebarData: SidebarInitialData = {
|
|||||||
mediaAssets: [],
|
mediaAssets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fileTreeProjectionBase: SidebarInitialData = {
|
||||||
|
...baseSidebarData,
|
||||||
|
mediaAssets: [
|
||||||
|
{
|
||||||
|
id: "asset_pdf",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "root",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/manual.pdf",
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: "documents/root/manual.pdf",
|
||||||
|
file_name: "manual.pdf",
|
||||||
|
file_size: 1024,
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: "2026-04-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asset_book",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "root",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/novel.epub",
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: "documents/root/novel.epub",
|
||||||
|
file_name: "novel.epub",
|
||||||
|
file_size: 2048,
|
||||||
|
mime_type: "application/epub+zip",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: "2026-04-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asset_mindmap_child",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "root",
|
||||||
|
asset_type: "image",
|
||||||
|
file_url: "/mindmap/concept.png",
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: "documents/root/mindmaps/asset_mindmap/concept.png",
|
||||||
|
file_name: "concept.png",
|
||||||
|
file_size: 512,
|
||||||
|
mime_type: "image/png",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: "2026-04-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tableAssets: [
|
||||||
|
{
|
||||||
|
id: "asset_table",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "root",
|
||||||
|
asset_type: "luckysheet",
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: "budget.luckysheet",
|
||||||
|
file_size: null,
|
||||||
|
mime_type: "application/json",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: "2026-04-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mindmapAssets: [
|
||||||
|
{
|
||||||
|
id: "asset_mindmap",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "root",
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: "mindmap.json",
|
||||||
|
file_size: null,
|
||||||
|
mime_type: "application/json",
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: "2026-04-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mindmapAssetChildren: {
|
||||||
|
asset_mindmap: ["asset_mindmap_child"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe("tree-stream/tree-delta", () => {
|
describe("tree-stream/tree-delta", () => {
|
||||||
it("支持 upsert_document 重建 sidebar projection", () => {
|
it("支持 upsert_document 重建 sidebar projection", () => {
|
||||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
@@ -210,6 +326,26 @@ describe("tree-stream/tree-delta", () => {
|
|||||||
expect(next.kernelSidebarProjection.items).toEqual([]);
|
expect(next.kernelSidebarProjection.items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("支持对已存在文档做局部 upsert patch,而不丢失原有排序字段", () => {
|
||||||
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: "child",
|
||||||
|
title: "Child Renamed",
|
||||||
|
updated_at: "2026-04-18T00:05:00Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.documents).toHaveLength(2);
|
||||||
|
expect(next.documents.find((item) => item.id === "child")).toMatchObject({
|
||||||
|
id: "child",
|
||||||
|
title: "Child Renamed",
|
||||||
|
parent_id: "root",
|
||||||
|
sort_order: 1,
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
||||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
op: "replace_sidebar",
|
op: "replace_sidebar",
|
||||||
@@ -247,4 +383,99 @@ describe("tree-stream/tree-delta", () => {
|
|||||||
expect(next.documents).toEqual([]);
|
expect(next.documents).toEqual([]);
|
||||||
expect(next.kernelSidebarProjection.items).toEqual([]);
|
expect(next.kernelSidebarProjection.items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("支持 noop delta 仅推进 cursor,不修改当前 sidebar snapshot", () => {
|
||||||
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
|
op: "noop",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toEqual(baseSidebarData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("为 page_tree 定义统一 delta 应用边界,并可稳定派生页面行", () => {
|
||||||
|
const next = applyTreeStreamDeltaToProjectionState({
|
||||||
|
projection: "page_tree",
|
||||||
|
base: baseSidebarData,
|
||||||
|
event: {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: "leaf",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "Leaf",
|
||||||
|
parent_id: "child",
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.projection).toBe("page_tree");
|
||||||
|
expect(next.documents.map((item) => item.id)).toEqual(["root", "child", "leaf"]);
|
||||||
|
expect(next.pageTreeItems.map((item) => item.nodeId)).toEqual(["root", "child", "leaf"]);
|
||||||
|
expect(next.pageTreeItems.find((item) => item.nodeId === "leaf")).toMatchObject({
|
||||||
|
parentNodeId: "child",
|
||||||
|
depth: 2,
|
||||||
|
title: "Leaf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("为 file_tree 定义统一 delta 应用边界,并保留 doc/index/asset-folder/asset 行语义", () => {
|
||||||
|
const next = applyTreeStreamDeltaToProjectionState({
|
||||||
|
projection: "file_tree",
|
||||||
|
base: fileTreeProjectionBase,
|
||||||
|
event: {
|
||||||
|
op: "replace_documents",
|
||||||
|
documents: [...fileTreeProjectionBase.documents],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.projection).toBe("file_tree");
|
||||||
|
expect(next.fileTreeItems.map((item) => item.rowKind)).toEqual(
|
||||||
|
expect.arrayContaining(["document", "index", "asset_folder", "asset"]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_mindmap"),
|
||||||
|
).toMatchObject({
|
||||||
|
rowKind: "asset_folder",
|
||||||
|
iconHint: "mindmap",
|
||||||
|
resourceMeta: expect.objectContaining({
|
||||||
|
resourceKind: "mindmap",
|
||||||
|
assetKind: "mindmap",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_pdf"),
|
||||||
|
).toMatchObject({
|
||||||
|
rowKind: "asset",
|
||||||
|
iconHint: "pdf",
|
||||||
|
resourceMeta: expect.objectContaining({
|
||||||
|
resourceKind: "pdf",
|
||||||
|
assetKind: "pdf",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_book"),
|
||||||
|
).toMatchObject({
|
||||||
|
rowKind: "asset",
|
||||||
|
iconHint: "book",
|
||||||
|
resourceMeta: expect.objectContaining({
|
||||||
|
resourceKind: "book",
|
||||||
|
assetKind: "book",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_table"),
|
||||||
|
).toMatchObject({
|
||||||
|
rowKind: "asset",
|
||||||
|
iconHint: "table",
|
||||||
|
resourceMeta: expect.objectContaining({
|
||||||
|
resourceKind: "table",
|
||||||
|
assetKind: "table",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
|
import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||||
import {
|
import {
|
||||||
buildSidebarDatasetListQueryResult,
|
buildSidebarDatasetListQueryResult,
|
||||||
mapSidebarDatasetListQueryResultToInitialData,
|
mapSidebarDatasetListQueryResultToInitialData,
|
||||||
} from "@/lib/sidebar-data";
|
} from "@/lib/sidebar-data";
|
||||||
import type { DocumentRecord } from "@/lib/documents";
|
import type { DocumentRecord } from "@/lib/documents";
|
||||||
|
import {
|
||||||
|
buildSidebarTreeFromKernelProjection,
|
||||||
|
type KernelSidebarProjectionItem,
|
||||||
|
} from "@/lib/kernel-sidebar";
|
||||||
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
|
import {
|
||||||
|
buildPageTreeProjectionItems,
|
||||||
|
type PageTreeProjectionItem,
|
||||||
|
} from "@/lib/tree-projection";
|
||||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
|
||||||
|
export type TreeStreamDocumentPatch =
|
||||||
|
Partial<DocumentRecord> & Pick<DocumentRecord, "id">;
|
||||||
|
|
||||||
export type TreeStreamDeltaOp =
|
export type TreeStreamDeltaOp =
|
||||||
|
| "noop"
|
||||||
| "upsert_document"
|
| "upsert_document"
|
||||||
| "remove_document"
|
| "remove_document"
|
||||||
| "replace_documents"
|
| "replace_documents"
|
||||||
@@ -16,13 +30,24 @@ export type TreeStreamDeltaOp =
|
|||||||
|
|
||||||
export type TreeStreamDeltaEvent = {
|
export type TreeStreamDeltaEvent = {
|
||||||
op: TreeStreamDeltaOp;
|
op: TreeStreamDeltaOp;
|
||||||
node?: DocumentRecord | null;
|
node?: TreeStreamDocumentPatch | null;
|
||||||
document?: DocumentRecord | null;
|
document?: TreeStreamDocumentPatch | null;
|
||||||
documentId?: string | null;
|
documentId?: string | null;
|
||||||
documents?: DocumentRecord[] | null;
|
documents?: DocumentRecord[] | null;
|
||||||
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TreeRendererProjection = "sidebar_tree" | "page_tree" | "file_tree";
|
||||||
|
|
||||||
|
export type TreeRendererDeltaState = {
|
||||||
|
projection: TreeRendererProjection;
|
||||||
|
sidebar: SidebarInitialData;
|
||||||
|
documents: DocumentRecord[];
|
||||||
|
sidebarItems: KernelSidebarProjectionItem[];
|
||||||
|
pageTreeItems: PageTreeProjectionItem[];
|
||||||
|
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||||
|
};
|
||||||
|
|
||||||
function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
|
function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
@@ -85,12 +110,37 @@ function buildSidebarFromDocuments(input: {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
(input.base.mediaAssets?.length ?? 0) > 0 ||
|
||||||
|
(input.base.mindmapAssets?.length ?? 0) > 0 ||
|
||||||
|
(input.base.tableAssets?.length ?? 0) > 0 ||
|
||||||
|
Object.keys(input.base.mindmapAssetChildren ?? {}).length > 0
|
||||||
|
) {
|
||||||
|
// 说明:stream delta 只替换 documents 时,仍要保留已有资源树语义;
|
||||||
|
// 否则 mindmap 子附件会在 resync 前退化成普通 asset。
|
||||||
|
const nextFileTreeProjection = buildKernelFileTreeProjection({
|
||||||
|
documents: input.documents,
|
||||||
|
mediaAssets: input.base.mediaAssets,
|
||||||
|
mindmapAssets: input.base.mindmapAssets,
|
||||||
|
tableAssets: input.base.tableAssets,
|
||||||
|
mindmapAssetChildren: input.base.mindmapAssetChildren,
|
||||||
|
});
|
||||||
|
queryResult.kernel_file_tree_projection = nextFileTreeProjection;
|
||||||
|
queryResult.kernelFileTreeProjection = nextFileTreeProjection;
|
||||||
|
queryResult.mindmap_asset_children = { ...(input.base.mindmapAssetChildren ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
|
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): DocumentRecord | null {
|
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): TreeStreamDocumentPatch | null {
|
||||||
const candidate = event.node ?? event.document ?? null;
|
const candidate = event.node ?? event.document ?? null;
|
||||||
return candidate && typeof candidate === "object" ? candidate : null;
|
if (!candidate || typeof candidate !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return typeof candidate.id === "string" && candidate.id.trim()
|
||||||
|
? ({ ...candidate, id: candidate.id.trim() } as TreeStreamDocumentPatch)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
|
function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
|
||||||
@@ -98,10 +148,50 @@ function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
|
|||||||
return candidate || null;
|
return candidate || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompleteDocumentRecord(value: TreeStreamDocumentPatch): value is DocumentRecord {
|
||||||
|
return (
|
||||||
|
typeof value.workspace_id === "string" &&
|
||||||
|
typeof value.access_scope === "string" &&
|
||||||
|
typeof value.is_template === "boolean" &&
|
||||||
|
typeof value.created_at === "string" &&
|
||||||
|
"parent_id" in value &&
|
||||||
|
"sort_order" in value &&
|
||||||
|
"is_starred" in value &&
|
||||||
|
"updated_at" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTreeRendererDeltaState(input: {
|
||||||
|
projection: TreeRendererProjection;
|
||||||
|
sidebar: SidebarInitialData;
|
||||||
|
}): TreeRendererDeltaState {
|
||||||
|
const sidebar = input.sidebar;
|
||||||
|
const pageTreeSource =
|
||||||
|
sidebar.kernelSidebarTree.length > 0
|
||||||
|
? sidebar.kernelSidebarTree
|
||||||
|
: buildSidebarTreeFromKernelProjection({
|
||||||
|
records: sidebar.documents,
|
||||||
|
projection: sidebar.kernelSidebarProjection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projection: input.projection,
|
||||||
|
sidebar,
|
||||||
|
documents: [...sidebar.documents],
|
||||||
|
sidebarItems: [...sidebar.kernelSidebarProjection.items],
|
||||||
|
pageTreeItems: buildPageTreeProjectionItems(pageTreeSource),
|
||||||
|
fileTreeItems: [...sidebar.kernelFileTreeProjection.items],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function applyTreeStreamDelta(
|
export function applyTreeStreamDelta(
|
||||||
base: SidebarInitialData,
|
base: SidebarInitialData,
|
||||||
event: TreeStreamDeltaEvent,
|
event: TreeStreamDeltaEvent,
|
||||||
): SidebarInitialData {
|
): SidebarInitialData {
|
||||||
|
if (event.op === "noop") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.op === "replace_sidebar" && event.sidebar) {
|
if (event.op === "replace_sidebar" && event.sidebar) {
|
||||||
if ("activeWorkspaceId" in event.sidebar) {
|
if ("activeWorkspaceId" in event.sidebar) {
|
||||||
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
||||||
@@ -117,16 +207,22 @@ export function applyTreeStreamDelta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.op === "upsert_document") {
|
if (event.op === "upsert_document") {
|
||||||
const nextDocument = normalizeUpsertDocument(event);
|
const documentPatch = normalizeUpsertDocument(event);
|
||||||
if (!nextDocument) {
|
if (!documentPatch) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
const nextDocuments = [...base.documents];
|
const nextDocuments = [...base.documents];
|
||||||
const existingIndex = nextDocuments.findIndex((item) => item.id === nextDocument.id);
|
const existingIndex = nextDocuments.findIndex((item) => item.id === documentPatch.id);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
nextDocuments[existingIndex] = nextDocument;
|
nextDocuments[existingIndex] = {
|
||||||
|
...nextDocuments[existingIndex],
|
||||||
|
...documentPatch,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
nextDocuments.push(nextDocument);
|
if (!isCompleteDocumentRecord(documentPatch)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
nextDocuments.push(documentPatch);
|
||||||
}
|
}
|
||||||
return buildSidebarFromDocuments({
|
return buildSidebarFromDocuments({
|
||||||
base,
|
base,
|
||||||
@@ -158,3 +254,14 @@ export function applyTreeStreamDelta(
|
|||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyTreeStreamDeltaToProjectionState(input: {
|
||||||
|
projection: TreeRendererProjection;
|
||||||
|
base: SidebarInitialData;
|
||||||
|
event: TreeStreamDeltaEvent;
|
||||||
|
}): TreeRendererDeltaState {
|
||||||
|
return deriveTreeRendererDeltaState({
|
||||||
|
projection: input.projection,
|
||||||
|
sidebar: applyTreeStreamDelta(input.base, input.event),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ class MockEventSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var EventSource: typeof MockEventSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flush() {
|
function flush() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(resolve, 0);
|
setTimeout(resolve, 0);
|
||||||
@@ -74,6 +69,13 @@ function buildInitialData(): SidebarInitialData {
|
|||||||
edges: [],
|
edges: [],
|
||||||
},
|
},
|
||||||
kernelSidebarTree: [],
|
kernelSidebarTree: [],
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
trashedDocuments: [],
|
trashedDocuments: [],
|
||||||
trashedMediaAssets: [],
|
trashedMediaAssets: [],
|
||||||
trashedMindmapAssets: [],
|
trashedMindmapAssets: [],
|
||||||
@@ -86,6 +88,56 @@ function buildInitialData(): SidebarInitialData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSnapshotEnvelope(title = "工作区首页") {
|
||||||
|
return {
|
||||||
|
stream: "workspace",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
cursor: "evt_2",
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
data: {
|
||||||
|
active_workspace_id: "ws_1",
|
||||||
|
workspaces: [],
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: "page_root",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title,
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-24T00:00:00Z",
|
||||||
|
updated_at: "2026-04-24T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kernel_file_tree_projection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
kernel_sidebar_projection: {
|
||||||
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||||
|
projection: "sidebar_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
trashed_documents: [],
|
||||||
|
media_assets: [],
|
||||||
|
trashed_media_assets: [],
|
||||||
|
mindmap_assets: [],
|
||||||
|
trashed_mindmap_assets: [],
|
||||||
|
table_assets: [],
|
||||||
|
trashed_table_assets: [],
|
||||||
|
mindmap_docs: [],
|
||||||
|
mindmap_asset_children: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
|
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
|
||||||
const state = useSidebarTreeStream(buildInitialData());
|
const state = useSidebarTreeStream(buildInitialData());
|
||||||
|
|
||||||
@@ -112,7 +164,7 @@ describe("useSidebarTreeStream", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
MockEventSource.instances = [];
|
MockEventSource.instances = [];
|
||||||
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
@@ -175,4 +227,74 @@ describe("useSidebarTreeStream", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("首帧前连接报错时应进入 fallback", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness onState={onState} />);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
MockEventSource.instances[0]?.onerror?.();
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onState).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: null,
|
||||||
|
status: "fallback",
|
||||||
|
cursor: null,
|
||||||
|
error: expect.any(Error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(MockEventSource.instances[0]?.closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("收到 snapshot 后连接中断也应切到 fallback,并保留最近一次 stream 数据", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness onState={onState} />);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
MockEventSource.instances[0]?.emit("snapshot", buildSnapshotEnvelope("来自 stream 的标题"));
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onState).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "live",
|
||||||
|
cursor: "evt_2",
|
||||||
|
data: expect.objectContaining({
|
||||||
|
documents: [expect.objectContaining({ title: "来自 stream 的标题" })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
MockEventSource.instances[0]?.onerror?.();
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onState).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "fallback",
|
||||||
|
cursor: "evt_2",
|
||||||
|
error: expect.any(Error),
|
||||||
|
data: expect.objectContaining({
|
||||||
|
documents: [expect.objectContaining({ title: "来自 stream 的标题" })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(MockEventSource.instances[0]?.closed).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
|
|||||||
document: isRecord(input.document) ? (input.document as TreeStreamDeltaEvent["document"]) : null,
|
document: isRecord(input.document) ? (input.document as TreeStreamDeltaEvent["document"]) : null,
|
||||||
documentId: typeof input.documentId === "string" ? input.documentId : null,
|
documentId: typeof input.documentId === "string" ? input.documentId : null,
|
||||||
documents: Array.isArray(input.documents) ? (input.documents as TreeStreamDeltaEvent["documents"]) : null,
|
documents: Array.isArray(input.documents) ? (input.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||||
sidebar: isRecord(input.sidebar) ? input.sidebar : null,
|
sidebar: isRecord(input.sidebar) ? (input.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
|||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setState((previous) => ({
|
setState((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
status: previous.data ? "live" : "fallback",
|
status: "fallback",
|
||||||
error: previous.error ?? new Error("tree stream 连接失败"),
|
error: previous.error ?? new Error("tree stream 连接失败"),
|
||||||
}));
|
}));
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user