4-26 树rust-2
This commit is contained in:
@@ -482,6 +482,7 @@ pub async fn save(
|
||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||
"blockCount": body.block_count,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web human editor save".into()),
|
||||
refs: vec!["mnote-web-editor-runtime".into()],
|
||||
dry_run: false,
|
||||
|
||||
@@ -1,34 +1,138 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use crate::routes::stream_support::{
|
||||
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::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use serde_json::Value;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
pub async fn events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<StreamSnapshotQuery>,
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
|
||||
let payload = load_stream_snapshot(state.config(), &context, &query).await?;
|
||||
let event = snapshot_event(&payload);
|
||||
let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?;
|
||||
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()
|
||||
.interval(Duration::from_secs(15))
|
||||
.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("snapshot")
|
||||
.event(event_name)
|
||||
.json_data(payload)
|
||||
.expect("SSE snapshot 事件必须可序列化")
|
||||
.expect("SSE 事件必须可序列化")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -62,7 +166,7 @@ mod tests {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/stream/events?workspaceId=ws_demo")
|
||||
.uri("/api/stream/events?workspaceId=ws_demo&maxPolls=0")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -75,7 +179,8 @@ mod tests {
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSnapshotQuery {
|
||||
@@ -21,6 +30,8 @@ pub struct StreamSnapshotQuery {
|
||||
pub depth: Option<u32>,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub poll_ms: Option<u64>,
|
||||
pub max_polls: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -29,6 +40,19 @@ pub enum StreamSnapshotScope {
|
||||
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 {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -36,6 +60,19 @@ impl StreamSnapshotScope {
|
||||
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 {
|
||||
@@ -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(
|
||||
workspace_id: &str,
|
||||
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(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -102,17 +388,13 @@ pub async fn load_stream_snapshot(
|
||||
})
|
||||
}
|
||||
StreamSnapshotScope::Subtree => {
|
||||
let root_node_id = query
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
let root_node_id = normalize_root_node_id(query)
|
||||
.expect("subtree scope 已确保 rootNodeId 存在");
|
||||
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
||||
let tree = execute_kernel_query(
|
||||
context,
|
||||
&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(),
|
||||
)?;
|
||||
|
||||
@@ -131,15 +413,20 @@ pub async fn load_stream_snapshot(
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
let cursor = resolve_stream_cursor(overview.as_ref(), query.cursor.as_deref());
|
||||
|
||||
Ok(json!({
|
||||
"kind": "snapshot",
|
||||
"scope": scope.as_str(),
|
||||
"stream": scope.as_str(),
|
||||
"projection": scope.projection(),
|
||||
"cursor": cursor,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"rootNodeId": query.root_node_id,
|
||||
"rootNodeId": normalize_root_node_id(query),
|
||||
"depth": query.depth,
|
||||
"data": snapshot,
|
||||
"snapshot": snapshot,
|
||||
"overview": overview,
|
||||
}))
|
||||
@@ -147,7 +434,11 @@ pub async fn load_stream_snapshot(
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn stream_scope_defaults_to_workspace() {
|
||||
@@ -167,4 +458,130 @@ mod tests {
|
||||
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 depth: Option<u32>,
|
||||
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 channel: Option<String>,
|
||||
pub host: Option<String>,
|
||||
@@ -162,6 +164,8 @@ fn build_tree_shell_html(
|
||||
workspace_id: &str,
|
||||
root_node_id: Option<&str>,
|
||||
active_document_id: Option<&str>,
|
||||
focused_document_id: Option<&str>,
|
||||
active_picker_item_key: Option<&str>,
|
||||
channel: &str,
|
||||
host: Option<&str>,
|
||||
context: &RequestContext,
|
||||
@@ -175,6 +179,8 @@ fn build_tree_shell_html(
|
||||
"workspaceId": workspace_id,
|
||||
"rootNodeId": root_node_id,
|
||||
"activeDocumentId": active_document_id,
|
||||
"focusedDocumentId": focused_document_id,
|
||||
"activePickerItemKey": active_picker_item_key,
|
||||
"actorId": context.auth.actor_id,
|
||||
"channel": channel,
|
||||
"host": host,
|
||||
@@ -663,6 +669,21 @@ fn build_tree_shell_html(
|
||||
.tree-kind-badge[data-kind="table"] {
|
||||
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"] {
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -776,6 +797,14 @@ fn build_tree_shell_html(
|
||||
typeof state.activeDocumentId === "string" && 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 rawMode =
|
||||
typeof state.mode === "string" ? state.mode.trim() : "";
|
||||
@@ -938,19 +967,43 @@ fn build_tree_shell_html(
|
||||
.filter((item) => item.childCount > 0 && item.expandedByDefault)
|
||||
.map((item) => item.nodeId),
|
||||
);
|
||||
let focusedNodeId =
|
||||
activeDocumentId && itemById.has(activeDocumentId)
|
||||
? activeDocumentId
|
||||
: roots[0]?.nodeId || "";
|
||||
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
|
||||
let fileTreeAnchorRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
|
||||
let fileTreeFocusedRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
|
||||
let currentActiveDocumentId = activeDocumentId;
|
||||
let currentFocusedDocumentId = focusedDocumentId;
|
||||
let currentActivePickerItemKey = activePickerItemKey;
|
||||
const resolvePickerRootFocused = () =>
|
||||
mode === "picker" && currentActivePickerItemKey === "__root__";
|
||||
const resolveFocusedNodeIdFromHostState = () => {
|
||||
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 draggingPageNodeId = "";
|
||||
let activePageDropNodeId = null;
|
||||
let draggingFileTreeRowIds = [];
|
||||
let activeFileTreeDropRowId = null;
|
||||
let activeFileTreeRootDrop = false;
|
||||
|
||||
let activeCursor = itemById.get(activeDocumentId) || null;
|
||||
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
||||
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
||||
expanded.add(activeCursor.parentNodeId);
|
||||
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"/>
|
||||
</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: `
|
||||
<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"/>
|
||||
@@ -1432,10 +1515,16 @@ fn build_tree_shell_html(
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.ok !== true || !data.result) {
|
||||
if (!data || typeof data !== "object") {
|
||||
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 {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -1453,9 +1542,101 @@ fn build_tree_shell_html(
|
||||
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) => {
|
||||
if (expanded.has(nodeId)) expanded.delete(nodeId);
|
||||
else expanded.add(nodeId);
|
||||
const nextExpanded = !expanded.has(nodeId);
|
||||
if (nextExpanded) expanded.add(nodeId);
|
||||
else expanded.delete(nodeId);
|
||||
postPageExpandChange(nodeId, nextExpanded);
|
||||
renderTree();
|
||||
};
|
||||
|
||||
@@ -1473,13 +1654,150 @@ fn build_tree_shell_html(
|
||||
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) => {
|
||||
if (!nodeId || !itemById.has(nodeId)) return;
|
||||
if (focusedNodeId === nodeId) {
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
focusedNodeId = nodeId;
|
||||
postPageFocusChange(nodeId);
|
||||
renderTree();
|
||||
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 = ({
|
||||
documentId,
|
||||
assetId,
|
||||
@@ -1575,6 +1893,7 @@ fn build_tree_shell_html(
|
||||
event.preventDefault();
|
||||
if (item.childCount > 0 && !expanded.has(item.nodeId)) {
|
||||
expanded.add(item.nodeId);
|
||||
postPageExpandChange(item.nodeId, true);
|
||||
renderTree();
|
||||
focusRowElement(item.nodeId);
|
||||
return;
|
||||
@@ -1589,6 +1908,7 @@ fn build_tree_shell_html(
|
||||
event.preventDefault();
|
||||
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
||||
expanded.delete(item.nodeId);
|
||||
postPageExpandChange(item.nodeId, false);
|
||||
renderTree();
|
||||
focusRowElement(item.nodeId);
|
||||
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 badge = document.createElement("span");
|
||||
badge.className = "tree-kind-badge";
|
||||
@@ -1756,9 +2111,19 @@ fn build_tree_shell_html(
|
||||
? ICONS.mindmap
|
||||
: kind === "table"
|
||||
? ICONS.table
|
||||
: kind === "index"
|
||||
? ICONS.index
|
||||
: kind === "page"
|
||||
: kind === "pdf"
|
||||
? ICONS.pdf
|
||||
: 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.file;
|
||||
return badge;
|
||||
@@ -1784,17 +2149,21 @@ fn build_tree_shell_html(
|
||||
const hasChildren = item.childCount > 0;
|
||||
const row = document.createElement("div");
|
||||
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.nodeId = item.nodeId;
|
||||
row.dataset.shellMode = mode;
|
||||
row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId);
|
||||
row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1;
|
||||
row.setAttribute("role", "treeitem");
|
||||
row.setAttribute("aria-level", String(item.depth + 1));
|
||||
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
||||
row.draggable = mode === "page";
|
||||
row.dataset.draggable = String(mode === "page");
|
||||
row.addEventListener("focus", () => {
|
||||
if (focusedNodeId !== item.nodeId) {
|
||||
focusedNodeId = item.nodeId;
|
||||
postPageFocusChange(item.nodeId);
|
||||
renderTree();
|
||||
}
|
||||
});
|
||||
@@ -1804,6 +2173,58 @@ fn build_tree_shell_html(
|
||||
event.preventDefault();
|
||||
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) {
|
||||
const toggleButton = document.createElement("button");
|
||||
@@ -2008,7 +2429,9 @@ fn build_tree_shell_html(
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-row";
|
||||
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.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
@@ -2195,6 +2618,7 @@ fn build_tree_shell_html(
|
||||
rootButton.type = "button";
|
||||
rootButton.className = "tree-row";
|
||||
rootButton.setAttribute("data-testid", "tree-picker-root");
|
||||
rootButton.dataset.focused = String(resolvePickerRootFocused());
|
||||
rootButton.addEventListener("click", () => {
|
||||
setLastAction("已选择根目录");
|
||||
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", () => {
|
||||
if (mode === "picker") return;
|
||||
void handleCreate(null);
|
||||
@@ -2257,6 +2727,9 @@ fn build_tree_shell_html(
|
||||
};
|
||||
|
||||
renderTree();
|
||||
if (mode === "page" && focusedNodeId) {
|
||||
postPageFocusChange(focusedNodeId);
|
||||
}
|
||||
if (mode === "filetree") {
|
||||
emitFileTreeSelectionChange();
|
||||
}
|
||||
@@ -2328,6 +2801,8 @@ pub async fn tree_shell(
|
||||
&effective_workspace_id,
|
||||
query.root_node_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),
|
||||
query.host.as_deref(),
|
||||
&effective_context,
|
||||
@@ -2389,6 +2864,7 @@ fn create_command_wire(
|
||||
"accessScope": access_scope,
|
||||
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("tree-shell create".into()),
|
||||
refs: vec!["mnote-web-tree".into()],
|
||||
dry_run: false,
|
||||
@@ -2423,6 +2899,7 @@ fn create_command_wire(
|
||||
"documentId": document_id,
|
||||
"title": title,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("tree-shell rename".into()),
|
||||
refs: vec!["mnote-web-tree".into()],
|
||||
dry_run: false,
|
||||
@@ -2461,6 +2938,7 @@ fn create_command_wire(
|
||||
"parentId": parent_id,
|
||||
"sortOrder": sort_order,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("tree-shell move".into()),
|
||||
refs: vec!["mnote-web-tree".into()],
|
||||
dry_run: false,
|
||||
@@ -2645,6 +3123,11 @@ mod tests {
|
||||
assert!(html.contains("test-shell"));
|
||||
assert!(html.contains("tree-action-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(\"aria-level\""));
|
||||
}
|
||||
@@ -2670,6 +3153,8 @@ mod tests {
|
||||
assert!(html.contains("\"allowRootPick\":true"));
|
||||
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
||||
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__"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user