2026-04-17 23:36:24 +08:00
|
|
|
use crate::app::AppState;
|
|
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::error::WebError;
|
|
|
|
|
use crate::routes::command_support::{
|
2026-05-16 07:11:06 +08:00
|
|
|
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
|
2026-04-26 19:35:52 +08:00
|
|
|
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
2026-04-17 23:36:24 +08:00
|
|
|
};
|
2026-05-08 00:41:03 +08:00
|
|
|
use crate::routes::local_folder_source::{
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
|
2026-05-20 19:04:05 +08:00
|
|
|
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
|
|
|
|
|
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
2026-05-23 23:38:42 +08:00
|
|
|
local_workspace_id_from_root_uri, LocalAccessMode,
|
2026-05-08 00:41:03 +08:00
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
use crate::routes::query_support::{
|
|
|
|
|
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
|
|
|
|
};
|
|
|
|
|
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
|
|
|
|
use crate::transport::convex::execute_convex_mutation_by_name;
|
2026-04-26 19:35:52 +08:00
|
|
|
use crate::tree_shell::filetree_renderer::{
|
2026-04-29 12:24:44 +08:00
|
|
|
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
|
2026-04-26 19:35:52 +08:00
|
|
|
};
|
|
|
|
|
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
|
|
|
|
use crate::tree_shell::page_renderer::{
|
2026-04-29 12:24:44 +08:00
|
|
|
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
|
2026-04-26 19:35:52 +08:00
|
|
|
};
|
|
|
|
|
use crate::tree_shell::picker_renderer::{
|
2026-04-29 12:24:44 +08:00
|
|
|
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
|
2026-04-26 19:35:52 +08:00
|
|
|
};
|
|
|
|
|
use crate::tree_shell::renderer_input::{
|
|
|
|
|
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
|
|
|
|
TreeShellRendererInput,
|
|
|
|
|
};
|
2026-04-28 16:30:51 +08:00
|
|
|
use crate::tree_shell::runtime_api::{
|
2026-04-29 12:24:44 +08:00
|
|
|
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
|
|
|
|
|
TreeShellRuntimeResult,
|
2026-04-28 16:30:51 +08:00
|
|
|
};
|
2026-04-26 19:35:52 +08:00
|
|
|
use axum::extract::{Extension, Query, State};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::http::{header, HeaderValue, StatusCode};
|
2026-04-26 19:35:52 +08:00
|
|
|
use axum::response::{Html, IntoResponse, Response};
|
2026-04-29 12:24:44 +08:00
|
|
|
use axum::Json;
|
2026-04-17 23:36:24 +08:00
|
|
|
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
|
|
|
|
use core_protocol::KernelProjectionKind;
|
|
|
|
|
use serde::Deserialize;
|
2026-04-29 12:24:44 +08:00
|
|
|
use serde_json::{json, Value};
|
2026-04-30 16:18:54 +08:00
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
2026-04-17 23:36:24 +08:00
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
static TREE_DOCUMENT_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeShellQuery {
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
pub root_node_id: Option<String>,
|
|
|
|
|
pub depth: Option<u32>,
|
|
|
|
|
pub active_document_id: Option<String>,
|
2026-04-26 04:29:23 +08:00
|
|
|
pub focused_document_id: Option<String>,
|
|
|
|
|
pub active_picker_item_key: Option<String>,
|
2026-04-17 23:36:24 +08:00
|
|
|
pub actor_id: Option<String>,
|
|
|
|
|
pub channel: Option<String>,
|
|
|
|
|
pub host: Option<String>,
|
|
|
|
|
pub mode: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
pub source_kind: Option<String>,
|
|
|
|
|
pub root_uri: Option<String>,
|
2026-04-17 23:36:24 +08:00
|
|
|
pub allow_root_pick: Option<String>,
|
|
|
|
|
pub exclude_ids: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct LocalFolderWatchQuery {
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeCommandEnvelope {
|
|
|
|
|
pub action: String,
|
|
|
|
|
pub workspace_id: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
pub source_kind: Option<String>,
|
|
|
|
|
pub root_uri: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub source_capabilities: Vec<String>,
|
|
|
|
|
pub target_node_id: Option<String>,
|
|
|
|
|
pub target_resource_meta: Option<Value>,
|
|
|
|
|
pub selection: Option<Value>,
|
|
|
|
|
pub operation: Option<String>,
|
2026-04-17 23:36:24 +08:00
|
|
|
pub document_id: Option<String>,
|
|
|
|
|
pub parent_id: Option<String>,
|
2026-05-08 00:41:03 +08:00
|
|
|
pub target_parent_id: Option<String>,
|
2026-04-17 23:36:24 +08:00
|
|
|
pub title: Option<String>,
|
|
|
|
|
pub access_scope: Option<String>,
|
|
|
|
|
pub content: Option<Value>,
|
|
|
|
|
pub sort_order: Option<i64>,
|
2026-05-08 00:41:03 +08:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub items: Vec<TreeCommandCopyItem>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeCommandCopyItem {
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub recursive: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
pub struct TreeCommandEnvelopeContext {
|
|
|
|
|
pub source_kind: Option<String>,
|
|
|
|
|
pub root_uri: Option<String>,
|
|
|
|
|
pub source_capabilities: Vec<String>,
|
|
|
|
|
pub target_node_id: Option<String>,
|
|
|
|
|
pub target_resource_meta: Option<Value>,
|
|
|
|
|
pub selection: Option<Value>,
|
|
|
|
|
pub operation: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TreeCommandEnvelopeContext {
|
|
|
|
|
fn from_envelope(envelope: &TreeCommandEnvelope) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
source_kind: read_optional_non_empty(envelope.source_kind.clone()),
|
|
|
|
|
root_uri: read_optional_non_empty(envelope.root_uri.clone()),
|
|
|
|
|
source_capabilities: envelope
|
|
|
|
|
.source_capabilities
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|capability| read_optional_non_empty(Some(capability.clone())))
|
|
|
|
|
.collect(),
|
|
|
|
|
target_node_id: read_optional_non_empty(envelope.target_node_id.clone()),
|
|
|
|
|
target_resource_meta: envelope.target_resource_meta.clone(),
|
|
|
|
|
selection: envelope.selection.clone(),
|
|
|
|
|
operation: read_optional_non_empty(envelope.operation.clone()),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum TreeCommandRequest {
|
|
|
|
|
Create {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
title: String,
|
|
|
|
|
access_scope: Option<String>,
|
|
|
|
|
content: Option<Value>,
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
CreateFolder {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
title: String,
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
Rename {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
title: String,
|
|
|
|
|
},
|
|
|
|
|
Move {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
sort_order: i64,
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
Archive {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
},
|
|
|
|
|
Restore {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
},
|
|
|
|
|
Copy {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
target_parent_id: Option<String>,
|
|
|
|
|
items: Vec<TreeCommandCopyItem>,
|
|
|
|
|
title: Option<String>,
|
|
|
|
|
},
|
|
|
|
|
DropFiles {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
files_json: String,
|
|
|
|
|
},
|
2026-04-29 12:24:44 +08:00
|
|
|
Purge {
|
|
|
|
|
workspace_id: Option<String>,
|
|
|
|
|
document_id: String,
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_html(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_inline_json(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "\\u0026")
|
|
|
|
|
.replace('<', "\\u003c")
|
|
|
|
|
.replace('>', "\\u003e")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_title(value: Option<String>) -> String {
|
|
|
|
|
let trimmed = value.unwrap_or_default().trim().to_string();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
"无标题".into()
|
|
|
|
|
} else {
|
|
|
|
|
trimmed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_channel(value: Option<String>) -> String {
|
|
|
|
|
let trimmed = value.unwrap_or_default().trim().to_string();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
"mnote-tree-shell-v1".into()
|
|
|
|
|
} else {
|
|
|
|
|
trimmed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_tree_mode(value: Option<&str>) -> &'static str {
|
|
|
|
|
match value.unwrap_or_default().trim() {
|
|
|
|
|
"picker" => "picker",
|
|
|
|
|
"filetree" => "filetree",
|
|
|
|
|
_ => "page",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
|
2026-04-18 09:38:16 +08:00
|
|
|
match value
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.trim()
|
|
|
|
|
.to_ascii_lowercase()
|
|
|
|
|
.as_str()
|
|
|
|
|
{
|
2026-04-17 23:36:24 +08:00
|
|
|
"1" | "true" | "yes" | "on" => true,
|
|
|
|
|
"0" | "false" | "no" | "off" => false,
|
|
|
|
|
_ => default,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
fn build_workspace_source_wire(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
envelope_context: &TreeCommandEnvelopeContext,
|
|
|
|
|
) -> bridge_runtime::RuntimeSourceWire {
|
|
|
|
|
let source_kind = envelope_context
|
|
|
|
|
.source_kind
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| "convex_workspace".into());
|
|
|
|
|
let root_uri = envelope_context.root_uri.clone().or_else(|| {
|
|
|
|
|
if source_kind == "convex_workspace" {
|
|
|
|
|
Some(format!("convex://workspace/{workspace_id}"))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let capabilities =
|
|
|
|
|
if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" {
|
|
|
|
|
vec![
|
|
|
|
|
"load-snapshot".into(),
|
|
|
|
|
"preflight-command".into(),
|
|
|
|
|
"execute-command".into(),
|
|
|
|
|
"resolve-page-aggregate".into(),
|
|
|
|
|
]
|
|
|
|
|
} else {
|
|
|
|
|
envelope_context.source_capabilities.clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
bridge_runtime::RuntimeSourceWire {
|
|
|
|
|
channel: context.source.channel.clone(),
|
|
|
|
|
client: context.source.client.clone(),
|
|
|
|
|
source_kind: Some(source_kind),
|
|
|
|
|
root_uri,
|
|
|
|
|
workspace_id: Some(workspace_id.to_string()),
|
|
|
|
|
capabilities,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn attach_tree_command_envelope_context(
|
|
|
|
|
mut payload: Value,
|
|
|
|
|
envelope_context: &TreeCommandEnvelopeContext,
|
|
|
|
|
) -> Value {
|
|
|
|
|
if let Some(map) = payload.as_object_mut() {
|
|
|
|
|
if let Some(target_node_id) = envelope_context.target_node_id.as_ref() {
|
|
|
|
|
map.insert("targetNodeId".into(), json!(target_node_id));
|
|
|
|
|
}
|
|
|
|
|
if let Some(target_resource_meta) = envelope_context.target_resource_meta.as_ref() {
|
|
|
|
|
map.insert("targetResourceMeta".into(), target_resource_meta.clone());
|
|
|
|
|
}
|
|
|
|
|
if let Some(selection) = envelope_context.selection.as_ref() {
|
|
|
|
|
map.insert("selection".into(), selection.clone());
|
|
|
|
|
}
|
|
|
|
|
if let Some(operation) = envelope_context.operation.as_ref() {
|
|
|
|
|
map.insert("operation".into(), json!(operation));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
fn parse_exclude_ids(value: Option<&str>) -> Vec<String> {
|
|
|
|
|
value
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|item| !item.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
fn collect_projection_item_ids(projection: &Value) -> Vec<String> {
|
|
|
|
|
projection
|
|
|
|
|
.get("items")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.map(|items| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|item| {
|
|
|
|
|
item.get("rowId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.or_else(|| item.get("nodeId").and_then(Value::as_str))
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
|
|
|
|
|
projection
|
|
|
|
|
.get("items")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.map(|items| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|item| {
|
|
|
|
|
item.get("expandedByDefault")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
})
|
|
|
|
|
.filter_map(|item| {
|
|
|
|
|
item.get("nodeId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
|
2026-04-30 16:18:54 +08:00
|
|
|
let mut rows: Vec<PageTreeRenderRow> = projection
|
2026-04-26 19:35:52 +08:00
|
|
|
.get("items")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.map(|items| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|item| {
|
|
|
|
|
let node_id = item
|
|
|
|
|
.get("nodeId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())?;
|
|
|
|
|
let row_kind = item
|
|
|
|
|
.get("rowKind")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if row_kind != "document" {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(PageTreeRenderRow {
|
|
|
|
|
node_id: node_id.to_string(),
|
|
|
|
|
parent_node_id: item
|
|
|
|
|
.get("parentNodeId")
|
2026-04-30 16:18:54 +08:00
|
|
|
.or_else(|| item.get("parentId"))
|
2026-04-26 19:35:52 +08:00
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
|
|
|
|
title: item
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("无标题")
|
|
|
|
|
.to_string(),
|
|
|
|
|
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
|
|
|
|
|
expandable: item
|
|
|
|
|
.get("expandable")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
item.get("childCount")
|
|
|
|
|
.and_then(Value::as_u64)
|
|
|
|
|
.map(|count| count > 0)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}),
|
|
|
|
|
expanded: item
|
|
|
|
|
.get("expandedByDefault")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or(false),
|
2026-05-11 13:16:34 +08:00
|
|
|
openable: item
|
|
|
|
|
.get("resourceMeta")
|
|
|
|
|
.and_then(Value::as_object)
|
|
|
|
|
.and_then(|meta| meta.get("documentId"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.is_some()
|
|
|
|
|
|| !node_id.starts_with("local-dir:"),
|
2026-04-26 19:35:52 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
2026-04-30 16:18:54 +08:00
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let parent_by_id = rows
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|row| (row.node_id.clone(), row.parent_node_id.clone()))
|
|
|
|
|
.collect::<BTreeMap<_, _>>();
|
|
|
|
|
for row in &mut rows {
|
|
|
|
|
if row.depth == 0 && row.parent_node_id.is_some() {
|
|
|
|
|
let mut depth = 0_u32;
|
|
|
|
|
let mut cursor = row.parent_node_id.as_deref();
|
|
|
|
|
while let Some(parent_id) = cursor {
|
|
|
|
|
depth += 1;
|
|
|
|
|
cursor = parent_by_id
|
|
|
|
|
.get(parent_id)
|
|
|
|
|
.and_then(|parent| parent.as_deref());
|
|
|
|
|
if depth > 32 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
row.depth = depth;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 10:43:38 +08:00
|
|
|
fn normalize_filetree_mindmap_title(raw_title: &str) -> String {
|
2026-05-16 07:11:06 +08:00
|
|
|
let title = raw_title.trim();
|
|
|
|
|
if title.is_empty() {
|
|
|
|
|
"无标题".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
title.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 14:36:24 +08:00
|
|
|
pub(crate) fn collect_filetree_render_rows(
|
2026-04-26 19:35:52 +08:00
|
|
|
projection: &Value,
|
|
|
|
|
active_document_id: Option<&str>,
|
2026-05-16 07:11:06 +08:00
|
|
|
active_row_id: Option<&str>,
|
2026-04-26 19:35:52 +08:00
|
|
|
) -> Vec<FileTreeRenderRow> {
|
2026-05-08 00:41:03 +08:00
|
|
|
let active_document_id = active_document_id
|
2026-04-26 19:35:52 +08:00
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
2026-05-08 00:41:03 +08:00
|
|
|
.map(ToOwned::to_owned);
|
2026-05-16 07:11:06 +08:00
|
|
|
let active_row_id = active_row_id
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
let selected_ids = active_row_id
|
2026-05-08 00:41:03 +08:00
|
|
|
.as_deref()
|
2026-05-16 07:11:06 +08:00
|
|
|
.map(|row_id| BTreeSet::from([row_id.to_string()]))
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
active_document_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|document_id| BTreeSet::from([format!("doc:{document_id}")]))
|
|
|
|
|
})
|
2026-04-26 19:35:52 +08:00
|
|
|
.unwrap_or_default();
|
2026-05-16 07:11:06 +08:00
|
|
|
let allow_document_fallback = active_row_id.is_none();
|
|
|
|
|
let active_document_id_for_fallback = active_document_id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.filter(|_| allow_document_fallback);
|
2026-04-26 19:35:52 +08:00
|
|
|
|
|
|
|
|
projection
|
|
|
|
|
.get("items")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.map(|items| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|item| {
|
|
|
|
|
let row_id = item
|
|
|
|
|
.get("rowId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())?;
|
|
|
|
|
let node_id = item
|
|
|
|
|
.get("nodeId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())?;
|
|
|
|
|
let resource_meta = item.get("resourceMeta").and_then(Value::as_object);
|
2026-05-08 00:41:03 +08:00
|
|
|
let document_id = resource_meta
|
|
|
|
|
.and_then(|meta| meta.get("documentId"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
let selected = selected_ids.contains(row_id)
|
2026-05-16 07:11:06 +08:00
|
|
|
|| active_document_id_for_fallback.is_some_and(|active_id| {
|
|
|
|
|
document_id.as_deref() == Some(active_id)
|
|
|
|
|
&& item
|
|
|
|
|
.get("rowKind")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.is_some_and(|kind| kind == "document")
|
|
|
|
|
});
|
|
|
|
|
let row_kind = item
|
|
|
|
|
.get("rowKind")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("document")
|
|
|
|
|
.to_string();
|
|
|
|
|
let asset_id = resource_meta
|
|
|
|
|
.and_then(|meta| meta.get("assetId"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
let icon_kind = item
|
|
|
|
|
.get("iconHint")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
resource_meta
|
|
|
|
|
.and_then(|meta| meta.get("iconHint"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
})
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("file")
|
|
|
|
|
.to_string();
|
|
|
|
|
let raw_title = item
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("无标题");
|
2026-04-26 19:35:52 +08:00
|
|
|
Some(FileTreeRenderRow {
|
|
|
|
|
row_id: row_id.to_string(),
|
2026-05-16 07:11:06 +08:00
|
|
|
row_kind: row_kind.clone(),
|
2026-04-26 19:35:52 +08:00
|
|
|
node_id: node_id.to_string(),
|
|
|
|
|
parent_node_id: item
|
|
|
|
|
.get("parentNodeId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
2026-05-20 10:43:38 +08:00
|
|
|
title: normalize_filetree_mindmap_title(raw_title),
|
2026-04-26 19:35:52 +08:00
|
|
|
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
|
|
|
|
|
expandable: item
|
|
|
|
|
.get("expandable")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
item.get("childCount")
|
|
|
|
|
.and_then(Value::as_u64)
|
|
|
|
|
.map(|count| count > 0)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}),
|
|
|
|
|
expanded: item
|
|
|
|
|
.get("expandedByDefault")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or(false),
|
2026-05-16 07:11:06 +08:00
|
|
|
icon_kind,
|
2026-05-08 00:41:03 +08:00
|
|
|
document_id,
|
2026-05-16 07:11:06 +08:00
|
|
|
asset_id,
|
2026-05-13 22:43:16 +08:00
|
|
|
object_identity: resource_meta
|
|
|
|
|
.and_then(|meta| meta.get("objectIdentity"))
|
|
|
|
|
.and_then(|value| serde_json::to_string(value).ok()),
|
2026-05-08 00:41:03 +08:00
|
|
|
selected,
|
2026-04-26 19:35:52 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_picker_render_rows(
|
|
|
|
|
projection: &Value,
|
|
|
|
|
active_picker_item_key: Option<&str>,
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
exclude_ids: &[String],
|
|
|
|
|
) -> Vec<PickerRenderRow> {
|
|
|
|
|
let active_key = active_picker_item_key
|
|
|
|
|
.or(active_document_id)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
|
|
|
|
let excluded_ids = exclude_ids.iter().cloned().collect::<BTreeSet<_>>();
|
|
|
|
|
|
|
|
|
|
projection
|
|
|
|
|
.get("items")
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
.map(|items| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|item| {
|
|
|
|
|
let node_id = item
|
|
|
|
|
.get("nodeId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())?;
|
|
|
|
|
if excluded_ids.contains(node_id) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let row_kind = item
|
|
|
|
|
.get("rowKind")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if row_kind != "document" {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(PickerRenderRow {
|
|
|
|
|
node_id: node_id.to_string(),
|
|
|
|
|
parent_node_id: item
|
|
|
|
|
.get("parentNodeId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
|
|
|
|
title: item
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or("无标题")
|
|
|
|
|
.to_string(),
|
|
|
|
|
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
|
|
|
|
|
expandable: item
|
|
|
|
|
.get("expandable")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
item.get("childCount")
|
|
|
|
|
.and_then(Value::as_u64)
|
|
|
|
|
.map(|count| count > 0)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}),
|
|
|
|
|
expanded: item
|
|
|
|
|
.get("expandedByDefault")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or(false),
|
|
|
|
|
active: active_key
|
|
|
|
|
.map(|active_key| active_key == node_id)
|
|
|
|
|
.unwrap_or(false),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_tree_shell_command_dispatcher(channel: &str) -> TreeShellCommandDispatcher {
|
|
|
|
|
TreeShellCommandDispatcher {
|
|
|
|
|
channel: channel.into(),
|
|
|
|
|
command_names: [
|
|
|
|
|
"tree.node.create",
|
|
|
|
|
"tree.node.rename",
|
|
|
|
|
"tree.subtree.move",
|
|
|
|
|
"tree.resource.copy",
|
|
|
|
|
"tree.resource.move",
|
|
|
|
|
"tree.resource.upload",
|
2026-05-16 07:11:06 +08:00
|
|
|
"tree.resource.archive",
|
|
|
|
|
"tree.resource.restore",
|
|
|
|
|
"tree.resource.purge",
|
|
|
|
|
"tree.resource.rename",
|
2026-04-26 19:35:52 +08:00
|
|
|
]
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.collect(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_tree_shell_renderer_input(
|
|
|
|
|
projection: &Value,
|
|
|
|
|
mode: &str,
|
|
|
|
|
channel: &str,
|
|
|
|
|
active_document_id: Option<&str>,
|
|
|
|
|
focused_document_id: Option<&str>,
|
|
|
|
|
active_picker_item_key: Option<&str>,
|
|
|
|
|
exclude_ids: &[String],
|
|
|
|
|
) -> TreeShellRendererInput {
|
|
|
|
|
let projection_item_ids = collect_projection_item_ids(projection);
|
|
|
|
|
let expanded_ids = collect_expanded_ids(projection);
|
|
|
|
|
let command_dispatcher = build_tree_shell_command_dispatcher(channel);
|
|
|
|
|
match mode {
|
|
|
|
|
"filetree" => {
|
|
|
|
|
let selection = active_document_id
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(|document_id| {
|
2026-05-16 07:11:06 +08:00
|
|
|
FileTreeSelectionState::from_selected(&[format!("doc:{document_id}")])
|
2026-04-26 19:35:52 +08:00
|
|
|
})
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
TreeShellRendererInput::filetree(FileTreeRendererInput {
|
|
|
|
|
projection_item_ids,
|
|
|
|
|
expanded_ids,
|
|
|
|
|
filetree_selection: selection,
|
|
|
|
|
command_dispatcher,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
"picker" => TreeShellRendererInput::picker(PickerRendererInput {
|
|
|
|
|
projection_item_ids,
|
|
|
|
|
expanded_ids,
|
|
|
|
|
active_picker_item: active_picker_item_key
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
|
|
|
|
excluded_picker_ids: exclude_ids.iter().cloned().collect(),
|
|
|
|
|
command_dispatcher,
|
|
|
|
|
}),
|
|
|
|
|
_ => TreeShellRendererInput::page(PageTreeRendererInput {
|
|
|
|
|
projection_item_ids,
|
|
|
|
|
expanded_ids,
|
|
|
|
|
focused_id: focused_document_id
|
|
|
|
|
.or(active_document_id)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
|
|
|
|
command_dispatcher,
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext {
|
|
|
|
|
let mut next = context.clone();
|
|
|
|
|
let actor_id = actor_id
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
if let Some(actor_id) = actor_id {
|
|
|
|
|
next.auth.actor_id = actor_id;
|
|
|
|
|
next.auth.actor_type = "user".into();
|
|
|
|
|
}
|
|
|
|
|
next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn generate_tree_document_id() -> String {
|
|
|
|
|
let millis = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_millis();
|
|
|
|
|
let counter = TREE_DOCUMENT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
format!("tree_{millis}_{counter}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_tree_shell_html(
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
root_node_id: Option<&str>,
|
|
|
|
|
active_document_id: Option<&str>,
|
2026-04-26 04:29:23 +08:00
|
|
|
focused_document_id: Option<&str>,
|
|
|
|
|
active_picker_item_key: Option<&str>,
|
2026-04-17 23:36:24 +08:00
|
|
|
channel: &str,
|
|
|
|
|
host: Option<&str>,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
projection: &Value,
|
|
|
|
|
mode: &str,
|
|
|
|
|
allow_root_pick: bool,
|
|
|
|
|
exclude_ids: &[String],
|
|
|
|
|
dataset: &Value,
|
|
|
|
|
) -> String {
|
2026-04-26 19:35:52 +08:00
|
|
|
let renderer_input = build_tree_shell_renderer_input(
|
|
|
|
|
projection,
|
|
|
|
|
mode,
|
|
|
|
|
channel,
|
|
|
|
|
active_document_id,
|
|
|
|
|
focused_document_id,
|
|
|
|
|
active_picker_item_key,
|
|
|
|
|
exclude_ids,
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
let app_state = json!({
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"rootNodeId": root_node_id,
|
|
|
|
|
"activeDocumentId": active_document_id,
|
2026-04-26 04:29:23 +08:00
|
|
|
"focusedDocumentId": focused_document_id,
|
|
|
|
|
"activePickerItemKey": active_picker_item_key,
|
2026-04-17 23:36:24 +08:00
|
|
|
"actorId": context.auth.actor_id,
|
|
|
|
|
"channel": channel,
|
|
|
|
|
"host": host,
|
|
|
|
|
"mode": mode,
|
2026-05-08 00:41:03 +08:00
|
|
|
"sourceKind": projection
|
|
|
|
|
.get("sourceKind")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or("convex_workspace"),
|
|
|
|
|
"rootUri": projection
|
|
|
|
|
.get("rootUri")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or(""),
|
|
|
|
|
"localWatchRevision": projection.get("watchRevision").cloned().unwrap_or(Value::Null),
|
2026-04-17 23:36:24 +08:00
|
|
|
"allowRootPick": allow_root_pick,
|
|
|
|
|
"excludeIds": exclude_ids,
|
2026-04-26 19:35:52 +08:00
|
|
|
"rendererInput": renderer_input,
|
2026-04-17 23:36:24 +08:00
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"commandPath": "/api/tree/commands",
|
|
|
|
|
"items": projection.get("items").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"mediaAssets": dataset.get("media_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"mindmapAssets": dataset.get("mindmap_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"tableAssets": dataset.get("table_assets").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
"mindmapAssetChildren": dataset.get("mindmap_asset_children").cloned().unwrap_or_else(|| json!({})),
|
|
|
|
|
});
|
|
|
|
|
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
|
|
|
|
|
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
|
2026-04-26 19:35:52 +08:00
|
|
|
let initial_tree_html = match mode {
|
|
|
|
|
"page" => render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
|
|
|
|
rows: collect_page_tree_render_rows(projection),
|
|
|
|
|
active_node_id: active_document_id
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
|
|
|
|
focused_node_id: focused_document_id
|
|
|
|
|
.or(active_document_id)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned),
|
|
|
|
|
}),
|
|
|
|
|
"filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput {
|
2026-05-16 07:11:06 +08:00
|
|
|
rows: collect_filetree_render_rows(projection, active_document_id, None),
|
2026-04-26 19:35:52 +08:00
|
|
|
}),
|
|
|
|
|
"picker" => render_initial_picker_html(&PickerInitialRenderInput {
|
|
|
|
|
rows: collect_picker_render_rows(
|
|
|
|
|
projection,
|
|
|
|
|
active_picker_item_key,
|
|
|
|
|
active_document_id,
|
|
|
|
|
exclude_ids,
|
|
|
|
|
),
|
|
|
|
|
allow_root_pick,
|
|
|
|
|
root_active: active_picker_item_key
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.map(|value| value == "__root__")
|
|
|
|
|
.unwrap_or(false),
|
|
|
|
|
}),
|
|
|
|
|
_ => String::new(),
|
|
|
|
|
};
|
2026-04-17 23:36:24 +08:00
|
|
|
let root_label = root_node_id.unwrap_or("workspace_root");
|
|
|
|
|
let active_label = active_document_id.unwrap_or("未指定");
|
|
|
|
|
let template = r##"<!doctype html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
<title>mnote Tree Shell</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
color-scheme: light;
|
|
|
|
|
--bg: #f3efe7;
|
|
|
|
|
--panel: rgba(255, 252, 247, 0.96);
|
|
|
|
|
--panel-strong: #ffffff;
|
|
|
|
|
--ink: #18222f;
|
|
|
|
|
--muted: #64748b;
|
|
|
|
|
--line: rgba(148, 163, 184, 0.28);
|
|
|
|
|
--accent: #0f6c84;
|
|
|
|
|
--accent-soft: rgba(15, 108, 132, 0.12);
|
|
|
|
|
--danger: #b42318;
|
|
|
|
|
}
|
|
|
|
|
* {
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
background:
|
|
|
|
|
radial-gradient(circle at top left, rgba(15, 108, 132, 0.18), transparent 32%),
|
|
|
|
|
linear-gradient(180deg, #fbf8f3 0%, var(--bg) 100%);
|
|
|
|
|
}
|
|
|
|
|
main {
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 18px;
|
|
|
|
|
}
|
|
|
|
|
.hero,
|
|
|
|
|
.status-card,
|
|
|
|
|
.tree-card {
|
|
|
|
|
background: var(--panel);
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
box-shadow: 0 18px 40px rgba(24, 34, 47, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.hero {
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
.eyebrow {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
h1 {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: clamp(24px, 4vw, 34px);
|
|
|
|
|
line-height: 1.05;
|
|
|
|
|
}
|
|
|
|
|
.summary {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
}
|
|
|
|
|
.meta-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.meta-pill {
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.78);
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
|
|
|
}
|
|
|
|
|
.meta-pill strong {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.status-card {
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button,
|
|
|
|
|
.tree-action,
|
|
|
|
|
.tree-toggle,
|
|
|
|
|
.tree-link {
|
|
|
|
|
font: inherit;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #fff;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button:hover {
|
|
|
|
|
filter: brightness(0.97);
|
|
|
|
|
}
|
|
|
|
|
.toolbar button:disabled {
|
|
|
|
|
opacity: 0.55;
|
|
|
|
|
cursor: progress;
|
|
|
|
|
}
|
|
|
|
|
.status-text {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.status-text[data-tone="error"] {
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
.tree-card {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
padding: 16px 18px 12px;
|
|
|
|
|
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.tree-card-title {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-meta {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
}
|
|
|
|
|
.tree-scroll {
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
padding: 14px 12px 18px;
|
|
|
|
|
}
|
|
|
|
|
.tree-root,
|
|
|
|
|
.tree-children {
|
|
|
|
|
list-style: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-children {
|
|
|
|
|
margin-left: 22px;
|
|
|
|
|
padding-left: 14px;
|
|
|
|
|
border-left: 1px dashed rgba(15, 108, 132, 0.2);
|
|
|
|
|
}
|
|
|
|
|
.tree-node + .tree-node {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
background: var(--panel-strong);
|
|
|
|
|
padding: 8px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-focused="true"] {
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.48);
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(15, 108, 132, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-active="true"] {
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.32);
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(15, 108, 132, 0.12);
|
|
|
|
|
background: linear-gradient(180deg, rgba(15, 108, 132, 0.08), rgba(255, 255, 255, 0.96));
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-selected="true"] {
|
|
|
|
|
background: rgba(15, 108, 132, 0.1);
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.24);
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
.tree-row[data-drop-feedback="true"] {
|
|
|
|
|
background: rgba(148, 163, 184, 0.18);
|
|
|
|
|
border-color: rgba(15, 108, 132, 0.28);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-draggable="true"] {
|
|
|
|
|
cursor: grab;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-draggable="true"]:active {
|
|
|
|
|
cursor: grabbing;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
.tree-toggle,
|
|
|
|
|
.tree-action {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
}
|
|
|
|
|
.tree-toggle:hover,
|
|
|
|
|
.tree-action:hover {
|
|
|
|
|
background: rgba(15, 108, 132, 0.08);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
.tree-toggle[disabled],
|
|
|
|
|
.tree-action[disabled] {
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
.tree-spacer {
|
|
|
|
|
width: 34px;
|
|
|
|
|
height: 30px;
|
|
|
|
|
}
|
|
|
|
|
.tree-link {
|
|
|
|
|
border: 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-title {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-meta {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
min-width: 22px;
|
|
|
|
|
height: 22px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: rgba(15, 108, 132, 0.08);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
.tree-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
transition: opacity 140ms ease;
|
|
|
|
|
}
|
|
|
|
|
.tree-row:hover .tree-actions,
|
|
|
|
|
.tree-row[data-focused="true"] .tree-actions,
|
|
|
|
|
.tree-row:focus-within .tree-actions {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
}
|
|
|
|
|
.tree-link:focus-visible,
|
|
|
|
|
.tree-toggle:focus-visible,
|
|
|
|
|
.tree-action:focus-visible,
|
|
|
|
|
.tree-row:focus-visible {
|
|
|
|
|
outline: 2px solid rgba(15, 108, 132, 0.35);
|
|
|
|
|
outline-offset: 2px;
|
|
|
|
|
}
|
|
|
|
|
.tree-empty {
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
border: 1px dashed rgba(148, 163, 184, 0.36);
|
|
|
|
|
padding: 24px 18px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
background: rgba(255, 255, 255, 0.64);
|
|
|
|
|
}
|
|
|
|
|
.tree-card-footer {
|
|
|
|
|
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
|
|
|
|
padding: 12px 16px 16px;
|
|
|
|
|
}
|
|
|
|
|
details {
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
|
|
|
background: rgba(255, 255, 255, 0.72);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
summary {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
list-style: none;
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
summary::-webkit-details-marker {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
pre {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0 14px 14px;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
color: #243447;
|
|
|
|
|
}
|
|
|
|
|
/* 说明:这里用覆盖式样式把原本的开发态卡片壳压成真正的侧栏树视图,
|
|
|
|
|
避免继续保留 hero/status/debug 大面板。 */
|
|
|
|
|
body {
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
main {
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
|
|
|
gap: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.hero,
|
|
|
|
|
.status-card,
|
|
|
|
|
.tree-card-footer,
|
|
|
|
|
details,
|
|
|
|
|
summary,
|
|
|
|
|
pre {
|
|
|
|
|
display: none !important;
|
|
|
|
|
}
|
|
|
|
|
.tree-card {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-header {
|
|
|
|
|
padding: 8px 10px 6px;
|
|
|
|
|
border-bottom: 1px solid rgba(31, 35, 40, 0.08);
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
min-height: 40px;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-title {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
.tree-card-meta {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
.toolbar {
|
|
|
|
|
gap: 6px;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button {
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
.toolbar button:hover {
|
|
|
|
|
background: rgba(15, 23, 42, 0.05);
|
|
|
|
|
filter: none;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
}
|
|
|
|
|
.tree-scroll {
|
|
|
|
|
padding: 6px 4px 12px;
|
|
|
|
|
}
|
|
|
|
|
.tree-children {
|
|
|
|
|
margin-left: 18px;
|
|
|
|
|
padding-left: 10px;
|
|
|
|
|
border-left: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-node + .tree-node {
|
|
|
|
|
margin-top: 1px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row {
|
|
|
|
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
min-height: 26px;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
border: 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
padding: 1px 4px;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
.tree-row:hover {
|
|
|
|
|
background: rgba(15, 23, 42, 0.04);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-focused="true"] {
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
background: rgba(37, 99, 235, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-active="true"],
|
|
|
|
|
.tree-row[data-selected="true"] {
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
background: rgba(37, 99, 235, 0.12);
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
.tree-row[data-drop-target="true"] {
|
|
|
|
|
background: rgba(15, 108, 132, 0.14);
|
|
|
|
|
outline: 1px solid rgba(15, 108, 132, 0.35);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
.tree-row[data-cut="true"] {
|
|
|
|
|
opacity: 0.55;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-drop-position="before"]::before,
|
|
|
|
|
.tree-row[data-drop-position="after"]::after {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 20px;
|
|
|
|
|
right: 8px;
|
|
|
|
|
height: 2px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: rgba(37, 99, 235, 0.82);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-drop-position="before"]::before {
|
|
|
|
|
top: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-drop-position="after"]::after {
|
|
|
|
|
bottom: 0;
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
.tree-root[data-drop-target="true"] {
|
|
|
|
|
background: rgba(15, 108, 132, 0.05);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
.tree-context-menu {
|
|
|
|
|
position: fixed;
|
|
|
|
|
z-index: 50;
|
|
|
|
|
min-width: 188px;
|
|
|
|
|
padding: 4px;
|
|
|
|
|
border: 1px solid rgba(31, 35, 40, 0.12);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
|
|
|
|
|
}
|
|
|
|
|
.tree-context-menu[hidden] {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
.tree-menu-item {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 26px;
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
}
|
|
|
|
|
.tree-menu-item:hover:not(:disabled),
|
|
|
|
|
.tree-menu-item:focus-visible:not(:disabled) {
|
|
|
|
|
background: rgba(37, 99, 235, 0.08);
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
.tree-menu-item:disabled {
|
|
|
|
|
color: #9ca3af;
|
|
|
|
|
cursor: default;
|
|
|
|
|
}
|
|
|
|
|
.tree-menu-separator {
|
|
|
|
|
height: 1px;
|
|
|
|
|
margin: 4px 2px;
|
|
|
|
|
background: rgba(31, 35, 40, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-backdrop {
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
z-index: 60;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
background: rgba(15, 23, 42, 0.28);
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-dialog {
|
|
|
|
|
width: min(420px, 100%);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
border: 1px solid rgba(31, 35, 40, 0.12);
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22);
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-body {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-title {
|
|
|
|
|
margin: 0 0 8px;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-list {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding-left: 18px;
|
|
|
|
|
color: #4b5563;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
border-top: 1px solid rgba(31, 35, 40, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-actions button {
|
|
|
|
|
min-width: 64px;
|
|
|
|
|
min-height: 28px;
|
|
|
|
|
border: 1px solid rgba(31, 35, 40, 0.12);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
.tree-preflight-actions [data-role="confirm"] {
|
|
|
|
|
border-color: #2563eb;
|
|
|
|
|
background: #2563eb;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
.tree-toggle,
|
|
|
|
|
.tree-action {
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
width: 20px;
|
|
|
|
|
height: 20px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.tree-spacer {
|
|
|
|
|
width: 16px;
|
|
|
|
|
height: 16px;
|
|
|
|
|
}
|
|
|
|
|
.tree-link {
|
|
|
|
|
padding: 2px 4px;
|
|
|
|
|
gap: 0;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-link-title {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
line-height: 1.35;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
.tree-rename-input {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 80px;
|
|
|
|
|
height: 22px;
|
|
|
|
|
border: 1px solid rgba(37, 99, 235, 0.8);
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
padding: 0 4px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
font: inherit;
|
|
|
|
|
line-height: 20px;
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
.tree-link-meta {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge {
|
|
|
|
|
min-width: 16px;
|
|
|
|
|
width: 16px;
|
|
|
|
|
height: 16px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge svg,
|
|
|
|
|
.tree-action svg {
|
|
|
|
|
width: 14px;
|
|
|
|
|
height: 14px;
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="page"] {
|
|
|
|
|
color: #2563eb;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="index"] {
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="mindmap"] {
|
|
|
|
|
color: #7c3aed;
|
|
|
|
|
}
|
|
|
|
|
.tree-kind-badge[data-kind="table"] {
|
|
|
|
|
color: #b45309;
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
.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;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
.tree-kind-badge[data-kind="file"] {
|
|
|
|
|
color: #64748b;
|
|
|
|
|
}
|
|
|
|
|
.tree-actions {
|
|
|
|
|
gap: 2px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-wrap: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.tree-action {
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
.tree-action:hover {
|
|
|
|
|
color: #1f2328;
|
|
|
|
|
background: rgba(15, 23, 42, 0.06);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="page"] .tree-link {
|
|
|
|
|
padding-right: 42px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="page"] .tree-actions {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 6px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="filetree"] .tree-link {
|
|
|
|
|
padding-right: 30px;
|
|
|
|
|
}
|
|
|
|
|
.tree-row[data-shell-mode="filetree"] .tree-actions {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 6px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
}
|
|
|
|
|
.tree-empty {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
padding: 16px 10px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
background: transparent;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 860px) {
|
|
|
|
|
main {
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.tree-row {
|
|
|
|
|
grid-template-columns: auto auto minmax(0, 1fr);
|
|
|
|
|
}
|
|
|
|
|
.tree-actions {
|
|
|
|
|
position: static;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<section class="tree-card">
|
|
|
|
|
<div class="tree-card-header">
|
|
|
|
|
<div class="tree-card-title" id="tree-shell-title">Sidebar / Page Tree</div>
|
|
|
|
|
<div class="tree-card-meta" id="tree-shell-summary">workspace=__WORKSPACE_ID__ · root=__ROOT_LABEL__ · active=__ACTIVE_LABEL__</div>
|
|
|
|
|
<div class="toolbar" id="tree-shell-toolbar">
|
|
|
|
|
<button id="tree-create-root" data-testid="tree-create-root" type="button">新建</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-26 19:35:52 +08:00
|
|
|
<div class="tree-scroll" id="tree-shell-app">__INITIAL_TREE_HTML__</div>
|
2026-04-17 23:36:24 +08:00
|
|
|
</section>
|
|
|
|
|
<div class="status-text" id="tree-shell-status" data-tone="normal" hidden>Tree shell 已加载</div>
|
|
|
|
|
<div class="status-text" id="tree-shell-last-action" data-tone="normal" hidden></div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<script id="tree-shell-state" type="application/json">__APP_STATE__</script>
|
|
|
|
|
<script>
|
|
|
|
|
(() => {
|
|
|
|
|
const stateElement = document.getElementById("tree-shell-state");
|
|
|
|
|
const appElement = document.getElementById("tree-shell-app");
|
|
|
|
|
const statusElement = document.getElementById("tree-shell-status");
|
|
|
|
|
const lastActionElement = document.getElementById("tree-shell-last-action");
|
|
|
|
|
const createRootButton = document.getElementById("tree-create-root");
|
|
|
|
|
if (!stateElement || !appElement || !statusElement || !lastActionElement || !createRootButton) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parseState = () => {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(stateElement.textContent || "{}");
|
|
|
|
|
} catch {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const state = parseState();
|
2026-04-26 19:35:52 +08:00
|
|
|
const rendererInput =
|
|
|
|
|
state.rendererInput && typeof state.rendererInput === "object"
|
|
|
|
|
? state.rendererInput
|
|
|
|
|
: {};
|
|
|
|
|
const rendererFiletreeSelection =
|
|
|
|
|
rendererInput.filetreeSelection && typeof rendererInput.filetreeSelection === "object"
|
|
|
|
|
? rendererInput.filetreeSelection
|
|
|
|
|
: {};
|
|
|
|
|
const filetreeSelectionReducer =
|
|
|
|
|
rendererInput.filetreeSelectionReducer &&
|
|
|
|
|
typeof rendererInput.filetreeSelectionReducer === "object"
|
|
|
|
|
? rendererInput.filetreeSelectionReducer
|
|
|
|
|
: {};
|
|
|
|
|
const pickerStateReducer =
|
|
|
|
|
rendererInput.pickerStateReducer &&
|
|
|
|
|
typeof rendererInput.pickerStateReducer === "object"
|
|
|
|
|
? rendererInput.pickerStateReducer
|
|
|
|
|
: {};
|
|
|
|
|
const pageFocusKeyboardReducer =
|
|
|
|
|
rendererInput.pageFocusKeyboardReducer &&
|
|
|
|
|
typeof rendererInput.pageFocusKeyboardReducer === "object"
|
|
|
|
|
? rendererInput.pageFocusKeyboardReducer
|
|
|
|
|
: {};
|
2026-04-28 16:30:51 +08:00
|
|
|
const runtimeArtifact =
|
|
|
|
|
rendererInput.runtimeArtifact && typeof rendererInput.runtimeArtifact === "object"
|
|
|
|
|
? rendererInput.runtimeArtifact
|
|
|
|
|
: {};
|
|
|
|
|
const runtimeApi =
|
|
|
|
|
runtimeArtifact.runtimeApi && typeof runtimeArtifact.runtimeApi === "object"
|
|
|
|
|
? runtimeArtifact.runtimeApi
|
|
|
|
|
: {};
|
2026-04-26 19:35:52 +08:00
|
|
|
const normalizeStringArray = (value) =>
|
|
|
|
|
Array.isArray(value)
|
|
|
|
|
? value
|
|
|
|
|
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
: [];
|
2026-04-24 06:10:18 +08:00
|
|
|
const hostOverride =
|
|
|
|
|
window.__MNOTE_TREE_SHELL_OVERRIDE__ &&
|
|
|
|
|
typeof window.__MNOTE_TREE_SHELL_OVERRIDE__ === "object"
|
|
|
|
|
? window.__MNOTE_TREE_SHELL_OVERRIDE__
|
|
|
|
|
: {};
|
2026-04-17 23:36:24 +08:00
|
|
|
const channel =
|
|
|
|
|
typeof state.channel === "string" && state.channel.trim()
|
|
|
|
|
? state.channel.trim()
|
|
|
|
|
: "mnote-tree-shell-v1";
|
|
|
|
|
const workspaceId =
|
|
|
|
|
typeof state.workspaceId === "string" && state.workspaceId.trim()
|
|
|
|
|
? state.workspaceId.trim()
|
|
|
|
|
: "";
|
2026-05-08 00:41:03 +08:00
|
|
|
const sourceKind =
|
|
|
|
|
typeof state.sourceKind === "string" && state.sourceKind.trim()
|
|
|
|
|
? state.sourceKind.trim()
|
|
|
|
|
: "convex_workspace";
|
|
|
|
|
const rootUri =
|
|
|
|
|
typeof state.rootUri === "string" && state.rootUri.trim()
|
|
|
|
|
? state.rootUri.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const initialLocalWatchRevision =
|
|
|
|
|
state.localWatchRevision &&
|
|
|
|
|
typeof state.localWatchRevision === "object" &&
|
|
|
|
|
typeof state.localWatchRevision.revision === "string"
|
|
|
|
|
? state.localWatchRevision.revision
|
|
|
|
|
: "";
|
2026-04-17 23:36:24 +08:00
|
|
|
const actorId =
|
|
|
|
|
typeof state.actorId === "string" && state.actorId.trim()
|
|
|
|
|
? state.actorId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const activeDocumentId =
|
|
|
|
|
typeof state.activeDocumentId === "string" && state.activeDocumentId.trim()
|
|
|
|
|
? state.activeDocumentId.trim()
|
|
|
|
|
: "";
|
2026-04-26 04:29:23 +08:00
|
|
|
const focusedDocumentId =
|
|
|
|
|
typeof state.focusedDocumentId === "string" && state.focusedDocumentId.trim()
|
|
|
|
|
? state.focusedDocumentId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const activePickerItemKey =
|
2026-04-26 19:35:52 +08:00
|
|
|
typeof rendererInput.activePickerItem === "string" && rendererInput.activePickerItem.trim()
|
|
|
|
|
? rendererInput.activePickerItem.trim()
|
|
|
|
|
: typeof state.activePickerItemKey === "string" && state.activePickerItemKey.trim()
|
|
|
|
|
? state.activePickerItemKey.trim()
|
|
|
|
|
: "";
|
2026-04-17 23:36:24 +08:00
|
|
|
const mode = (() => {
|
|
|
|
|
const rawMode =
|
|
|
|
|
typeof state.mode === "string" ? state.mode.trim() : "";
|
|
|
|
|
if (rawMode === "picker") return "picker";
|
|
|
|
|
if (rawMode === "filetree") return "filetree";
|
|
|
|
|
return "page";
|
|
|
|
|
})();
|
|
|
|
|
const allowRootPick = state.allowRootPick === true;
|
|
|
|
|
const excludedIds = new Set(
|
2026-04-26 19:35:52 +08:00
|
|
|
normalizeStringArray(rendererInput.excludedPickerIds).length > 0
|
|
|
|
|
? normalizeStringArray(rendererInput.excludedPickerIds)
|
|
|
|
|
: normalizeStringArray(state.excludeIds),
|
|
|
|
|
);
|
|
|
|
|
const pickerStateReducerContractName =
|
|
|
|
|
typeof pickerStateReducer.contractName === "string" &&
|
|
|
|
|
pickerStateReducer.contractName.trim()
|
|
|
|
|
? pickerStateReducer.contractName.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const pickerStateReducerActions = new Set(
|
|
|
|
|
normalizeStringArray(pickerStateReducer.actions),
|
|
|
|
|
);
|
|
|
|
|
const pageFocusKeyboardReducerContractName =
|
|
|
|
|
typeof pageFocusKeyboardReducer.contractName === "string" &&
|
|
|
|
|
pageFocusKeyboardReducer.contractName.trim()
|
|
|
|
|
? pageFocusKeyboardReducer.contractName.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const pageFocusKeyboardReducerActions = new Set(
|
|
|
|
|
normalizeStringArray(pageFocusKeyboardReducer.actions),
|
2026-04-17 23:36:24 +08:00
|
|
|
);
|
|
|
|
|
const commandPath =
|
|
|
|
|
typeof state.commandPath === "string" && state.commandPath.trim()
|
|
|
|
|
? state.commandPath.trim()
|
|
|
|
|
: "/api/tree/commands";
|
2026-05-16 07:11:06 +08:00
|
|
|
let mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
|
|
|
|
|
let mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
|
|
|
|
|
let tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : [];
|
2026-04-17 23:36:24 +08:00
|
|
|
const mindmapAssetChildren =
|
|
|
|
|
state.mindmapAssetChildren && typeof state.mindmapAssetChildren === "object"
|
|
|
|
|
? state.mindmapAssetChildren
|
|
|
|
|
: {};
|
|
|
|
|
const titleElement = document.getElementById("tree-shell-title");
|
|
|
|
|
const summaryElement = document.getElementById("tree-shell-summary");
|
|
|
|
|
const toolbarElement = document.getElementById("tree-shell-toolbar");
|
|
|
|
|
const targetOrigin = (() => {
|
|
|
|
|
try {
|
|
|
|
|
if (!document.referrer) return "*";
|
|
|
|
|
return new URL(document.referrer).origin || "*";
|
|
|
|
|
} catch {
|
|
|
|
|
return "*";
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const normalizeText = (value, fallback = "") => {
|
|
|
|
|
if (typeof value !== "string") return fallback;
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed || fallback;
|
|
|
|
|
};
|
2026-05-08 00:41:03 +08:00
|
|
|
const initialRenameRowId = (() => {
|
|
|
|
|
try {
|
|
|
|
|
return normalizeText(new URL(window.location.href).searchParams.get("renameRowId"));
|
|
|
|
|
} catch {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
})();
|
2026-04-28 16:30:51 +08:00
|
|
|
const runtimeReduceEndpoint = normalizeText(
|
|
|
|
|
runtimeApi.reduceEndpoint,
|
|
|
|
|
"/api/tree/runtime/reduce",
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
const normalizeParent = (value) => {
|
|
|
|
|
const normalized = normalizeText(value);
|
|
|
|
|
return normalized || null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeNumber = (value, fallback = Number.MAX_SAFE_INTEGER) => {
|
|
|
|
|
return Number.isFinite(value) ? Number(value) : fallback;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const normalizeRowKind = (value) => {
|
|
|
|
|
const normalized = normalizeText(value).toLowerCase();
|
|
|
|
|
if (normalized === "index") return "index";
|
|
|
|
|
if (normalized === "asset") return "asset";
|
2026-05-08 00:41:03 +08:00
|
|
|
if (normalized === "folder") return "folder";
|
|
|
|
|
if (normalized === "markdown") return "markdown";
|
2026-04-24 06:10:18 +08:00
|
|
|
if (normalized === "asset_folder") return "asset_folder";
|
|
|
|
|
return "document";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeCapabilities = (value) =>
|
|
|
|
|
Array.isArray(value)
|
|
|
|
|
? value
|
|
|
|
|
.map((item) => normalizeText(item))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const normalizeResourceMeta = (value) => {
|
|
|
|
|
if (!value || typeof value !== "object") {
|
|
|
|
|
return {
|
|
|
|
|
resourceKind: "",
|
|
|
|
|
documentId: "",
|
|
|
|
|
assetId: "",
|
|
|
|
|
assetKind: "",
|
2026-05-13 22:43:16 +08:00
|
|
|
objectIdentity: null,
|
|
|
|
|
blockAssetRelation: null,
|
2026-04-24 06:10:18 +08:00
|
|
|
};
|
|
|
|
|
}
|
2026-05-13 22:43:16 +08:00
|
|
|
const objectIdentity =
|
|
|
|
|
value?.objectIdentity && typeof value.objectIdentity === "object"
|
|
|
|
|
? value.objectIdentity
|
|
|
|
|
: null;
|
|
|
|
|
const blockAssetRelation =
|
|
|
|
|
value?.blockAssetRelation && typeof value.blockAssetRelation === "object"
|
|
|
|
|
? value.blockAssetRelation
|
|
|
|
|
: null;
|
2026-04-24 06:10:18 +08:00
|
|
|
return {
|
|
|
|
|
resourceKind: normalizeText(value?.resourceKind),
|
|
|
|
|
documentId: normalizeText(value?.documentId),
|
|
|
|
|
assetId: normalizeText(value?.assetId),
|
|
|
|
|
assetKind: normalizeText(value?.assetKind),
|
2026-05-13 22:43:16 +08:00
|
|
|
objectIdentity,
|
|
|
|
|
blockAssetRelation,
|
2026-04-24 06:10:18 +08:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const compareItems = (left, right) => {
|
|
|
|
|
const byPosition = left.position - right.position;
|
|
|
|
|
if (byPosition !== 0) return byPosition;
|
|
|
|
|
return left.title.localeCompare(right.title, "zh-CN");
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
const normalizeTreeItems = (items) =>
|
|
|
|
|
Array.isArray(items)
|
|
|
|
|
? items
|
|
|
|
|
.map((item) => {
|
|
|
|
|
const nodeId = normalizeText(item?.nodeId);
|
|
|
|
|
const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
|
|
|
|
|
const rowKind = normalizeRowKind(item?.rowKind);
|
|
|
|
|
const fallbackRowId =
|
|
|
|
|
rowKind === "index"
|
|
|
|
|
? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
|
|
|
|
|
: rowKind === "asset"
|
|
|
|
|
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
|
|
|
|
|
: rowKind === "asset_folder"
|
|
|
|
|
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
|
|
|
|
|
: `doc:${resourceMeta.documentId || nodeId}`;
|
|
|
|
|
return {
|
|
|
|
|
rowId: normalizeText(item?.rowId, fallbackRowId),
|
|
|
|
|
rowKind,
|
|
|
|
|
nodeId,
|
|
|
|
|
parentNodeId: normalizeParent(item?.parentNodeId),
|
|
|
|
|
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
|
|
|
|
|
depth: normalizeNumber(item?.depth, 0),
|
|
|
|
|
childCount: normalizeNumber(item?.childCount, 0),
|
|
|
|
|
position: normalizeNumber(item?.position),
|
|
|
|
|
expandedByDefault: item?.expandedByDefault !== false,
|
|
|
|
|
iconHint: normalizeText(item?.iconHint),
|
|
|
|
|
capabilities: normalizeCapabilities(item?.capabilities),
|
|
|
|
|
resourceMeta,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
|
|
|
|
|
: [];
|
2026-04-17 23:36:24 +08:00
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
let rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
|
|
|
|
|
let normalizedItems = normalizeTreeItems(rawItems);
|
|
|
|
|
let itemById = new Map();
|
|
|
|
|
let fileTreeRowById = new Map();
|
|
|
|
|
let childrenByParentId = new Map();
|
|
|
|
|
let roots = [];
|
|
|
|
|
let assetsByDocId = new Map();
|
|
|
|
|
|
|
|
|
|
const rebuildTreeIndexes = () => {
|
|
|
|
|
itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
|
|
|
|
|
fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
|
|
|
|
|
childrenByParentId = new Map();
|
|
|
|
|
roots = [];
|
|
|
|
|
assetsByDocId = new Map();
|
|
|
|
|
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
|
|
|
|
|
const documentId = normalizeText(asset?.document_id);
|
|
|
|
|
const assetId = normalizeText(asset?.id);
|
|
|
|
|
if (!documentId || !assetId) return;
|
|
|
|
|
const bucket = assetsByDocId.get(documentId) || [];
|
|
|
|
|
bucket.push({
|
|
|
|
|
id: assetId,
|
|
|
|
|
documentId,
|
|
|
|
|
assetType: normalizeText(asset?.asset_type, "file"),
|
|
|
|
|
fileName: normalizeText(asset?.file_name, "附件"),
|
|
|
|
|
storagePath: normalizeText(asset?.storage_path),
|
|
|
|
|
});
|
|
|
|
|
assetsByDocId.set(documentId, bucket);
|
|
|
|
|
});
|
|
|
|
|
normalizedItems.forEach((item) => {
|
|
|
|
|
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
|
|
|
|
|
if (!parentId) {
|
|
|
|
|
roots.push(item);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const bucket = childrenByParentId.get(parentId) || [];
|
|
|
|
|
bucket.push(item);
|
|
|
|
|
childrenByParentId.set(parentId, bucket);
|
|
|
|
|
});
|
|
|
|
|
roots.sort(compareItems);
|
|
|
|
|
childrenByParentId.forEach((bucket) => bucket.sort(compareItems));
|
|
|
|
|
};
|
|
|
|
|
rebuildTreeIndexes();
|
2026-04-17 23:36:24 +08:00
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);
|
2026-04-17 23:36:24 +08:00
|
|
|
const expanded = new Set(
|
2026-04-26 19:35:52 +08:00
|
|
|
rendererExpandedIds.length > 0
|
|
|
|
|
? rendererExpandedIds
|
|
|
|
|
: normalizedItems
|
|
|
|
|
.filter((item) => item.childCount > 0 && item.expandedByDefault)
|
|
|
|
|
.map((item) => item.nodeId),
|
2026-04-17 23:36:24 +08:00
|
|
|
);
|
2026-04-26 04:29:23 +08:00
|
|
|
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();
|
2026-04-26 19:35:52 +08:00
|
|
|
const rendererSelectedFileTreeRowIds = normalizeStringArray(
|
|
|
|
|
rendererFiletreeSelection.selectedRowIds,
|
2026-04-26 04:29:23 +08:00
|
|
|
);
|
2026-04-26 19:35:52 +08:00
|
|
|
const rendererAnchorRowId =
|
|
|
|
|
typeof rendererFiletreeSelection.anchorRowId === "string" &&
|
|
|
|
|
rendererFiletreeSelection.anchorRowId.trim()
|
|
|
|
|
? rendererFiletreeSelection.anchorRowId.trim()
|
|
|
|
|
: null;
|
|
|
|
|
const rendererFocusedRowId =
|
|
|
|
|
typeof rendererFiletreeSelection.focusedRowId === "string" &&
|
|
|
|
|
rendererFiletreeSelection.focusedRowId.trim()
|
|
|
|
|
? rendererFiletreeSelection.focusedRowId.trim()
|
|
|
|
|
: null;
|
|
|
|
|
const filetreeSelectionReducerContractName =
|
|
|
|
|
typeof filetreeSelectionReducer.contractName === "string" &&
|
|
|
|
|
filetreeSelectionReducer.contractName.trim()
|
|
|
|
|
? filetreeSelectionReducer.contractName.trim()
|
|
|
|
|
: "";
|
|
|
|
|
const filetreeSelectionReducerActions = new Set(
|
|
|
|
|
normalizeStringArray(filetreeSelectionReducer.actions),
|
|
|
|
|
);
|
|
|
|
|
let selectedFileTreeRowIds = new Set(
|
|
|
|
|
rendererSelectedFileTreeRowIds.length > 0
|
|
|
|
|
? rendererSelectedFileTreeRowIds
|
2026-05-16 07:11:06 +08:00
|
|
|
: currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`] : []
|
2026-04-26 19:35:52 +08:00
|
|
|
);
|
2026-05-16 07:11:06 +08:00
|
|
|
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
|
|
|
|
|
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
|
2026-04-24 06:10:18 +08:00
|
|
|
let visibleFileTreeRowIds = [];
|
2026-04-26 04:29:23 +08:00
|
|
|
let draggingPageNodeId = "";
|
|
|
|
|
let activePageDropNodeId = null;
|
2026-04-24 06:10:18 +08:00
|
|
|
let draggingFileTreeRowIds = [];
|
|
|
|
|
let activeFileTreeDropRowId = null;
|
2026-05-08 00:41:03 +08:00
|
|
|
let activeFileTreeDropPosition = null;
|
2026-04-24 06:10:18 +08:00
|
|
|
let activeFileTreeRootDrop = false;
|
2026-05-08 00:41:03 +08:00
|
|
|
let fileTreeHoverExpandTimer = 0;
|
|
|
|
|
let fileTreeClipboard = { action: null, rowIds: [] };
|
|
|
|
|
let inlineRenameState = { mode: null, id: null, committing: false };
|
|
|
|
|
let activeFileTreeMenuElement = null;
|
|
|
|
|
let activeFileTreePreflightElement = null;
|
2026-04-17 23:36:24 +08:00
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
2026-04-17 23:36:24 +08:00
|
|
|
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
|
|
|
|
expanded.add(activeCursor.parentNodeId);
|
|
|
|
|
activeCursor = itemById.get(activeCursor.parentNodeId) || null;
|
|
|
|
|
}
|
|
|
|
|
assetsByDocId.forEach((_, documentId) => {
|
|
|
|
|
if (itemById.has(documentId)) {
|
|
|
|
|
expanded.add(documentId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (titleElement) {
|
|
|
|
|
titleElement.textContent =
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "页面选择"
|
|
|
|
|
: mode === "filetree"
|
|
|
|
|
? "资源管理器"
|
|
|
|
|
: "页面树";
|
|
|
|
|
}
|
|
|
|
|
if (summaryElement) {
|
|
|
|
|
summaryElement.textContent =
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "这个页面复用统一 projection 协议,以轻量选择器模式承载 move / embed picker。当前阶段只负责树浏览与目标选择,不承接命令写链。"
|
|
|
|
|
: mode === "filetree"
|
|
|
|
|
? "这个页面以文件树模式消费 Rust 侧 projection,并承载页面、index 与附件浏览。当前阶段仍是过渡验证壳,但 filetree 协议与渲染分支必须保持闭环。"
|
|
|
|
|
: "这个页面直接消费 Rust 侧 projection,并通过统一 command route 回写树操作。当前阶段先交付最小可交互壳,用于主 Sidebar 页面树切流与真实网页验证。";
|
|
|
|
|
}
|
|
|
|
|
if (toolbarElement && mode === "picker") {
|
|
|
|
|
toolbarElement.style.display = "none";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let busy = false;
|
|
|
|
|
|
|
|
|
|
const setBusy = (nextBusy) => {
|
|
|
|
|
busy = nextBusy;
|
|
|
|
|
createRootButton.disabled = nextBusy;
|
|
|
|
|
appElement.querySelectorAll("button").forEach((button) => {
|
|
|
|
|
button.disabled = nextBusy;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setStatus = (message, tone = "normal") => {
|
|
|
|
|
statusElement.textContent = message;
|
|
|
|
|
statusElement.dataset.tone = tone;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setLastAction = (message, tone = "normal") => {
|
|
|
|
|
lastActionElement.textContent = message;
|
|
|
|
|
lastActionElement.dataset.tone = tone;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusRowElement = (nodeId) => {
|
|
|
|
|
if (!nodeId) return;
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
const row = appElement.querySelector(`.tree-row[data-node-id="${nodeId}"]`);
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
row.focus({ preventScroll: true });
|
|
|
|
|
row.scrollIntoView({ block: "nearest" });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const postToHost = (type, extra = {}) => {
|
|
|
|
|
if (window.parent === window) return;
|
|
|
|
|
const payload = Object.assign({ channel, type }, extra);
|
|
|
|
|
window.parent.postMessage(payload, targetOrigin);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids";
|
|
|
|
|
|
|
|
|
|
const getFileTreeRowDocumentId = (item) => {
|
|
|
|
|
if (!item) return "";
|
2026-05-08 00:41:03 +08:00
|
|
|
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
|
2026-04-24 06:10:18 +08:00
|
|
|
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
|
|
|
|
return "";
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 17:01:35 +08:00
|
|
|
const getFileTreeRowOwnerDocumentId = (item) => {
|
|
|
|
|
if (!item) return "";
|
|
|
|
|
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
|
|
|
|
return getFileTreeRowDocumentId(item);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const getFileTreeRowAssetId = (item) => {
|
|
|
|
|
if (!item) return "";
|
|
|
|
|
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
|
|
|
|
if (item.rowKind === "asset") return item.nodeId.replace(/^asset:/, "");
|
|
|
|
|
if (item.rowKind === "asset_folder") return item.nodeId.replace(/^asset-folder:/, "");
|
|
|
|
|
return "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFileTreeRowIconKind = (item) => {
|
|
|
|
|
if (!item) return "file";
|
|
|
|
|
const iconHint = normalizeText(item.iconHint).toLowerCase();
|
|
|
|
|
if (iconHint === "mindmap") return "mindmap";
|
|
|
|
|
if (iconHint === "table") return "table";
|
|
|
|
|
if (iconHint === "index") return "index";
|
|
|
|
|
if (iconHint === "page") return "page";
|
|
|
|
|
if (item.rowKind === "document") return "page";
|
2026-05-08 00:41:03 +08:00
|
|
|
if (item.rowKind === "folder") return "folder";
|
|
|
|
|
if (item.rowKind === "markdown") return "file";
|
2026-04-24 06:10:18 +08:00
|
|
|
if (item.rowKind === "index") return "index";
|
|
|
|
|
if (item.rowKind === "asset_folder") return "mindmap";
|
|
|
|
|
if (item.resourceMeta?.resourceKind === "table") return "table";
|
|
|
|
|
if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap";
|
|
|
|
|
return "file";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFileTreeRowMetaLabel = (item) => {
|
|
|
|
|
if (!item) return "";
|
|
|
|
|
if (item.rowKind === "document") {
|
|
|
|
|
return `${getFileTreeRowDocumentId(item)} · 页面`;
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
if (item.rowKind === "folder") {
|
|
|
|
|
return "本地文件夹";
|
|
|
|
|
}
|
|
|
|
|
if (item.rowKind === "markdown") {
|
|
|
|
|
return `${getFileTreeRowDocumentId(item)} · Markdown`;
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
if (item.rowKind === "index") {
|
|
|
|
|
return "页面正文";
|
|
|
|
|
}
|
|
|
|
|
if (item.rowKind === "asset_folder") {
|
|
|
|
|
return `${item.resourceMeta?.resourceKind || "mindmap"} · 资源目录`;
|
|
|
|
|
}
|
|
|
|
|
return `${item.resourceMeta?.resourceKind || item.resourceMeta?.assetKind || "asset"} · ${getFileTreeRowAssetId(item)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const canExpandFileTreeRow = (item) => {
|
|
|
|
|
if (!item) return false;
|
|
|
|
|
return item.childCount > 0 || item.capabilities.includes("expand");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFileTreeDropTargetFromElement = (element) => {
|
|
|
|
|
const row = element instanceof Element
|
|
|
|
|
? element.closest('.tree-row[data-shell-mode="filetree"]')
|
|
|
|
|
: null;
|
|
|
|
|
if (!(row instanceof HTMLElement)) {
|
|
|
|
|
return {
|
|
|
|
|
rowId: null,
|
|
|
|
|
rowKind: "root",
|
2026-05-08 00:41:03 +08:00
|
|
|
nodeId: null,
|
2026-04-24 06:10:18 +08:00
|
|
|
documentId: null,
|
2026-05-18 17:01:35 +08:00
|
|
|
ownerDocumentId: null,
|
2026-04-24 06:10:18 +08:00
|
|
|
assetId: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
rowId: normalizeText(row.dataset.rowId) || null,
|
|
|
|
|
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
2026-05-08 00:41:03 +08:00
|
|
|
nodeId: normalizeText(row.dataset.nodeId) || null,
|
2026-04-24 06:10:18 +08:00
|
|
|
documentId: normalizeText(row.dataset.documentId) || null,
|
2026-05-18 17:01:35 +08:00
|
|
|
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
|
2026-04-24 06:10:18 +08:00
|
|
|
assetId: normalizeText(row.dataset.assetId) || null,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
const getFileTreeDropTargetFromEvent = (event) => {
|
|
|
|
|
const target = getFileTreeDropTargetFromElement(event.target);
|
|
|
|
|
if (!target.rowId) return { ...target, dropPosition: "inside" };
|
|
|
|
|
const row = event.target instanceof Element
|
|
|
|
|
? event.target.closest('.tree-row[data-shell-mode="filetree"]')
|
|
|
|
|
: null;
|
|
|
|
|
if (!(row instanceof HTMLElement)) return { ...target, dropPosition: "inside" };
|
|
|
|
|
const rect = row.getBoundingClientRect();
|
|
|
|
|
const offset = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
|
|
|
|
const item = fileTreeRowById.get(target.rowId);
|
|
|
|
|
if (offset < 0.25) return { ...target, dropPosition: "before" };
|
|
|
|
|
if (offset > 0.75) return { ...target, dropPosition: "after" };
|
|
|
|
|
return {
|
|
|
|
|
...target,
|
|
|
|
|
dropPosition: item && canExpandFileTreeRow(item) ? "inside" : "after",
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveLocalFileTreeParentId = (target) => {
|
|
|
|
|
if (!target || target.rowKind === "root") return null;
|
|
|
|
|
const targetItem = target.rowId ? fileTreeRowById.get(target.rowId) : null;
|
|
|
|
|
if (targetItem && targetItem.rowKind === "folder") {
|
|
|
|
|
return targetItem.nodeId;
|
|
|
|
|
}
|
|
|
|
|
if (targetItem && targetItem.parentNodeId) {
|
|
|
|
|
return targetItem.parentNodeId;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isFileTreeDescendantOf = (candidate, ancestor) => {
|
|
|
|
|
let cursor = candidate;
|
|
|
|
|
while (cursor && cursor.parentNodeId) {
|
|
|
|
|
if (cursor.parentNodeId === ancestor.nodeId) return true;
|
|
|
|
|
cursor = itemById.get(cursor.parentNodeId) || null;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validateFileTreeInternalDrop = (target, rowIds, copy) => {
|
|
|
|
|
const sourceRowIds = Array.isArray(rowIds) ? rowIds.filter(Boolean) : [];
|
|
|
|
|
if (sourceRowIds.length === 0) return { ok: false, reason: "empty" };
|
|
|
|
|
const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null;
|
|
|
|
|
if (targetItem && targetItem.capabilities.some((capability) =>
|
|
|
|
|
capability === "readonly" || capability === "readOnly" || capability === "permissionDenied"
|
|
|
|
|
)) {
|
|
|
|
|
return { ok: false, reason: "readonly" };
|
|
|
|
|
}
|
|
|
|
|
for (const rowId of sourceRowIds) {
|
|
|
|
|
const sourceItem = fileTreeRowById.get(rowId);
|
|
|
|
|
if (!sourceItem) return { ok: false, reason: "cross_workspace" };
|
|
|
|
|
if (targetItem && targetItem.rowId === sourceItem.rowId) {
|
|
|
|
|
return { ok: false, reason: "self" };
|
|
|
|
|
}
|
|
|
|
|
if (targetItem && sourceItem.rowKind === "folder" && isFileTreeDescendantOf(targetItem, sourceItem)) {
|
|
|
|
|
return { ok: false, reason: "descendant" };
|
|
|
|
|
}
|
|
|
|
|
if (copy && sourceItem.rowKind === "folder") {
|
|
|
|
|
return { ok: false, reason: "copy_folder_unsupported" };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, reason: "" };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const filterRedundantFileTreeRowIds = (rowIds) => {
|
|
|
|
|
const selected = new Set(Array.isArray(rowIds) ? rowIds.filter(Boolean) : []);
|
|
|
|
|
return Array.from(selected).filter((rowId) => {
|
|
|
|
|
const item = fileTreeRowById.get(rowId);
|
|
|
|
|
if (!item) return false;
|
|
|
|
|
let cursor = item;
|
|
|
|
|
while (cursor && cursor.parentNodeId) {
|
|
|
|
|
const parent = itemById.get(cursor.parentNodeId) || null;
|
|
|
|
|
if (parent && selected.has(parent.rowId)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
cursor = parent;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validateFileTreeWritableTarget = (target) => {
|
|
|
|
|
const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null;
|
|
|
|
|
if (
|
|
|
|
|
targetItem &&
|
|
|
|
|
targetItem.capabilities.some((capability) =>
|
|
|
|
|
capability === "readonly" ||
|
|
|
|
|
capability === "readOnly" ||
|
|
|
|
|
capability === "permissionDenied"
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return { ok: false, reason: "readonly", targetItem };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, reason: "", targetItem };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFileTreeRowLabel = (item) =>
|
|
|
|
|
item?.title || item?.rowId || item?.nodeId || "选中项";
|
|
|
|
|
|
|
|
|
|
const getFileTreeTargetLabel = (target) => {
|
|
|
|
|
if (!target || target.rowKind === "root" || !target.rowId) return "Explorer 根目录";
|
|
|
|
|
const item = fileTreeRowById.get(target.rowId);
|
|
|
|
|
if (!item) return target.rowId;
|
|
|
|
|
if (item.rowKind === "markdown" || item.rowKind === "document" || item.rowKind === "index") {
|
|
|
|
|
return `${item.title} 的父目录`;
|
|
|
|
|
}
|
|
|
|
|
return item.title;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const showFileTreePreflight = (preflight) =>
|
|
|
|
|
new Promise((resolve) => {
|
|
|
|
|
if (activeFileTreePreflightElement) {
|
|
|
|
|
activeFileTreePreflightElement.remove();
|
|
|
|
|
activeFileTreePreflightElement = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const backdrop = document.createElement("div");
|
|
|
|
|
backdrop.className = "tree-preflight-backdrop";
|
|
|
|
|
backdrop.dataset.testid = "tree-preflight";
|
|
|
|
|
backdrop.dataset.preflightKind = preflight.kind || "";
|
|
|
|
|
const dialog = document.createElement("div");
|
|
|
|
|
dialog.className = "tree-preflight-dialog";
|
|
|
|
|
dialog.setAttribute("role", "dialog");
|
|
|
|
|
dialog.setAttribute("aria-modal", "true");
|
|
|
|
|
const body = document.createElement("div");
|
|
|
|
|
body.className = "tree-preflight-body";
|
|
|
|
|
const title = document.createElement("h2");
|
|
|
|
|
title.className = "tree-preflight-title";
|
|
|
|
|
title.textContent = preflight.title || "操作预检";
|
|
|
|
|
body.appendChild(title);
|
|
|
|
|
|
|
|
|
|
const list = document.createElement("ul");
|
|
|
|
|
list.className = "tree-preflight-list";
|
|
|
|
|
(preflight.lines || []).forEach((line) => {
|
|
|
|
|
const item = document.createElement("li");
|
|
|
|
|
item.textContent = line;
|
|
|
|
|
list.appendChild(item);
|
|
|
|
|
});
|
|
|
|
|
body.appendChild(list);
|
|
|
|
|
dialog.appendChild(body);
|
|
|
|
|
|
|
|
|
|
const actions = document.createElement("div");
|
|
|
|
|
actions.className = "tree-preflight-actions";
|
|
|
|
|
const cancel = document.createElement("button");
|
|
|
|
|
cancel.type = "button";
|
|
|
|
|
cancel.dataset.role = "cancel";
|
|
|
|
|
cancel.textContent = "取消";
|
|
|
|
|
const confirm = document.createElement("button");
|
|
|
|
|
confirm.type = "button";
|
|
|
|
|
confirm.dataset.role = "confirm";
|
|
|
|
|
confirm.textContent = preflight.confirmLabel || "继续";
|
|
|
|
|
actions.appendChild(cancel);
|
|
|
|
|
actions.appendChild(confirm);
|
|
|
|
|
dialog.appendChild(actions);
|
|
|
|
|
backdrop.appendChild(dialog);
|
|
|
|
|
|
|
|
|
|
const finish = (accepted) => {
|
|
|
|
|
backdrop.remove();
|
|
|
|
|
activeFileTreePreflightElement = null;
|
|
|
|
|
document.removeEventListener("keydown", onKeyDown, true);
|
|
|
|
|
resolve(accepted);
|
|
|
|
|
};
|
|
|
|
|
const onKeyDown = (event) => {
|
|
|
|
|
if (event.key === "Escape") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
finish(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
cancel.addEventListener("click", () => finish(false));
|
|
|
|
|
confirm.addEventListener("click", () => finish(true));
|
|
|
|
|
document.addEventListener("keydown", onKeyDown, true);
|
|
|
|
|
document.body.appendChild(backdrop);
|
|
|
|
|
activeFileTreePreflightElement = backdrop;
|
|
|
|
|
cancel.focus({ preventScroll: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const runFileTreePreflight = async (kind, context = {}) => {
|
|
|
|
|
const sourceRowIds = Array.isArray(context.rowIds) ? context.rowIds.filter(Boolean) : [];
|
|
|
|
|
const sourceItems = sourceRowIds
|
|
|
|
|
.map((rowId) => fileTreeRowById.get(rowId))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
const targetLabel = getFileTreeTargetLabel(context.target);
|
|
|
|
|
const sourceLabel =
|
|
|
|
|
sourceItems.length === 0
|
|
|
|
|
? "外部文件"
|
|
|
|
|
: sourceItems.map(getFileTreeRowLabel).join("、");
|
|
|
|
|
const fileNames = Array.isArray(context.files)
|
|
|
|
|
? context.files.map((file) => file.name || "未命名文件").filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
if (context.validation && context.validation.ok === false) {
|
|
|
|
|
if (context.validation.reason === "readonly") {
|
|
|
|
|
await showFileTreePreflight({
|
|
|
|
|
kind: "readonly",
|
|
|
|
|
title: "操作预检失败",
|
|
|
|
|
confirmLabel: "知道了",
|
|
|
|
|
lines: [
|
|
|
|
|
"目标: readonly",
|
|
|
|
|
"原因: 当前目标为只读或权限不足。",
|
|
|
|
|
"结果: 不会发送 execute。",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`预检拒绝: ${context.validation.reason}`, "error");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const writableTarget = validateFileTreeWritableTarget(context.target);
|
|
|
|
|
if (!writableTarget.ok) {
|
|
|
|
|
await showFileTreePreflight({
|
|
|
|
|
kind: "readonly",
|
|
|
|
|
title: "操作预检失败",
|
|
|
|
|
confirmLabel: "知道了",
|
|
|
|
|
lines: [
|
|
|
|
|
`目标: ${getFileTreeRowLabel(writableTarget.targetItem)}`,
|
|
|
|
|
"原因: 当前目标为只读或权限不足。",
|
|
|
|
|
"结果: 不会发送 execute。",
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const preflight = (() => {
|
|
|
|
|
if (kind === "delete") {
|
|
|
|
|
return {
|
|
|
|
|
kind,
|
|
|
|
|
title: "删除预检",
|
|
|
|
|
confirmLabel: "删除",
|
|
|
|
|
lines: [
|
|
|
|
|
`目标: ${sourceLabel}`,
|
|
|
|
|
`影响: ${Math.max(1, sourceItems.length)} 个文件树节点`,
|
|
|
|
|
sourceKind === "local_folder"
|
|
|
|
|
? "删除后进入本地 .mnote/trash。"
|
|
|
|
|
: "删除将进入云端回收站。",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (kind === "paste") {
|
|
|
|
|
return {
|
|
|
|
|
kind,
|
|
|
|
|
title: context.copy ? "粘贴复制预检" : "粘贴移动预检",
|
|
|
|
|
confirmLabel: "粘贴",
|
|
|
|
|
lines: [
|
|
|
|
|
`来源: ${sourceLabel}`,
|
|
|
|
|
`目标: ${targetLabel}`,
|
|
|
|
|
"命名冲突策略: 使用递增命名,不静默覆盖。",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (kind === "dropFiles") {
|
|
|
|
|
return {
|
|
|
|
|
kind,
|
|
|
|
|
title: "外部拖入预检",
|
|
|
|
|
confirmLabel: sourceKind === "local_folder" ? "拖入" : "发送",
|
|
|
|
|
lines: [
|
|
|
|
|
`文件: ${fileNames.join("、") || "外部文件"}`,
|
|
|
|
|
`目标: ${targetLabel}`,
|
|
|
|
|
sourceKind === "local_folder"
|
|
|
|
|
? "目标可写时复制进本地文件夹;命名冲突使用递增命名。"
|
|
|
|
|
: "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
kind: context.copy ? "copy" : "move",
|
|
|
|
|
title: context.copy ? "复制预检" : "移动预检",
|
|
|
|
|
confirmLabel: context.copy ? "复制" : "移动",
|
|
|
|
|
lines: [
|
|
|
|
|
`来源: ${sourceLabel}`,
|
|
|
|
|
`目标: ${targetLabel}`,
|
|
|
|
|
context.copy
|
|
|
|
|
? "命名冲突策略: 使用递增命名,不静默覆盖。"
|
|
|
|
|
: "会检查自拖自身、后代目标和跨 workspace 来源。",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return await showFileTreePreflight(preflight);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const executeLocalFileTreeInternalDrop = async (target, rowIds, copy, trigger = "drop") => {
|
|
|
|
|
const validation = validateFileTreeInternalDrop(target, rowIds, copy);
|
|
|
|
|
if (!validation.ok) {
|
|
|
|
|
setLastAction(`已拒绝非法拖拽: ${validation.reason}`, "error");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const accepted = await runFileTreePreflight(trigger === "paste" ? "paste" : "move", {
|
|
|
|
|
target,
|
|
|
|
|
rowIds,
|
|
|
|
|
copy,
|
|
|
|
|
validation,
|
|
|
|
|
});
|
|
|
|
|
if (!accepted) return false;
|
|
|
|
|
const parentId = resolveLocalFileTreeParentId(target);
|
|
|
|
|
const sourceRowIds = filterRedundantFileTreeRowIds(rowIds);
|
|
|
|
|
if (sourceRowIds.length === 0) return false;
|
|
|
|
|
for (const rowId of sourceRowIds) {
|
|
|
|
|
const sourceItem = fileTreeRowById.get(rowId);
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(sourceItem) || sourceItem?.nodeId || "";
|
|
|
|
|
if (!documentId) continue;
|
|
|
|
|
await sendCommand({
|
|
|
|
|
action: copy ? "copy" : "move",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId,
|
|
|
|
|
parentId,
|
|
|
|
|
sortOrder: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
setStatus(copy ? "复制成功" : "移动成功");
|
|
|
|
|
setLastAction(copy ? "本地文件树复制完成" : "本地文件树移动完成");
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const executeLocalFileTreeExternalDrop = async (target, files) => {
|
|
|
|
|
const droppedFiles = Array.from(files || []);
|
|
|
|
|
if (droppedFiles.length === 0) return false;
|
|
|
|
|
const accepted = await runFileTreePreflight("dropFiles", {
|
|
|
|
|
target,
|
|
|
|
|
files: droppedFiles,
|
|
|
|
|
});
|
|
|
|
|
if (!accepted) return false;
|
|
|
|
|
const parentId = resolveLocalFileTreeParentId(target);
|
|
|
|
|
const payload = await Promise.all(
|
|
|
|
|
droppedFiles.map(async (file) => ({
|
|
|
|
|
name: file.name || "dropped-file",
|
|
|
|
|
text: await file.text(),
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
await sendCommand({
|
|
|
|
|
action: "dropFiles",
|
|
|
|
|
workspaceId,
|
|
|
|
|
parentId,
|
|
|
|
|
content: payload,
|
|
|
|
|
});
|
|
|
|
|
setStatus("外部文件拖入成功");
|
|
|
|
|
setLastAction(`已拖入 ${payload.length} 个本地文件`);
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const clearFileTreeDropFeedback = () => {
|
2026-05-08 00:41:03 +08:00
|
|
|
if (fileTreeHoverExpandTimer) {
|
|
|
|
|
window.clearTimeout(fileTreeHoverExpandTimer);
|
|
|
|
|
fileTreeHoverExpandTimer = 0;
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
if (activeFileTreeDropRowId) {
|
|
|
|
|
const previousRow = appElement.querySelector(
|
|
|
|
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (previousRow instanceof HTMLElement) {
|
|
|
|
|
previousRow.dataset.dropTarget = "false";
|
2026-05-08 00:41:03 +08:00
|
|
|
delete previousRow.dataset.dropPosition;
|
2026-04-24 06:10:18 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
activeFileTreeDropRowId = null;
|
2026-05-08 00:41:03 +08:00
|
|
|
activeFileTreeDropPosition = null;
|
2026-04-24 06:10:18 +08:00
|
|
|
activeFileTreeRootDrop = false;
|
|
|
|
|
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
|
|
|
|
|
if (element instanceof HTMLElement) {
|
|
|
|
|
element.dataset.dropTarget = "false";
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setFileTreeDropFeedback = (target) => {
|
|
|
|
|
const nextRowId = target?.rowId || null;
|
2026-05-08 00:41:03 +08:00
|
|
|
const nextDropPosition = target?.dropPosition || "inside";
|
2026-04-24 06:10:18 +08:00
|
|
|
if (activeFileTreeDropRowId && activeFileTreeDropRowId !== nextRowId) {
|
|
|
|
|
const previousRow = appElement.querySelector(
|
|
|
|
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (previousRow instanceof HTMLElement) {
|
|
|
|
|
previousRow.dataset.dropTarget = "false";
|
2026-05-08 00:41:03 +08:00
|
|
|
delete previousRow.dataset.dropPosition;
|
2026-04-24 06:10:18 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (nextRowId) {
|
|
|
|
|
const nextRow = appElement.querySelector(
|
|
|
|
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${nextRowId}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (nextRow instanceof HTMLElement) {
|
|
|
|
|
nextRow.dataset.dropTarget = "true";
|
2026-05-08 00:41:03 +08:00
|
|
|
nextRow.dataset.dropPosition = nextDropPosition;
|
2026-04-24 06:10:18 +08:00
|
|
|
}
|
|
|
|
|
activeFileTreeDropRowId = nextRowId;
|
2026-05-08 00:41:03 +08:00
|
|
|
activeFileTreeDropPosition = nextDropPosition;
|
2026-04-24 06:10:18 +08:00
|
|
|
activeFileTreeRootDrop = false;
|
2026-05-08 00:41:03 +08:00
|
|
|
if (fileTreeHoverExpandTimer) {
|
|
|
|
|
window.clearTimeout(fileTreeHoverExpandTimer);
|
|
|
|
|
fileTreeHoverExpandTimer = 0;
|
|
|
|
|
}
|
|
|
|
|
const targetItem = fileTreeRowById.get(nextRowId);
|
|
|
|
|
if (
|
|
|
|
|
nextDropPosition === "inside" &&
|
|
|
|
|
targetItem &&
|
|
|
|
|
canExpandFileTreeRow(targetItem) &&
|
|
|
|
|
!expanded.has(targetItem.nodeId)
|
|
|
|
|
) {
|
|
|
|
|
fileTreeHoverExpandTimer = window.setTimeout(() => {
|
|
|
|
|
expanded.add(targetItem.nodeId);
|
|
|
|
|
fileTreeHoverExpandTimer = 0;
|
|
|
|
|
renderTree();
|
|
|
|
|
}, 650);
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
|
|
|
|
|
if (element instanceof HTMLElement) {
|
|
|
|
|
element.dataset.dropTarget = "false";
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
activeFileTreeDropRowId = null;
|
2026-05-08 00:41:03 +08:00
|
|
|
activeFileTreeDropPosition = null;
|
2026-04-24 06:10:18 +08:00
|
|
|
const root = appElement.querySelector(".tree-root");
|
|
|
|
|
if (root instanceof HTMLElement) {
|
|
|
|
|
root.dataset.dropTarget = "true";
|
|
|
|
|
}
|
|
|
|
|
activeFileTreeRootDrop = true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateFileTreeDropFeedback = (rowId) => {
|
|
|
|
|
if (rowId) {
|
|
|
|
|
setFileTreeDropFeedback({ rowId });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const inferDefaultFileTreeDropDocumentId = () => {
|
|
|
|
|
const candidateRowIds = [
|
|
|
|
|
fileTreeFocusedRowId,
|
|
|
|
|
fileTreeAnchorRowId,
|
|
|
|
|
...Array.from(selectedFileTreeRowIds),
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
|
|
|
|
for (const rowId of candidateRowIds) {
|
|
|
|
|
if (!rowId) continue;
|
|
|
|
|
const item = fileTreeRowById.get(rowId);
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(item);
|
|
|
|
|
if (documentId) {
|
|
|
|
|
return documentId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const firstDocRowId = visibleFileTreeRowIds.find((rowId) => rowId.startsWith("doc:"));
|
|
|
|
|
const firstDocItem = firstDocRowId ? fileTreeRowById.get(firstDocRowId) : null;
|
|
|
|
|
return getFileTreeRowDocumentId(firstDocItem) || null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isExternalFileDrag = (event) => {
|
|
|
|
|
const types = event.dataTransfer?.types;
|
|
|
|
|
return Array.isArray(types)
|
|
|
|
|
? types.includes("Files")
|
|
|
|
|
: types instanceof DOMStringList
|
|
|
|
|
? types.contains("Files")
|
|
|
|
|
: false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isInternalFileTreeDrag = (event) => {
|
|
|
|
|
const types = event.dataTransfer?.types;
|
|
|
|
|
return Array.isArray(types)
|
|
|
|
|
? types.includes(FILETREE_DRAG_MIME)
|
|
|
|
|
: types instanceof DOMStringList
|
|
|
|
|
? types.contains(FILETREE_DRAG_MIME)
|
|
|
|
|
: false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const emitFileTreeSelectionChange = () => {
|
|
|
|
|
if (mode !== "filetree") return;
|
|
|
|
|
postToHost("tree.filetree.selection.changed", {
|
|
|
|
|
selectedRowIds: Array.from(selectedFileTreeRowIds),
|
|
|
|
|
anchorRowId: fileTreeAnchorRowId,
|
|
|
|
|
focusedRowId: fileTreeFocusedRowId,
|
|
|
|
|
payload: {
|
|
|
|
|
selectedRowIds: Array.from(selectedFileTreeRowIds),
|
|
|
|
|
anchorRowId: fileTreeAnchorRowId,
|
|
|
|
|
focusedRowId: fileTreeFocusedRowId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFileTreeRangeRowIds = (fromId, toId) => {
|
|
|
|
|
const fromIndex = visibleFileTreeRowIds.indexOf(fromId);
|
|
|
|
|
const toIndex = visibleFileTreeRowIds.indexOf(toId);
|
|
|
|
|
if (fromIndex < 0 || toIndex < 0) {
|
|
|
|
|
return [toId];
|
|
|
|
|
}
|
|
|
|
|
const lo = Math.min(fromIndex, toIndex);
|
|
|
|
|
const hi = Math.max(fromIndex, toIndex);
|
|
|
|
|
return visibleFileTreeRowIds.slice(lo, hi + 1);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const normalizeFileTreeSelectionState = (selection) => {
|
|
|
|
|
const selectedRowIds =
|
|
|
|
|
selection?.selectedRowIds instanceof Set
|
|
|
|
|
? new Set(
|
|
|
|
|
Array.from(selection.selectedRowIds)
|
|
|
|
|
.map((rowId) => normalizeText(rowId))
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
)
|
|
|
|
|
: new Set(normalizeStringArray(selection?.selectedRowIds));
|
|
|
|
|
const anchorRowId = normalizeText(selection?.anchorRowId) || null;
|
|
|
|
|
const focusedRowId = normalizeText(selection?.focusedRowId) || null;
|
|
|
|
|
return {
|
|
|
|
|
selectedRowIds,
|
|
|
|
|
anchorRowId,
|
|
|
|
|
focusedRowId,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readFileTreeSelectionState = () =>
|
|
|
|
|
normalizeFileTreeSelectionState({
|
|
|
|
|
selectedRowIds: Array.from(selectedFileTreeRowIds),
|
|
|
|
|
anchorRowId: fileTreeAnchorRowId,
|
|
|
|
|
focusedRowId: fileTreeFocusedRowId,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const commitFileTreeSelection = (nextSelection) => {
|
2026-04-26 19:35:52 +08:00
|
|
|
const normalizedSelection = normalizeFileTreeSelectionState(nextSelection);
|
|
|
|
|
selectedFileTreeRowIds = normalizedSelection.selectedRowIds;
|
|
|
|
|
fileTreeAnchorRowId = normalizedSelection.anchorRowId;
|
|
|
|
|
fileTreeFocusedRowId = normalizedSelection.focusedRowId;
|
2026-04-24 06:10:18 +08:00
|
|
|
emitFileTreeSelectionChange();
|
2026-04-26 19:35:52 +08:00
|
|
|
return normalizedSelection;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const computeFileTreeSelectionActionResult = (action) => {
|
|
|
|
|
const currentSelection = readFileTreeSelectionState();
|
|
|
|
|
if (
|
|
|
|
|
mode !== "filetree" ||
|
|
|
|
|
filetreeSelectionReducerContractName !== "rust_filetree_selection_reducer_v1" ||
|
|
|
|
|
!filetreeSelectionReducerActions.has(action?.kind || "")
|
|
|
|
|
) {
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: currentSelection,
|
|
|
|
|
dragRowIds: action?.kind === "resolve_drag_rows"
|
|
|
|
|
? [normalizeText(action?.rowId)].filter(Boolean)
|
|
|
|
|
: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action.kind === "select_row") {
|
|
|
|
|
const rowId = normalizeText(action.rowId);
|
|
|
|
|
if (!rowId) {
|
|
|
|
|
return { nextSelection: currentSelection, dragRowIds: null };
|
|
|
|
|
}
|
|
|
|
|
const shiftKey = action.modifiers?.shiftKey === true;
|
|
|
|
|
const metaKey = action.modifiers?.metaKey === true;
|
|
|
|
|
const ctrlKey = action.modifiers?.ctrlKey === true;
|
|
|
|
|
const toggleSelection = metaKey || ctrlKey;
|
|
|
|
|
|
|
|
|
|
if (shiftKey) {
|
|
|
|
|
const anchor =
|
|
|
|
|
currentSelection.anchorRowId || currentSelection.focusedRowId || rowId;
|
|
|
|
|
const nextSelection = toggleSelection
|
|
|
|
|
? new Set(currentSelection.selectedRowIds)
|
|
|
|
|
: new Set();
|
|
|
|
|
getFileTreeRangeRowIds(anchor, rowId).forEach((id) => {
|
|
|
|
|
nextSelection.add(id);
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: nextSelection,
|
|
|
|
|
anchorRowId: currentSelection.anchorRowId || anchor,
|
|
|
|
|
focusedRowId: rowId,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (toggleSelection) {
|
|
|
|
|
const nextSelection = new Set(currentSelection.selectedRowIds);
|
|
|
|
|
if (nextSelection.has(rowId)) {
|
|
|
|
|
nextSelection.delete(rowId);
|
|
|
|
|
} else {
|
|
|
|
|
nextSelection.add(rowId);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: nextSelection,
|
|
|
|
|
anchorRowId: rowId,
|
|
|
|
|
focusedRowId: rowId,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: new Set([rowId]),
|
|
|
|
|
anchorRowId: rowId,
|
|
|
|
|
focusedRowId: rowId,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action.kind === "select_context_row") {
|
|
|
|
|
const rowId = normalizeText(action.rowId);
|
|
|
|
|
if (!rowId) {
|
|
|
|
|
return { nextSelection: currentSelection, dragRowIds: null };
|
|
|
|
|
}
|
|
|
|
|
if (currentSelection.selectedRowIds.has(rowId)) {
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: new Set(currentSelection.selectedRowIds),
|
|
|
|
|
anchorRowId: currentSelection.anchorRowId,
|
|
|
|
|
focusedRowId: rowId,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: new Set([rowId]),
|
|
|
|
|
anchorRowId: rowId,
|
|
|
|
|
focusedRowId: rowId,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action.kind === "normalize_visible_rows") {
|
|
|
|
|
const visibleSet = new Set(normalizeStringArray(action.visibleRowIds));
|
|
|
|
|
const nextSelectedRowIds = Array.from(currentSelection.selectedRowIds).filter((rowId) =>
|
|
|
|
|
visibleSet.has(rowId),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: new Set(nextSelectedRowIds),
|
|
|
|
|
anchorRowId:
|
|
|
|
|
currentSelection.anchorRowId && visibleSet.has(currentSelection.anchorRowId)
|
|
|
|
|
? currentSelection.anchorRowId
|
|
|
|
|
: null,
|
|
|
|
|
focusedRowId:
|
|
|
|
|
currentSelection.focusedRowId && visibleSet.has(currentSelection.focusedRowId)
|
|
|
|
|
? currentSelection.focusedRowId
|
|
|
|
|
: null,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action.kind === "clear") {
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: {
|
|
|
|
|
selectedRowIds: new Set(),
|
|
|
|
|
anchorRowId: null,
|
|
|
|
|
focusedRowId: null,
|
|
|
|
|
},
|
|
|
|
|
dragRowIds: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action.kind === "resolve_drag_rows") {
|
|
|
|
|
const rowId = normalizeText(action.rowId);
|
|
|
|
|
if (!rowId) {
|
|
|
|
|
return { nextSelection: currentSelection, dragRowIds: [] };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
nextSelection: currentSelection,
|
|
|
|
|
dragRowIds: currentSelection.selectedRowIds.has(rowId)
|
|
|
|
|
? Array.from(currentSelection.selectedRowIds)
|
|
|
|
|
: [rowId],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { nextSelection: currentSelection, dragRowIds: null };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyFileTreeSelectionAction = (action) => {
|
|
|
|
|
const result = computeFileTreeSelectionActionResult(action);
|
|
|
|
|
if (action?.kind !== "resolve_drag_rows") {
|
|
|
|
|
commitFileTreeSelection(result.nextSelection);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
2026-04-24 06:10:18 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectFileTreeRow = (rowId, modifiers = {}) => {
|
2026-04-26 19:35:52 +08:00
|
|
|
applyFileTreeSelectionAction({
|
|
|
|
|
kind: "select_row",
|
|
|
|
|
rowId,
|
|
|
|
|
modifiers: {
|
|
|
|
|
shiftKey: modifiers.shiftKey === true,
|
|
|
|
|
ctrlKey: modifiers.ctrlKey === true,
|
|
|
|
|
metaKey: modifiers.metaKey === true,
|
|
|
|
|
},
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectFileTreeContextRow = (rowId) => {
|
2026-04-26 19:35:52 +08:00
|
|
|
applyFileTreeSelectionAction({
|
|
|
|
|
kind: "select_context_row",
|
|
|
|
|
rowId,
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearFileTreeSelection = () => {
|
|
|
|
|
if (mode !== "filetree") return;
|
|
|
|
|
if (
|
|
|
|
|
selectedFileTreeRowIds.size === 0 &&
|
|
|
|
|
!fileTreeAnchorRowId &&
|
|
|
|
|
!fileTreeFocusedRowId
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
applyFileTreeSelectionAction({ kind: "clear" });
|
2026-04-24 06:10:18 +08:00
|
|
|
renderTree();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeFileTreeSelectionForVisibleRows = () => {
|
|
|
|
|
if (mode !== "filetree") return;
|
2026-04-26 19:35:52 +08:00
|
|
|
const currentSelection = readFileTreeSelectionState();
|
|
|
|
|
const nextSelection = computeFileTreeSelectionActionResult({
|
|
|
|
|
kind: "normalize_visible_rows",
|
|
|
|
|
visibleRowIds: visibleFileTreeRowIds,
|
|
|
|
|
}).nextSelection;
|
2026-04-24 06:10:18 +08:00
|
|
|
|
|
|
|
|
if (
|
2026-04-26 19:35:52 +08:00
|
|
|
nextSelection.selectedRowIds.size === currentSelection.selectedRowIds.size &&
|
|
|
|
|
Array.from(nextSelection.selectedRowIds).every((rowId) =>
|
|
|
|
|
currentSelection.selectedRowIds.has(rowId),
|
|
|
|
|
) &&
|
|
|
|
|
nextSelection.anchorRowId === currentSelection.anchorRowId &&
|
|
|
|
|
nextSelection.focusedRowId === currentSelection.focusedRowId
|
2026-04-24 06:10:18 +08:00
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
commitFileTreeSelection(nextSelection);
|
2026-04-24 06:10:18 +08:00
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const resolveFileTreeDraggedRowIds = (rowId) =>
|
|
|
|
|
computeFileTreeSelectionActionResult({
|
|
|
|
|
kind: "resolve_drag_rows",
|
|
|
|
|
rowId,
|
|
|
|
|
}).dragRowIds || [];
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
const patchFileTreeCutDecoration = () => {
|
|
|
|
|
appElement.querySelectorAll('[data-rust-rendered-row="filetree"], .tree-row[data-shell-mode="filetree"]').forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
row.dataset.cut = String(
|
|
|
|
|
fileTreeClipboard.action === "cut" &&
|
|
|
|
|
fileTreeClipboard.rowIds.includes(normalizeText(row.dataset.rowId)),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusFileTreeRowByOffset = (offset) => {
|
|
|
|
|
if (visibleFileTreeRowIds.length === 0) return;
|
|
|
|
|
const currentIndex = Math.max(0, visibleFileTreeRowIds.indexOf(fileTreeFocusedRowId));
|
|
|
|
|
const nextIndex = Math.max(0, Math.min(visibleFileTreeRowIds.length - 1, currentIndex + offset));
|
|
|
|
|
const rowId = visibleFileTreeRowIds[nextIndex];
|
|
|
|
|
if (!rowId) return;
|
|
|
|
|
commitFileTreeSelection({ selectedRowIds: [rowId], anchorRowId: rowId, focusedRowId: rowId });
|
|
|
|
|
const row = appElement.querySelector(`.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(rowId)}"]`);
|
|
|
|
|
if (row instanceof HTMLElement) row.focus({ preventScroll: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setFileTreeClipboard = (action) => {
|
|
|
|
|
const rowIds = selectedFileTreeRowIds.size > 0
|
|
|
|
|
? Array.from(selectedFileTreeRowIds)
|
|
|
|
|
: fileTreeFocusedRowId
|
|
|
|
|
? [fileTreeFocusedRowId]
|
|
|
|
|
: [];
|
|
|
|
|
fileTreeClipboard = { action, rowIds };
|
|
|
|
|
patchFileTreeCutDecoration();
|
|
|
|
|
setLastAction(action === "cut" ? "已剪切选中文件树节点" : "已复制选中文件树节点");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveFileTreeActionRowIds = (rowId) => {
|
|
|
|
|
const normalizedRowId = normalizeText(rowId);
|
|
|
|
|
if (normalizedRowId && selectedFileTreeRowIds.has(normalizedRowId)) {
|
|
|
|
|
return Array.from(selectedFileTreeRowIds);
|
|
|
|
|
}
|
|
|
|
|
if (normalizedRowId) return [normalizedRowId];
|
|
|
|
|
if (selectedFileTreeRowIds.size > 0) return Array.from(selectedFileTreeRowIds);
|
|
|
|
|
return fileTreeFocusedRowId ? [fileTreeFocusedRowId] : [];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runFileTreeDelete = async (rowId) => {
|
|
|
|
|
const rowIds = resolveFileTreeActionRowIds(rowId);
|
|
|
|
|
const deletableItems = rowIds
|
|
|
|
|
.map((id) => fileTreeRowById.get(id))
|
2026-05-16 07:11:06 +08:00
|
|
|
.filter((item) => Boolean(getFileTreeRowDocumentId(item)) || (sourceKind === "local_folder" && item?.rowKind === "asset"));
|
2026-05-08 00:41:03 +08:00
|
|
|
if (deletableItems.length === 0) {
|
|
|
|
|
setLastAction("当前选择没有可删除的页面或 Markdown 文件", "error");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const accepted = await runFileTreePreflight("delete", {
|
|
|
|
|
rowIds: deletableItems.map((item) => item.rowId),
|
|
|
|
|
});
|
|
|
|
|
if (!accepted) return false;
|
|
|
|
|
if (sourceKind === "local_folder") {
|
|
|
|
|
for (const item of deletableItems) {
|
|
|
|
|
await sendCommand({
|
|
|
|
|
action: "delete",
|
|
|
|
|
workspaceId,
|
2026-05-16 07:11:06 +08:00
|
|
|
documentId: getFileTreeRowDocumentId(item) || item.rowId,
|
2026-05-08 00:41:03 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (sourceKind === "convex_workspace") {
|
|
|
|
|
for (const item of deletableItems) {
|
2026-05-14 05:52:08 +08:00
|
|
|
const documentId = getFileTreeRowDocumentId(item);
|
2026-05-08 00:41:03 +08:00
|
|
|
await sendCommand({
|
|
|
|
|
action: "delete",
|
|
|
|
|
workspaceId,
|
2026-05-14 05:52:08 +08:00
|
|
|
documentId,
|
2026-05-08 00:41:03 +08:00
|
|
|
});
|
2026-05-14 05:52:08 +08:00
|
|
|
applyRemovedDocumentLocally(documentId);
|
|
|
|
|
}
|
|
|
|
|
if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
|
|
|
|
|
scheduleRefresh();
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
postToHost("tree.filetree.delete", {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowIds: deletableItems.map((item) => item.rowId),
|
|
|
|
|
payload: {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowIds: deletableItems.map((item) => item.rowId),
|
|
|
|
|
documentIds: deletableItems.map(getFileTreeRowDocumentId),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pasteFileTreeClipboardInto = async (targetRowId) => {
|
|
|
|
|
if (!fileTreeClipboard.action || fileTreeClipboard.rowIds.length === 0) return;
|
|
|
|
|
const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
|
|
|
|
|
const target = targetItem
|
|
|
|
|
? {
|
2026-05-18 17:01:35 +08:00
|
|
|
rowId: targetItem.rowId,
|
|
|
|
|
rowKind: targetItem.rowKind,
|
|
|
|
|
nodeId: targetItem.nodeId,
|
|
|
|
|
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
|
|
|
|
ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
|
|
|
|
|
assetId: getFileTreeRowAssetId(targetItem) || null,
|
|
|
|
|
}
|
|
|
|
|
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
|
2026-05-08 00:41:03 +08:00
|
|
|
if (sourceKind === "local_folder") {
|
|
|
|
|
const pasted = await executeLocalFileTreeInternalDrop(
|
|
|
|
|
target,
|
|
|
|
|
fileTreeClipboard.rowIds,
|
|
|
|
|
fileTreeClipboard.action === "copy",
|
|
|
|
|
"paste",
|
|
|
|
|
);
|
|
|
|
|
if (pasted && fileTreeClipboard.action === "cut") {
|
|
|
|
|
fileTreeClipboard = { action: null, rowIds: [] };
|
|
|
|
|
}
|
|
|
|
|
patchFileTreeCutDecoration();
|
|
|
|
|
} else {
|
|
|
|
|
const accepted = await runFileTreePreflight("paste", {
|
|
|
|
|
target,
|
|
|
|
|
rowIds: fileTreeClipboard.rowIds,
|
|
|
|
|
copy: fileTreeClipboard.action === "copy",
|
|
|
|
|
});
|
|
|
|
|
if (!accepted) return;
|
|
|
|
|
if (sourceKind === "convex_workspace") {
|
|
|
|
|
const sourceRowIds = filterRedundantFileTreeRowIds(fileTreeClipboard.rowIds);
|
|
|
|
|
for (const rowId of sourceRowIds) {
|
|
|
|
|
const sourceItem = fileTreeRowById.get(rowId);
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(sourceItem);
|
|
|
|
|
if (!documentId) continue;
|
|
|
|
|
await sendCommand({
|
|
|
|
|
action: fileTreeClipboard.action === "copy" ? "copy" : "move",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId,
|
|
|
|
|
parentId: target.documentId || null,
|
|
|
|
|
targetParentId: target.documentId || null,
|
|
|
|
|
sortOrder: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (fileTreeClipboard.action === "cut") {
|
|
|
|
|
fileTreeClipboard = { action: null, rowIds: [] };
|
|
|
|
|
}
|
|
|
|
|
patchFileTreeCutDecoration();
|
|
|
|
|
scheduleRefresh();
|
|
|
|
|
} else {
|
|
|
|
|
postToHost("tree.filetree.paste", {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowIds: fileTreeClipboard.rowIds,
|
|
|
|
|
action: fileTreeClipboard.action,
|
|
|
|
|
targetRowId: target.rowId,
|
|
|
|
|
targetRowKind: target.rowKind,
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
payload: {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowIds: fileTreeClipboard.rowIds,
|
|
|
|
|
action: fileTreeClipboard.action,
|
|
|
|
|
targetRowId: target.rowId,
|
|
|
|
|
targetRowKind: target.rowKind,
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFileTreeKeyDown = (event, item) => {
|
|
|
|
|
if (mode !== "filetree") return;
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
setFileTreeClipboard("copy");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "x") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
setFileTreeClipboard("cut");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void pasteFileTreeClipboardInto(item?.rowId || fileTreeFocusedRowId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
commitFileTreeSelection({
|
|
|
|
|
selectedRowIds: visibleFileTreeRowIds,
|
|
|
|
|
anchorRowId: visibleFileTreeRowIds[0] || null,
|
|
|
|
|
focusedRowId: visibleFileTreeRowIds[visibleFileTreeRowIds.length - 1] || null,
|
|
|
|
|
});
|
|
|
|
|
renderTree();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "F2") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const rowId = item?.rowId || fileTreeFocusedRowId;
|
2026-05-18 17:01:35 +08:00
|
|
|
const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
|
|
|
|
|
if (rowId && getFileTreeRowDocumentId(renameItem)) {
|
|
|
|
|
beginInlineRename("filetree", rowId);
|
|
|
|
|
} else {
|
|
|
|
|
setLastAction("当前资源暂不支持重命名", "error");
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowDown") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
focusFileTreeRowByOffset(1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowUp") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
focusFileTreeRowByOffset(-1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Home") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
focusFileTreeRowByOffset(-visibleFileTreeRowIds.length);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "End") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
focusFileTreeRowByOffset(visibleFileTreeRowIds.length);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (item) {
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(item);
|
2026-05-18 17:01:35 +08:00
|
|
|
if (documentId) {
|
|
|
|
|
handleNavigate(documentId);
|
|
|
|
|
} else {
|
|
|
|
|
openHydratedFileTreeItem(item);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Delete" || event.key === "Backspace") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void runFileTreeDelete(item?.rowId || fileTreeFocusedRowId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
const parseTreeShellStateFromHtml = (html) => {
|
|
|
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
|
|
|
const nextStateElement = doc.getElementById("tree-shell-state");
|
|
|
|
|
if (!nextStateElement) return null;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(nextStateElement.textContent || "{}");
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyTreeShellStateSnapshot = (nextState, options = {}) => {
|
|
|
|
|
if (!nextState || typeof nextState !== "object") return false;
|
|
|
|
|
mediaAssets = Array.isArray(nextState.mediaAssets) ? nextState.mediaAssets : [];
|
|
|
|
|
mindmapAssets = Array.isArray(nextState.mindmapAssets) ? nextState.mindmapAssets : [];
|
|
|
|
|
tableAssets = Array.isArray(nextState.tableAssets) ? nextState.tableAssets : [];
|
|
|
|
|
rawItems = Array.isArray(nextState.items) ? nextState.items : [];
|
|
|
|
|
normalizedItems = normalizeTreeItems(rawItems);
|
|
|
|
|
rebuildTreeIndexes();
|
|
|
|
|
if (currentActiveDocumentId && !itemById.has(currentActiveDocumentId)) {
|
|
|
|
|
currentActiveDocumentId = roots[0]?.nodeId || "";
|
|
|
|
|
}
|
|
|
|
|
if (currentFocusedDocumentId && !itemById.has(currentFocusedDocumentId)) {
|
|
|
|
|
currentFocusedDocumentId = currentActiveDocumentId;
|
|
|
|
|
}
|
|
|
|
|
normalizedItems
|
|
|
|
|
.filter((item) => item.childCount > 0 && item.expandedByDefault)
|
|
|
|
|
.forEach((item) => expanded.add(item.nodeId));
|
|
|
|
|
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
|
|
|
|
renderTree();
|
|
|
|
|
if (mode === "filetree") {
|
|
|
|
|
emitFileTreeSelectionChange();
|
|
|
|
|
}
|
|
|
|
|
const renameRowId = normalizeText(options.renameRowId);
|
|
|
|
|
if (renameRowId) {
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (fileTreeRowById.has(renameRowId)) {
|
|
|
|
|
beginInlineRename("filetree", renameRowId);
|
|
|
|
|
}
|
|
|
|
|
}, 80);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshLocalFolderSnapshot = async (options = {}) => {
|
|
|
|
|
const response = await fetch(window.location.href, {
|
|
|
|
|
headers: { "accept": "text/html" },
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) return false;
|
|
|
|
|
const nextState = parseTreeShellStateFromHtml(await response.text());
|
|
|
|
|
return applyTreeShellStateSnapshot(nextState, options);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
const scheduleRefresh = (options = {}) => {
|
2026-04-17 23:36:24 +08:00
|
|
|
window.setTimeout(() => {
|
2026-05-16 07:11:06 +08:00
|
|
|
void refreshLocalFolderSnapshot(options);
|
2026-04-17 23:36:24 +08:00
|
|
|
}, 80);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 05:52:08 +08:00
|
|
|
const addTreeItemLocally = (item) => {
|
|
|
|
|
if (!item?.nodeId || itemById.has(item.nodeId)) return false;
|
|
|
|
|
normalizedItems.push(item);
|
|
|
|
|
itemById.set(item.nodeId, item);
|
|
|
|
|
fileTreeRowById.set(item.rowId, item);
|
|
|
|
|
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
|
|
|
|
|
if (parentId) {
|
|
|
|
|
const bucket = childrenByParentId.get(parentId) || [];
|
|
|
|
|
bucket.push(item);
|
|
|
|
|
bucket.sort(compareItems);
|
|
|
|
|
childrenByParentId.set(parentId, bucket);
|
|
|
|
|
const parentItem = itemById.get(parentId);
|
|
|
|
|
if (parentItem) parentItem.childCount = Math.max(parentItem.childCount || 0, bucket.length);
|
|
|
|
|
expanded.add(parentId);
|
|
|
|
|
} else {
|
|
|
|
|
roots.push(item);
|
|
|
|
|
roots.sort(compareItems);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyCreatedDocumentLocally = (result, parentId, title) => {
|
|
|
|
|
const documentId =
|
|
|
|
|
typeof result?.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
|
|
|
|
: typeof result?.id === "string" && result.id.trim()
|
|
|
|
|
? result.id.trim()
|
|
|
|
|
: "";
|
|
|
|
|
if (!documentId) return false;
|
|
|
|
|
const createdAt = normalizeText(result?.updatedAt || result?.execution?.updated_at);
|
|
|
|
|
const parentNodeId = parentId && itemById.has(parentId) ? parentId : null;
|
|
|
|
|
const item = {
|
|
|
|
|
rowId: `doc:${documentId}`,
|
|
|
|
|
rowKind: "document",
|
|
|
|
|
nodeId: documentId,
|
|
|
|
|
parentNodeId,
|
|
|
|
|
title: normalizeText(result?.title, title || "无标题"),
|
|
|
|
|
depth: parentNodeId ? normalizeNumber(itemById.get(parentNodeId)?.depth, 0) + 1 : 0,
|
|
|
|
|
childCount: 0,
|
|
|
|
|
position: normalizeNumber(result?.sortOrder ?? result?.execution?.sort_order ?? Date.now()),
|
|
|
|
|
expandedByDefault: true,
|
|
|
|
|
iconHint: "page",
|
|
|
|
|
capabilities: ["open", "rename", "delete", "move", "create"],
|
|
|
|
|
resourceMeta: {
|
|
|
|
|
resourceKind: "document",
|
|
|
|
|
documentId,
|
|
|
|
|
assetId: "",
|
|
|
|
|
assetKind: "",
|
|
|
|
|
objectIdentity: { objectKind: "page", documentId, blockId: null, assetId: null },
|
|
|
|
|
blockAssetRelation: null,
|
|
|
|
|
},
|
|
|
|
|
updatedAt: createdAt,
|
|
|
|
|
};
|
|
|
|
|
if (!addTreeItemLocally(item)) return false;
|
|
|
|
|
currentFocusedDocumentId = documentId;
|
|
|
|
|
focusedNodeId = documentId;
|
|
|
|
|
currentActiveDocumentId = documentId;
|
|
|
|
|
renderTree();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeTreeItemEverywhere = (item) => {
|
|
|
|
|
if (!item) return;
|
|
|
|
|
const itemIndex = normalizedItems.indexOf(item);
|
|
|
|
|
if (itemIndex >= 0) normalizedItems.splice(itemIndex, 1);
|
|
|
|
|
itemById.delete(item.nodeId);
|
|
|
|
|
fileTreeRowById.delete(item.rowId);
|
|
|
|
|
const rootIndex = roots.indexOf(item);
|
|
|
|
|
if (rootIndex >= 0) roots.splice(rootIndex, 1);
|
|
|
|
|
const bucket = item.parentNodeId ? childrenByParentId.get(item.parentNodeId) : null;
|
|
|
|
|
if (bucket) {
|
|
|
|
|
const bucketIndex = bucket.indexOf(item);
|
|
|
|
|
if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
|
|
|
|
|
if (bucket.length === 0) childrenByParentId.delete(item.parentNodeId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyRemovedDocumentLocally = (documentId) => {
|
|
|
|
|
const normalizedDocumentId = normalizeText(documentId);
|
|
|
|
|
if (!normalizedDocumentId) return false;
|
|
|
|
|
const removedNodeIds = new Set([normalizedDocumentId]);
|
|
|
|
|
let changed = false;
|
|
|
|
|
let expandedDuringScan = true;
|
|
|
|
|
while (expandedDuringScan) {
|
|
|
|
|
expandedDuringScan = false;
|
|
|
|
|
normalizedItems.forEach((item) => {
|
|
|
|
|
if (item.parentNodeId && removedNodeIds.has(item.parentNodeId) && !removedNodeIds.has(item.nodeId)) {
|
|
|
|
|
removedNodeIds.add(item.nodeId);
|
|
|
|
|
expandedDuringScan = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
normalizedItems.slice().forEach((item) => {
|
2026-05-18 17:01:35 +08:00
|
|
|
const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
|
2026-05-14 05:52:08 +08:00
|
|
|
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
|
|
|
|
removeTreeItemEverywhere(item);
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!changed) return false;
|
|
|
|
|
if (currentActiveDocumentId === normalizedDocumentId) currentActiveDocumentId = roots[0]?.nodeId || "";
|
|
|
|
|
if (currentFocusedDocumentId === normalizedDocumentId) currentFocusedDocumentId = currentActiveDocumentId;
|
|
|
|
|
focusedNodeId = resolveFocusedNodeIdFromHostState();
|
|
|
|
|
renderTree();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
if (sourceKind === "local_folder" && rootUri) {
|
|
|
|
|
let localWatchRevision = initialLocalWatchRevision;
|
|
|
|
|
let localWatchRefreshTimer = 0;
|
|
|
|
|
const refreshFromLocalWatch = () => {
|
|
|
|
|
if (localWatchRefreshTimer) return;
|
|
|
|
|
localWatchRefreshTimer = window.setTimeout(() => {
|
2026-05-16 07:11:06 +08:00
|
|
|
localWatchRefreshTimer = 0;
|
|
|
|
|
void refreshLocalFolderSnapshot();
|
2026-05-08 00:41:03 +08:00
|
|
|
}, 180);
|
|
|
|
|
};
|
|
|
|
|
const pollLocalFolderRevision = async () => {
|
|
|
|
|
if (busy || document.hidden) return;
|
|
|
|
|
const url = new URL("/api/tree/local-folder-watch", window.location.origin);
|
|
|
|
|
url.searchParams.set("rootUri", rootUri);
|
|
|
|
|
const response = await fetch(url.toString(), { headers: { "accept": "application/json" } });
|
|
|
|
|
if (!response.ok) return;
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
const nextRevision =
|
|
|
|
|
payload &&
|
|
|
|
|
payload.result &&
|
|
|
|
|
typeof payload.result.revision === "string"
|
|
|
|
|
? payload.result.revision
|
|
|
|
|
: "";
|
|
|
|
|
if (!nextRevision) return;
|
|
|
|
|
if (!localWatchRevision) {
|
|
|
|
|
localWatchRevision = nextRevision;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (nextRevision !== localWatchRevision) {
|
|
|
|
|
localWatchRevision = nextRevision;
|
|
|
|
|
refreshFromLocalWatch();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.setInterval(() => {
|
|
|
|
|
void pollLocalFolderRevision();
|
|
|
|
|
}, 1200);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const readErrorMessage = async (response) => {
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
try {
|
|
|
|
|
const payload = text ? JSON.parse(text) : null;
|
|
|
|
|
const fromMessage =
|
|
|
|
|
payload && typeof payload.message === "string" ? payload.message :
|
|
|
|
|
payload && typeof payload.error === "string" ? payload.error :
|
|
|
|
|
payload && payload.result && typeof payload.result.message === "string" ? payload.result.message :
|
|
|
|
|
"";
|
|
|
|
|
if (fromMessage) return fromMessage;
|
|
|
|
|
} catch {
|
|
|
|
|
// 忽略 JSON 解析失败,继续返回文本片段
|
|
|
|
|
}
|
|
|
|
|
return text ? text.slice(0, 180) : "树命令执行失败";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ICONS = {
|
|
|
|
|
add: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M8 3.2v9.6M3.2 8h9.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
more: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
|
|
|
<circle cx="4" cy="8" r="1.2"/>
|
|
|
|
|
<circle cx="8" cy="8" r="1.2"/>
|
|
|
|
|
<circle cx="12" cy="8" r="1.2"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
2026-04-18 09:38:16 +08:00
|
|
|
edit: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M3.4 11.8 3 13l1.2-.4 6.6-6.6-1.4-1.4-6 6.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
|
|
|
|
|
<path d="m9.9 4.6 1.5-1.5a1 1 0 0 1 1.4 0l.6.6a1 1 0 0 1 0 1.4l-1.5 1.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
up: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M8 12.6V4.2M8 4.2 5.4 6.8M8 4.2l2.6 2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
2026-04-17 23:36:24 +08:00
|
|
|
page: `
|
|
|
|
|
<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="M9.2 2.8v2.8H12" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
index: `
|
|
|
|
|
<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 7h4M6 9h4M6 11h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
mindmap: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<circle cx="8" cy="8" r="1.6" fill="currentColor"/>
|
|
|
|
|
<circle cx="4" cy="4.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
<circle cx="12" cy="4.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
<circle cx="12" cy="11.5" r="1.3" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
<path d="M6.8 7 4.9 5.4M9.2 7l1.9-1.6M9.1 9l2 1.6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
table: `
|
|
|
|
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
|
<rect x="3.2" y="3.2" width="9.6" height="9.6" rx="1.4" stroke="currentColor" stroke-width="1.2"/>
|
|
|
|
|
<path d="M3.8 6.6h8.4M6.6 3.8v8.4M9.4 3.8v8.4" stroke="currentColor" stroke-width="1.1"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
2026-04-26 04:29:23 +08:00
|
|
|
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>
|
|
|
|
|
`,
|
2026-04-17 23:36:24 +08:00
|
|
|
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"/>
|
|
|
|
|
<path d="M6 8.2h4M6 10.4h2.8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
`,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sendCommand = async (payload) => {
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setStatus("正在提交树命令…");
|
|
|
|
|
try {
|
2026-05-08 00:41:03 +08:00
|
|
|
const commandPayload = Object.assign(
|
|
|
|
|
{},
|
|
|
|
|
payload,
|
|
|
|
|
sourceKind ? { sourceKind } : {},
|
|
|
|
|
rootUri ? { rootUri } : {},
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
const response = await fetch(commandPath, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": "application/json",
|
|
|
|
|
"x-mnote-workspace-id": workspaceId,
|
|
|
|
|
"x-mnote-source-channel": "mnote_web_tree_shell",
|
|
|
|
|
"x-mnote-source-client": "mnote-web",
|
|
|
|
|
...(actorId
|
|
|
|
|
? {
|
|
|
|
|
"x-mnote-actor-id": actorId,
|
|
|
|
|
"x-mnote-actor-type": "user",
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
body: JSON.stringify(commandPayload),
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readErrorMessage(response));
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json().catch(() => null);
|
2026-04-26 04:29:23 +08:00
|
|
|
if (!data || typeof data !== "object") {
|
2026-04-17 23:36:24 +08:00
|
|
|
throw new Error("tree command 返回了无效响应");
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
if (data.ok === true && data.result) {
|
|
|
|
|
return data.result;
|
|
|
|
|
}
|
|
|
|
|
if (data.result && typeof data.result === "object") {
|
|
|
|
|
return data.result;
|
|
|
|
|
}
|
|
|
|
|
return data;
|
2026-04-17 23:36:24 +08:00
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getSiblings = (parentId) => {
|
|
|
|
|
if (!parentId) return roots.slice();
|
|
|
|
|
return (childrenByParentId.get(parentId) || []).slice();
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 16:30:51 +08:00
|
|
|
const commitPageExpandedIds = (expandedIds) => {
|
|
|
|
|
const nextExpanded = new Set(normalizeStringArray(expandedIds));
|
|
|
|
|
let changed = nextExpanded.size !== expanded.size;
|
|
|
|
|
if (!changed) {
|
|
|
|
|
changed = Array.from(nextExpanded).some((nodeId) => !expanded.has(nodeId));
|
|
|
|
|
}
|
|
|
|
|
if (!changed) return false;
|
|
|
|
|
expanded.clear();
|
|
|
|
|
nextExpanded.forEach((nodeId) => expanded.add(nodeId));
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const patchPageTreeAfterRuntimeState = (changedNodeIds, focusedId) => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
if (usedRustInitialRenderer) {
|
|
|
|
|
const ids = normalizeStringArray(changedNodeIds);
|
|
|
|
|
ids.forEach((nodeId) => {
|
|
|
|
|
patchPageTreeExpansionDom(nodeId);
|
|
|
|
|
});
|
|
|
|
|
patchPageTreeActiveDom();
|
|
|
|
|
if (focusedId) focusRowElement(focusedId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
renderTree();
|
|
|
|
|
if (focusedId) focusRowElement(focusedId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyLocalPageExpansionFallback = (nodeId, nextExpanded) => {
|
2026-04-26 04:29:23 +08:00
|
|
|
if (nextExpanded) expanded.add(nodeId);
|
|
|
|
|
else expanded.delete(nodeId);
|
|
|
|
|
postPageExpandChange(nodeId, nextExpanded);
|
2026-04-27 10:27:15 +08:00
|
|
|
if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) {
|
|
|
|
|
focusRowElement(nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
renderTree();
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 16:30:51 +08:00
|
|
|
const toggleExpand = (nodeId) => {
|
|
|
|
|
applyLocalPageExpansionFallback(nodeId, !expanded.has(nodeId));
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const getVisiblePageItems = () => {
|
|
|
|
|
const visible = [];
|
|
|
|
|
const walk = (entries) => {
|
|
|
|
|
entries.forEach((item) => {
|
|
|
|
|
visible.push(item);
|
|
|
|
|
if (item.childCount > 0 && expanded.has(item.nodeId)) {
|
|
|
|
|
walk(getSiblings(item.nodeId));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
walk(roots);
|
|
|
|
|
return visible;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const isPickerEntryPickable = (entry) => {
|
|
|
|
|
if (!entry) return false;
|
|
|
|
|
if (entry.pickerItemKey === "__root__") {
|
|
|
|
|
return allowRootPick;
|
|
|
|
|
}
|
|
|
|
|
const documentId = normalizeText(entry.item?.nodeId || entry.pickerItemKey);
|
|
|
|
|
return Boolean(documentId && !excludedIds.has(documentId));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getPickablePickerEntries = () =>
|
|
|
|
|
getVisiblePickerEntries().filter((entry) => isPickerEntryPickable(entry));
|
|
|
|
|
|
|
|
|
|
const normalizePickerItemKey = (pickerItemKey) => {
|
|
|
|
|
const normalizedItemKey = normalizeText(pickerItemKey);
|
|
|
|
|
if (normalizedItemKey === "__root__" && allowRootPick) {
|
|
|
|
|
return "__root__";
|
|
|
|
|
}
|
|
|
|
|
if (normalizedItemKey && itemById.has(normalizedItemKey) && !excludedIds.has(normalizedItemKey)) {
|
|
|
|
|
return normalizedItemKey;
|
|
|
|
|
}
|
|
|
|
|
return "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveCurrentPickerItemKey = () => {
|
|
|
|
|
const fromActive = normalizePickerItemKey(currentActivePickerItemKey);
|
|
|
|
|
if (fromActive) return fromActive;
|
|
|
|
|
const fromDocument = normalizePickerItemKey(currentActiveDocumentId);
|
|
|
|
|
if (fromDocument) return fromDocument;
|
|
|
|
|
return getPickablePickerEntries()[0]?.pickerItemKey || "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const computePickerStateActionResult = (action) => {
|
|
|
|
|
const currentPickerItemKey = resolveCurrentPickerItemKey();
|
|
|
|
|
if (
|
|
|
|
|
mode !== "picker" ||
|
|
|
|
|
pickerStateReducerContractName !== "rust_picker_state_reducer_v1" ||
|
|
|
|
|
!pickerStateReducerActions.has(action?.kind || "")
|
|
|
|
|
) {
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: currentPickerItemKey,
|
|
|
|
|
pickedDocumentId:
|
|
|
|
|
action?.kind === "pick" && currentPickerItemKey !== "__root__"
|
|
|
|
|
? currentPickerItemKey || null
|
|
|
|
|
: null,
|
|
|
|
|
pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pickable = getPickablePickerEntries();
|
|
|
|
|
if (pickable.length === 0) {
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: "",
|
|
|
|
|
pickedDocumentId: null,
|
|
|
|
|
pickedRoot: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const currentIndex = pickable.findIndex(
|
|
|
|
|
(entry) => entry.pickerItemKey === currentPickerItemKey,
|
|
|
|
|
);
|
|
|
|
|
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
|
|
|
|
const actionKind = action.kind;
|
|
|
|
|
|
|
|
|
|
if (actionKind === "normalize") {
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: pickable[resolvedIndex]?.pickerItemKey || "",
|
|
|
|
|
pickedDocumentId: null,
|
|
|
|
|
pickedRoot: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (actionKind === "focus") {
|
|
|
|
|
const nextItemKey = normalizePickerItemKey(action.itemKey);
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "",
|
|
|
|
|
pickedDocumentId: null,
|
|
|
|
|
pickedRoot: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (actionKind === "pick") {
|
|
|
|
|
const target = pickable[resolvedIndex];
|
|
|
|
|
const targetKey = target?.pickerItemKey || "";
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: targetKey,
|
|
|
|
|
pickedDocumentId:
|
|
|
|
|
targetKey && targetKey !== "__root__" ? targetKey : null,
|
|
|
|
|
pickedRoot: targetKey === "__root__",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let nextIndex = resolvedIndex;
|
|
|
|
|
if (actionKind === "next") {
|
|
|
|
|
nextIndex = Math.min(pickable.length - 1, resolvedIndex + 1);
|
|
|
|
|
} else if (actionKind === "previous") {
|
|
|
|
|
nextIndex = Math.max(0, resolvedIndex - 1);
|
|
|
|
|
} else if (actionKind === "home") {
|
|
|
|
|
nextIndex = 0;
|
|
|
|
|
} else if (actionKind === "end") {
|
|
|
|
|
nextIndex = pickable.length - 1;
|
|
|
|
|
} else {
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: currentPickerItemKey,
|
|
|
|
|
pickedDocumentId: null,
|
|
|
|
|
pickedRoot: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: pickable[nextIndex]?.pickerItemKey || "",
|
|
|
|
|
pickedDocumentId: null,
|
|
|
|
|
pickedRoot: false,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const focusNode = (nodeId) => {
|
|
|
|
|
if (!nodeId || !itemById.has(nodeId)) return;
|
2026-04-26 04:29:23 +08:00
|
|
|
if (focusedNodeId === nodeId) {
|
|
|
|
|
focusRowElement(nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
focusedNodeId = nodeId;
|
2026-04-26 04:29:23 +08:00
|
|
|
postPageFocusChange(nodeId);
|
2026-04-27 10:27:15 +08:00
|
|
|
if (usedRustInitialRenderer) {
|
|
|
|
|
patchPageTreeActiveDom();
|
|
|
|
|
focusRowElement(nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
renderTree();
|
|
|
|
|
focusRowElement(nodeId);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 16:30:51 +08:00
|
|
|
const resolvePageActionItem = (action, item) => {
|
|
|
|
|
const actionNodeId = normalizeText(action?.nodeId);
|
|
|
|
|
if (actionNodeId && itemById.has(actionNodeId)) {
|
|
|
|
|
return itemById.get(actionNodeId);
|
|
|
|
|
}
|
|
|
|
|
if (item?.nodeId && itemById.has(item.nodeId)) {
|
|
|
|
|
return item;
|
|
|
|
|
}
|
|
|
|
|
return focusedNodeId && itemById.has(focusedNodeId)
|
|
|
|
|
? itemById.get(focusedNodeId)
|
|
|
|
|
: null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildPageRuntimeAction = (action, item) => {
|
|
|
|
|
const actionKind = normalizeText(action?.kind).toLowerCase();
|
|
|
|
|
if (actionKind === "focus") {
|
|
|
|
|
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
|
|
|
|
|
return nodeId ? { kind: "focus", nodeId } : null;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "move_next") return { kind: "moveNext" };
|
|
|
|
|
if (actionKind === "move_previous") return { kind: "movePrevious" };
|
|
|
|
|
if (actionKind === "move_home") return { kind: "moveHome" };
|
|
|
|
|
if (actionKind === "move_end") return { kind: "moveEnd" };
|
|
|
|
|
if (actionKind === "open") return { kind: "openFocused" };
|
|
|
|
|
if (actionKind === "context_menu") return { kind: "contextMenuFocused" };
|
|
|
|
|
if (actionKind === "expand" || actionKind === "collapse" || actionKind === "toggle") {
|
|
|
|
|
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
|
|
|
|
|
return nodeId ? { kind: actionKind, nodeId } : null;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildPageRuntimeEnvironment = () => ({
|
|
|
|
|
visibleNodeIds: getVisiblePageItems().map((entry) => entry.nodeId),
|
|
|
|
|
expandableNodeIds: normalizedItems
|
|
|
|
|
.filter((entry) => entry.childCount > 0 && getSiblings(entry.nodeId).length > 0)
|
|
|
|
|
.map((entry) => entry.nodeId),
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-20 20:48:18 +08:00
|
|
|
const buildFileTreeRuntimeEnvironment = () => ({
|
|
|
|
|
visibleRowIds: visibleFileTreeRowIds,
|
|
|
|
|
rows: Array.from(fileTreeRowById.values()).map((entry) => ({
|
|
|
|
|
rowId: entry.rowId,
|
|
|
|
|
rowKind: entry.rowKind,
|
|
|
|
|
documentId: entry.documentId || null,
|
|
|
|
|
assetId: entry.assetId || null,
|
|
|
|
|
})),
|
|
|
|
|
rootUri: sourceKind === "local_folder" ? rootUri || null : null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-28 16:30:51 +08:00
|
|
|
const readPageRuntimeState = (action, item) => {
|
|
|
|
|
const actionKind = normalizeText(action?.kind).toLowerCase();
|
|
|
|
|
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
|
|
|
|
|
const focusedId =
|
|
|
|
|
(actionKind === "open" || actionKind === "context_menu") && itemById.has(nodeId)
|
|
|
|
|
? nodeId
|
|
|
|
|
: focusedNodeId || null;
|
|
|
|
|
return {
|
|
|
|
|
focusedId,
|
|
|
|
|
expandedIds: Array.from(expanded),
|
|
|
|
|
dropFeedback: null,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reducePageActionWithRuntime = async (action, item) => {
|
|
|
|
|
if (mode !== "page" || !runtimeReduceEndpoint) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const runtimeAction = buildPageRuntimeAction(action, item);
|
|
|
|
|
if (!runtimeAction) return null;
|
|
|
|
|
const response = await fetch(runtimeReduceEndpoint, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
mode: "page",
|
|
|
|
|
requestId: `page-runtime-${Date.now()}`,
|
|
|
|
|
environment: buildPageRuntimeEnvironment(),
|
|
|
|
|
state: readPageRuntimeState(action, item),
|
|
|
|
|
action: runtimeAction,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(await readErrorMessage(response));
|
|
|
|
|
}
|
|
|
|
|
return response.json();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizePageRuntimeResult = (runtimeResult) => {
|
|
|
|
|
if (!runtimeResult || runtimeResult.mode !== "page") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const stateSnapshot =
|
|
|
|
|
runtimeResult.state && runtimeResult.state.mode === "page"
|
|
|
|
|
? runtimeResult.state.state
|
|
|
|
|
: null;
|
|
|
|
|
const pagePatch = Array.isArray(runtimeResult.domPatches)
|
|
|
|
|
? runtimeResult.domPatches.find((patch) => patch?.kind === "pageState")
|
|
|
|
|
: null;
|
|
|
|
|
const focusedId =
|
|
|
|
|
typeof pagePatch?.focusedId === "string"
|
|
|
|
|
? pagePatch.focusedId
|
|
|
|
|
: typeof stateSnapshot?.focusedId === "string"
|
|
|
|
|
? stateSnapshot.focusedId
|
|
|
|
|
: "";
|
|
|
|
|
const expandedIds = Array.isArray(pagePatch?.expandedIds)
|
|
|
|
|
? pagePatch.expandedIds
|
|
|
|
|
: Array.isArray(stateSnapshot?.expandedIds)
|
|
|
|
|
? stateSnapshot.expandedIds
|
|
|
|
|
: null;
|
|
|
|
|
return {
|
|
|
|
|
focusedId: normalizeText(focusedId),
|
|
|
|
|
expandedIds: expandedIds ? normalizeStringArray(expandedIds) : null,
|
|
|
|
|
hostEvents: Array.isArray(runtimeResult.hostEvents) ? runtimeResult.hostEvents : [],
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const replayPageRuntimeHostEvents = (runtimeResult, item, sourceElement) => {
|
|
|
|
|
const result = normalizePageRuntimeResult(runtimeResult);
|
|
|
|
|
if (!result) return false;
|
|
|
|
|
let replayed = false;
|
|
|
|
|
result.hostEvents.forEach((event) => {
|
|
|
|
|
if (!event || typeof event !== "object") return;
|
|
|
|
|
if (event.kind === "pageOpen") {
|
|
|
|
|
const nodeId = normalizeText(event.nodeId);
|
|
|
|
|
if (nodeId) {
|
|
|
|
|
handleNavigate(nodeId);
|
|
|
|
|
replayed = true;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.kind === "pageContextMenu") {
|
|
|
|
|
const nodeId = normalizeText(event.nodeId || item?.nodeId);
|
|
|
|
|
if (!nodeId) return;
|
|
|
|
|
const rect = sourceElement?.getBoundingClientRect?.();
|
|
|
|
|
openContextMenu(
|
|
|
|
|
nodeId,
|
|
|
|
|
rect ? rect.left + Math.min(rect.width - 12, 28) : 0,
|
|
|
|
|
rect ? rect.top + Math.min(rect.height - 12, 18) : 0,
|
|
|
|
|
);
|
|
|
|
|
replayed = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return replayed;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reconcilePageRuntimeResult = (runtimeResult, item) => {
|
|
|
|
|
const result = normalizePageRuntimeResult(runtimeResult);
|
|
|
|
|
if (!result) return false;
|
|
|
|
|
const previousExpanded = new Set(expanded);
|
|
|
|
|
let shouldPatchTree = false;
|
|
|
|
|
if (Array.isArray(result.expandedIds)) {
|
|
|
|
|
shouldPatchTree = commitPageExpandedIds(result.expandedIds) || shouldPatchTree;
|
|
|
|
|
}
|
|
|
|
|
if (result.focusedId && result.focusedId !== focusedNodeId) {
|
|
|
|
|
focusedNodeId = result.focusedId;
|
|
|
|
|
postPageFocusChange(result.focusedId);
|
|
|
|
|
shouldPatchTree = true;
|
|
|
|
|
}
|
|
|
|
|
const itemNodeId = normalizeText(item?.nodeId);
|
|
|
|
|
if (itemNodeId && previousExpanded.has(itemNodeId) !== expanded.has(itemNodeId)) {
|
|
|
|
|
postPageExpandChange(itemNodeId, expanded.has(itemNodeId));
|
|
|
|
|
}
|
|
|
|
|
if (shouldPatchTree) {
|
|
|
|
|
patchPageTreeAfterRuntimeState(
|
|
|
|
|
Array.isArray(result.expandedIds) ? [itemNodeId, ...result.expandedIds] : [itemNodeId],
|
|
|
|
|
result.focusedId || focusedNodeId,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return shouldPatchTree;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyLocalPageActionFallback = (action, item, sourceElement) => {
|
2026-04-26 19:35:52 +08:00
|
|
|
if (mode !== "page") return;
|
|
|
|
|
const actionKind = normalizeText(action?.kind).toLowerCase();
|
|
|
|
|
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (actionKind === "focus") {
|
|
|
|
|
const nextFocusId = normalizeText(action?.nodeId);
|
|
|
|
|
if (nextFocusId && itemById.has(nextFocusId)) {
|
|
|
|
|
focusNode(nextFocusId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visible = getVisiblePageItems();
|
|
|
|
|
const visibleIds = visible.map((entry) => entry.nodeId);
|
|
|
|
|
if (visibleIds.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const currentFocusId =
|
|
|
|
|
visibleIds.includes(focusedNodeId) ? focusedNodeId : visibleIds[0];
|
|
|
|
|
const currentIndex = visibleIds.indexOf(currentFocusId);
|
|
|
|
|
|
|
|
|
|
if (actionKind === "move_next") {
|
|
|
|
|
const next = visible[currentIndex + 1];
|
|
|
|
|
if (next) focusNode(next.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "move_previous") {
|
|
|
|
|
const previous = visible[currentIndex - 1];
|
|
|
|
|
if (previous) focusNode(previous.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "move_home") {
|
|
|
|
|
focusNode(visibleIds[0]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "move_end") {
|
|
|
|
|
focusNode(visibleIds[visibleIds.length - 1]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "expand") {
|
|
|
|
|
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
|
2026-04-28 16:30:51 +08:00
|
|
|
applyLocalPageExpansionFallback(item.nodeId, true);
|
2026-04-26 19:35:52 +08:00
|
|
|
focusRowElement(item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const firstChild = item ? getSiblings(item.nodeId)[0] : null;
|
|
|
|
|
if (firstChild) {
|
|
|
|
|
focusNode(firstChild.nodeId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "collapse") {
|
|
|
|
|
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
|
2026-04-28 16:30:51 +08:00
|
|
|
applyLocalPageExpansionFallback(item.nodeId, false);
|
2026-04-26 19:35:52 +08:00
|
|
|
focusRowElement(item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (item?.parentNodeId && itemById.has(item.parentNodeId)) {
|
|
|
|
|
focusNode(item.parentNodeId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "open") {
|
|
|
|
|
if (item?.nodeId) {
|
|
|
|
|
handleNavigate(item.nodeId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (actionKind === "context_menu") {
|
|
|
|
|
if (!item?.nodeId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const rect = sourceElement?.getBoundingClientRect?.();
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
openContextMenu(
|
|
|
|
|
item.nodeId,
|
|
|
|
|
rect.left + Math.min(rect.width - 12, 28),
|
|
|
|
|
rect.top + Math.min(rect.height - 12, 18),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 16:30:51 +08:00
|
|
|
const applyPageKeyboardAction = (action, item, sourceElement) => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
const actionKind = normalizeText(action?.kind).toLowerCase();
|
|
|
|
|
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const runtimeItem = resolvePageActionItem(action, item);
|
|
|
|
|
const runtimeAction = buildPageRuntimeAction(action, runtimeItem);
|
|
|
|
|
if (!runtimeAction) {
|
|
|
|
|
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void reducePageActionWithRuntime(action, runtimeItem)
|
|
|
|
|
.then((runtimeResult) => {
|
|
|
|
|
const replayedHostEvent = replayPageRuntimeHostEvents(
|
|
|
|
|
runtimeResult,
|
|
|
|
|
runtimeItem,
|
|
|
|
|
sourceElement,
|
|
|
|
|
);
|
|
|
|
|
const reconciledState = reconcilePageRuntimeResult(runtimeResult, runtimeItem);
|
|
|
|
|
if (!replayedHostEvent && !reconciledState) {
|
|
|
|
|
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 10:27:15 +08:00
|
|
|
const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => {
|
2026-04-26 04:29:23 +08:00
|
|
|
if (mode !== "picker") return;
|
2026-04-26 19:35:52 +08:00
|
|
|
const result = computePickerStateActionResult({
|
|
|
|
|
kind: "focus",
|
|
|
|
|
itemKey: pickerItemKey,
|
|
|
|
|
});
|
2026-04-27 10:27:15 +08:00
|
|
|
const shouldFocusDom = options.focusDom === true;
|
2026-04-26 19:35:52 +08:00
|
|
|
const nextPickerItemKey = result.nextItemKey || "";
|
2026-04-26 04:29:23 +08:00
|
|
|
const nextDocumentId =
|
|
|
|
|
nextPickerItemKey && nextPickerItemKey !== "__root__"
|
|
|
|
|
? nextPickerItemKey
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
currentActivePickerItemKey = nextPickerItemKey;
|
|
|
|
|
currentActiveDocumentId = nextDocumentId;
|
|
|
|
|
focusedNodeId = nextDocumentId || "";
|
2026-04-26 19:35:52 +08:00
|
|
|
if (usedRustInitialRenderer) {
|
|
|
|
|
patchPickerActiveDom();
|
2026-04-27 10:27:15 +08:00
|
|
|
if (shouldFocusDom) focusPickerRowElement(nextPickerItemKey);
|
2026-04-26 19:35:52 +08:00
|
|
|
} else {
|
|
|
|
|
renderTree();
|
|
|
|
|
}
|
2026-04-27 10:27:15 +08:00
|
|
|
if (shouldFocusDom && nextDocumentId) {
|
2026-04-26 04:29:23 +08:00
|
|
|
focusRowElement(nextDocumentId);
|
|
|
|
|
}
|
|
|
|
|
postPickerFocusChange(nextPickerItemKey || null);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const applyPickerStateAction = (action) => {
|
|
|
|
|
if (mode !== "picker") {
|
|
|
|
|
return {
|
|
|
|
|
nextItemKey: "",
|
|
|
|
|
pickedDocumentId: null,
|
|
|
|
|
pickedRoot: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = computePickerStateActionResult(action);
|
|
|
|
|
if (action?.kind !== "pick") {
|
|
|
|
|
applyPickerFocusByItemKey(result.nextItemKey);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 10:27:15 +08:00
|
|
|
const postPickerPickResultToHost = (result) => {
|
|
|
|
|
if (mode !== "picker" || !result) return;
|
|
|
|
|
if (result.pickedRoot) {
|
|
|
|
|
setLastAction("已选择根目录");
|
|
|
|
|
postToHost("tree.pick.root", {
|
|
|
|
|
documentId: null,
|
|
|
|
|
target: { documentId: null },
|
|
|
|
|
payload: { documentId: null },
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (result.pickedDocumentId) {
|
|
|
|
|
postToHost("tree.pick", {
|
|
|
|
|
documentId: result.pickedDocumentId,
|
|
|
|
|
itemKey: result.nextItemKey || result.pickedDocumentId,
|
|
|
|
|
target: { documentId: result.pickedDocumentId },
|
|
|
|
|
payload: { documentId: result.pickedDocumentId },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
const handlePickerCommand = (command) => {
|
|
|
|
|
if (mode !== "picker") return;
|
|
|
|
|
|
|
|
|
|
const normalizedCommand = normalizeText(command);
|
2026-04-26 19:35:52 +08:00
|
|
|
if (!pickerStateReducerActions.has(normalizedCommand)) {
|
2026-04-26 04:29:23 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (normalizedCommand === "pick") {
|
2026-04-27 10:27:15 +08:00
|
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
2026-04-26 04:29:23 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPickerStateAction({ kind: normalizedCommand });
|
2026-04-26 04:29:23 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
const closeFileTreeContextMenu = () => {
|
|
|
|
|
if (!activeFileTreeMenuElement) return;
|
|
|
|
|
activeFileTreeMenuElement.remove();
|
|
|
|
|
activeFileTreeMenuElement = null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildFileTreeMenuTarget = (rowId, rowKind, documentId, assetId) => {
|
|
|
|
|
const item = rowId ? fileTreeRowById.get(rowId) : null;
|
|
|
|
|
return {
|
|
|
|
|
item,
|
|
|
|
|
rowId: rowId || null,
|
|
|
|
|
rowKind: rowKind || item?.rowKind || "root",
|
|
|
|
|
documentId: documentId || getFileTreeRowDocumentId(item) || null,
|
|
|
|
|
assetId: assetId || getFileTreeRowAssetId(item) || null,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createFileTreeMenuItem = (kind, label, options = {}) => ({
|
|
|
|
|
kind,
|
|
|
|
|
label,
|
|
|
|
|
disabled: options.disabled === true,
|
|
|
|
|
reason: normalizeText(options.reason),
|
|
|
|
|
separatorBefore: options.separatorBefore === true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buildFileTreeContextMenuProfile = (target) => {
|
|
|
|
|
const selectedCount = selectedFileTreeRowIds.size;
|
|
|
|
|
const isMulti = selectedCount > 1 && target.rowId && selectedFileTreeRowIds.has(target.rowId);
|
|
|
|
|
const item = target.item;
|
|
|
|
|
const rowKind = target.rowKind || "root";
|
|
|
|
|
const canPaste = Boolean(fileTreeClipboard.action && fileTreeClipboard.rowIds.length > 0);
|
|
|
|
|
const hasDocument = Boolean(getFileTreeRowDocumentId(item));
|
|
|
|
|
const hasAsset = Boolean(getFileTreeRowAssetId(item));
|
|
|
|
|
const localSource = sourceKind === "local_folder";
|
|
|
|
|
const convexSource = sourceKind === "convex_workspace";
|
|
|
|
|
const canCreateFolder = localSource;
|
|
|
|
|
const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document";
|
|
|
|
|
const canRename =
|
|
|
|
|
rowKind === "markdown" ||
|
|
|
|
|
rowKind === "document" ||
|
|
|
|
|
rowKind === "folder" ||
|
2026-05-18 17:01:35 +08:00
|
|
|
rowKind === "index";
|
2026-05-08 00:41:03 +08:00
|
|
|
const canCopyCut =
|
|
|
|
|
rowKind === "markdown" ||
|
|
|
|
|
rowKind === "document" ||
|
|
|
|
|
rowKind === "index";
|
|
|
|
|
const canDelete =
|
|
|
|
|
rowKind === "markdown" ||
|
|
|
|
|
rowKind === "document" ||
|
2026-05-16 07:11:06 +08:00
|
|
|
rowKind === "index" ||
|
|
|
|
|
(localSource && rowKind === "asset");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
if (rowKind === "root") {
|
|
|
|
|
return [
|
|
|
|
|
createFileTreeMenuItem("newPage", "新建页面", {
|
|
|
|
|
disabled: !canCreatePage,
|
|
|
|
|
reason: "当前 root 不支持新建页面",
|
|
|
|
|
}),
|
|
|
|
|
createFileTreeMenuItem("newFolder", "新建文件夹", {
|
|
|
|
|
disabled: !canCreateFolder,
|
|
|
|
|
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
|
|
|
|
}),
|
|
|
|
|
createFileTreeMenuItem("upload", "上传/导入", {
|
|
|
|
|
disabled: true,
|
|
|
|
|
reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入",
|
|
|
|
|
}),
|
|
|
|
|
createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
|
|
|
|
|
createFileTreeMenuItem("collapseAll", "全部折叠"),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isMulti) {
|
|
|
|
|
return [
|
|
|
|
|
createFileTreeMenuItem("copy", "复制"),
|
|
|
|
|
createFileTreeMenuItem("cut", "剪切"),
|
|
|
|
|
createFileTreeMenuItem("delete", "删除", { disabled: false }),
|
|
|
|
|
createFileTreeMenuItem("moveTo", "移动到", {
|
|
|
|
|
separatorBefore: true,
|
|
|
|
|
disabled: true,
|
|
|
|
|
reason: "目标选择器尚未接入 filetree 菜单",
|
|
|
|
|
}),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items = [];
|
|
|
|
|
if (rowKind === "folder") {
|
|
|
|
|
items.push(createFileTreeMenuItem("newPage", "新建页面", {
|
|
|
|
|
disabled: !canCreatePage,
|
|
|
|
|
reason: "当前文件夹不支持新建页面",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
|
|
|
|
|
disabled: !canCreateFolder,
|
|
|
|
|
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
|
|
|
|
disabled: !canPaste,
|
|
|
|
|
reason: "剪贴板为空",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("rename", "重命名", {
|
|
|
|
|
separatorBefore: true,
|
|
|
|
|
disabled: !canRename,
|
|
|
|
|
reason: "当前文件夹不支持重命名",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("delete", "删除", {
|
|
|
|
|
disabled: true,
|
|
|
|
|
reason: "目录删除尚未收口到统一 executor",
|
|
|
|
|
}));
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.push(createFileTreeMenuItem("open", "打开", {
|
|
|
|
|
disabled: !hasDocument && !hasAsset,
|
|
|
|
|
reason: "当前行没有可打开资源",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("rename", "重命名", {
|
|
|
|
|
disabled: !canRename,
|
|
|
|
|
reason: "当前资源暂不支持重命名",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("copy", "复制", {
|
|
|
|
|
disabled: !canCopyCut,
|
|
|
|
|
reason: "当前资源暂不支持复制",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("cut", "剪切", {
|
|
|
|
|
disabled: !canCopyCut,
|
|
|
|
|
reason: "当前资源暂不支持剪切",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
|
|
|
|
disabled: !canPaste,
|
|
|
|
|
reason: "剪贴板为空",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("moveTo", "移动到", {
|
|
|
|
|
disabled: true,
|
|
|
|
|
reason: "目标选择器尚未接入 filetree 菜单",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("delete", "删除", {
|
|
|
|
|
disabled: !canDelete,
|
|
|
|
|
reason: "当前资源暂不支持删除",
|
|
|
|
|
}));
|
|
|
|
|
items.push(createFileTreeMenuItem("reveal", "Reveal", { separatorBefore: true }));
|
|
|
|
|
if (hasAsset) {
|
|
|
|
|
items.push(createFileTreeMenuItem("download", "下载", {
|
|
|
|
|
disabled: true,
|
|
|
|
|
reason: "下载 executor 尚未接入",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
if (item?.capabilities?.includes("share")) {
|
|
|
|
|
items.push(createFileTreeMenuItem("share", "分享", { separatorBefore: true }));
|
|
|
|
|
}
|
|
|
|
|
if (item?.capabilities?.includes("publish")) {
|
|
|
|
|
items.push(createFileTreeMenuItem("publish", "发布"));
|
|
|
|
|
}
|
|
|
|
|
return items;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const localCreatedRowIdFromCommandResult = (result, rowKind) => {
|
|
|
|
|
const relativePath =
|
|
|
|
|
typeof result?.execution?.relativePath === "string" && result.execution.relativePath.trim()
|
|
|
|
|
? result.execution.relativePath.trim()
|
|
|
|
|
: typeof result?.relativePath === "string" && result.relativePath.trim()
|
|
|
|
|
? result.relativePath.trim()
|
|
|
|
|
: "";
|
|
|
|
|
if (!relativePath) return "";
|
|
|
|
|
return `local:${rowKind}:${relativePath}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const executeFileTreeContextMenuAction = async (kind, target) => {
|
|
|
|
|
closeFileTreeContextMenu();
|
|
|
|
|
const item = target.item;
|
|
|
|
|
if (kind === "open") {
|
|
|
|
|
if (item) openHydratedFileTreeItem(item);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "rename") {
|
2026-05-18 17:01:35 +08:00
|
|
|
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
|
|
|
|
beginInlineRename("filetree", target.rowId);
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "copy" || kind === "cut") {
|
|
|
|
|
if (target.rowId && !selectedFileTreeRowIds.has(target.rowId)) {
|
|
|
|
|
commitFileTreeSelection({
|
|
|
|
|
selectedRowIds: [target.rowId],
|
|
|
|
|
anchorRowId: target.rowId,
|
|
|
|
|
focusedRowId: target.rowId,
|
|
|
|
|
});
|
|
|
|
|
syncFileTreeSelectionDom();
|
|
|
|
|
}
|
|
|
|
|
setFileTreeClipboard(kind);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "paste") {
|
|
|
|
|
await pasteFileTreeClipboardInto(target.rowId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "delete") {
|
|
|
|
|
await runFileTreeDelete(target.rowId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "newPage") {
|
|
|
|
|
const parentId = item && item.rowKind === "folder"
|
|
|
|
|
? item.nodeId
|
|
|
|
|
: item && item.rowKind === "document"
|
|
|
|
|
? item.nodeId
|
|
|
|
|
: null;
|
|
|
|
|
await handleCreate(parentId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "newFolder") {
|
|
|
|
|
const parentId = item && item.rowKind === "folder" ? item.nodeId : null;
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "createFolder",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: "",
|
|
|
|
|
parentId,
|
|
|
|
|
title: "新建文件夹",
|
|
|
|
|
});
|
|
|
|
|
setStatus("创建文件夹成功");
|
|
|
|
|
setLastAction("已创建新建文件夹");
|
|
|
|
|
scheduleRefresh({
|
|
|
|
|
renameRowId: localCreatedRowIdFromCommandResult(result, "folder"),
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "refresh") {
|
2026-05-16 07:11:06 +08:00
|
|
|
await refreshLocalFolderSnapshot();
|
2026-05-08 00:41:03 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "collapseAll") {
|
|
|
|
|
expanded.clear();
|
|
|
|
|
renderTree();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (kind === "reveal") {
|
|
|
|
|
setLastAction(`Reveal ${target.documentId || target.assetId || target.rowId || "root"}`);
|
|
|
|
|
postToHost("tree.filetree.reveal", {
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
assetId: target.assetId,
|
|
|
|
|
rowId: target.rowId,
|
|
|
|
|
payload: target,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
postToHost(`tree.filetree.${kind}`, {
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
assetId: target.assetId,
|
|
|
|
|
rowId: target.rowId,
|
|
|
|
|
payload: target,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const openFileTreeContextMenu = ({
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId,
|
|
|
|
|
rowKind,
|
|
|
|
|
clientX,
|
|
|
|
|
clientY,
|
|
|
|
|
}) => {
|
2026-05-08 00:41:03 +08:00
|
|
|
closeFileTreeContextMenu();
|
|
|
|
|
const target = buildFileTreeMenuTarget(rowId, rowKind, documentId, assetId);
|
|
|
|
|
const profile = buildFileTreeContextMenuProfile(target);
|
|
|
|
|
const menu = document.createElement("div");
|
|
|
|
|
menu.className = "tree-context-menu";
|
|
|
|
|
menu.dataset.testid = "filetree-context-menu";
|
|
|
|
|
menu.dataset.sourceKind = sourceKind;
|
|
|
|
|
menu.dataset.rowKind = target.rowKind;
|
|
|
|
|
menu.setAttribute("role", "menu");
|
|
|
|
|
|
|
|
|
|
profile.forEach((entry) => {
|
|
|
|
|
if (entry.separatorBefore) {
|
|
|
|
|
const separator = document.createElement("div");
|
|
|
|
|
separator.className = "tree-menu-separator";
|
|
|
|
|
separator.setAttribute("role", "separator");
|
|
|
|
|
menu.appendChild(separator);
|
|
|
|
|
}
|
|
|
|
|
const button = document.createElement("button");
|
|
|
|
|
button.type = "button";
|
|
|
|
|
button.className = "tree-menu-item";
|
|
|
|
|
button.dataset.menuAction = entry.kind;
|
|
|
|
|
button.setAttribute("role", "menuitem");
|
|
|
|
|
button.textContent = entry.label;
|
|
|
|
|
if (entry.disabled) {
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
if (entry.reason) {
|
|
|
|
|
button.title = entry.reason;
|
|
|
|
|
button.dataset.disabledReason = entry.reason;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
void executeFileTreeContextMenuAction(entry.kind, target);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
menu.appendChild(button);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const closeOnOutside = (event) => {
|
|
|
|
|
if (activeFileTreeMenuElement && !activeFileTreeMenuElement.contains(event.target)) {
|
|
|
|
|
closeFileTreeContextMenu();
|
|
|
|
|
document.removeEventListener("mousedown", closeOnOutside, true);
|
|
|
|
|
document.removeEventListener("keydown", closeOnKeyDown, true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const closeOnKeyDown = (event) => {
|
|
|
|
|
if (event.key === "Escape") {
|
|
|
|
|
closeFileTreeContextMenu();
|
|
|
|
|
document.removeEventListener("mousedown", closeOnOutside, true);
|
|
|
|
|
document.removeEventListener("keydown", closeOnKeyDown, true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(menu);
|
|
|
|
|
const x = Number.isFinite(clientX) ? clientX : 16;
|
|
|
|
|
const y = Number.isFinite(clientY) ? clientY : 16;
|
|
|
|
|
const rect = menu.getBoundingClientRect();
|
|
|
|
|
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - rect.width - 4))}px`;
|
|
|
|
|
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))}px`;
|
|
|
|
|
activeFileTreeMenuElement = menu;
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
document.addEventListener("mousedown", closeOnOutside, true);
|
|
|
|
|
document.addEventListener("keydown", closeOnKeyDown, true);
|
|
|
|
|
}, 0);
|
2026-04-17 23:36:24 +08:00
|
|
|
setLastAction(
|
|
|
|
|
assetId
|
|
|
|
|
? `已打开资源 ${assetId} 的更多操作`
|
|
|
|
|
: `已打开文件树节点 ${documentId || rowId || "unknown"} 的更多操作`,
|
|
|
|
|
);
|
|
|
|
|
postToHost("tree.filetree.context-menu", {
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId,
|
|
|
|
|
rowKind,
|
|
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId,
|
|
|
|
|
rowKind,
|
|
|
|
|
x: clientX,
|
|
|
|
|
y: clientY,
|
|
|
|
|
},
|
|
|
|
|
x: clientX,
|
|
|
|
|
y: clientY,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openContextMenu = (nodeId, clientX, clientY) => {
|
|
|
|
|
if (!nodeId || mode !== "page") return;
|
|
|
|
|
setLastAction(`已打开页面 ${nodeId} 的上下文菜单`);
|
|
|
|
|
postToHost("tree.page.context-menu", {
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
target: { documentId: nodeId },
|
|
|
|
|
payload: { documentId: nodeId, x: clientX, y: clientY },
|
|
|
|
|
x: clientX,
|
|
|
|
|
y: clientY,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getElementCenter = (element) => {
|
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
|
return {
|
|
|
|
|
x: rect.left + rect.width / 2,
|
|
|
|
|
y: rect.top + rect.height / 2,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const readFileTreeInternalDropPayload = (event) => {
|
|
|
|
|
const raw =
|
|
|
|
|
event.dataTransfer?.getData("application/x-mnote-file-tree") ||
|
|
|
|
|
event.dataTransfer?.getData("text/plain") ||
|
|
|
|
|
"";
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(raw);
|
|
|
|
|
if (
|
|
|
|
|
payload?.type !== "mnote-file-tree-dnd" ||
|
|
|
|
|
payload.version !== 1 ||
|
|
|
|
|
!Array.isArray(payload.rowIds)
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const rowIds = payload.rowIds
|
|
|
|
|
.map((value) => normalizeText(value))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
return rowIds.length > 0 ? rowIds : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const handleRowKeyDown = (event, item) => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
if (event.key === "ArrowDown") {
|
|
|
|
|
event.preventDefault();
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "move_next" }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowUp") {
|
|
|
|
|
event.preventDefault();
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "move_previous" }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowRight") {
|
|
|
|
|
event.preventDefault();
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "expand" }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "ArrowLeft") {
|
|
|
|
|
event.preventDefault();
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "collapse" }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
event.preventDefault();
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "open" }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === "F2") {
|
|
|
|
|
event.preventDefault();
|
2026-05-08 00:41:03 +08:00
|
|
|
beginInlineRename("page", item.nodeId);
|
2026-04-17 23:36:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
event.key === "ContextMenu" ||
|
|
|
|
|
(event.shiftKey && event.key === "F10")
|
|
|
|
|
) {
|
|
|
|
|
event.preventDefault();
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleNavigate = (nodeId) => {
|
|
|
|
|
if (!nodeId) return;
|
|
|
|
|
if (mode === "picker") {
|
|
|
|
|
setLastAction(`已选择页面 ${nodeId}`);
|
|
|
|
|
postToHost("tree.pick", {
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
target: { documentId: nodeId },
|
|
|
|
|
payload: { documentId: nodeId },
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`准备打开页面 ${nodeId}`);
|
|
|
|
|
postToHost("tree.navigate", {
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
target: { documentId: nodeId },
|
|
|
|
|
payload: { documentId: nodeId },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCreate = async (parentId) => {
|
|
|
|
|
const title = "无标题";
|
|
|
|
|
try {
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "create",
|
|
|
|
|
workspaceId,
|
|
|
|
|
parentId,
|
|
|
|
|
title,
|
|
|
|
|
accessScope: "private",
|
|
|
|
|
content: [],
|
|
|
|
|
});
|
|
|
|
|
const documentId =
|
|
|
|
|
typeof result.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
|
|
|
|
: "";
|
|
|
|
|
setStatus("创建页面成功");
|
|
|
|
|
setLastAction(parentId ? "已创建无标题子页面" : "已创建无标题页面");
|
|
|
|
|
postToHost("tree.node.created", {
|
|
|
|
|
documentId,
|
|
|
|
|
target: { documentId },
|
|
|
|
|
payload: { documentId },
|
|
|
|
|
});
|
2026-05-14 05:52:08 +08:00
|
|
|
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
|
|
|
|
|
if (mode === "filetree") {
|
|
|
|
|
beginInlineRename("filetree", `doc:${documentId}`);
|
|
|
|
|
} else {
|
|
|
|
|
beginInlineRename("page", documentId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-14 15:10:33 +08:00
|
|
|
if (documentId) {
|
|
|
|
|
postToHost("tree.navigate", {
|
|
|
|
|
documentId,
|
|
|
|
|
target: { documentId },
|
|
|
|
|
payload: { documentId },
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
scheduleRefresh({
|
|
|
|
|
renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"),
|
|
|
|
|
});
|
2026-04-17 23:36:24 +08:00
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "创建页面失败";
|
|
|
|
|
setStatus(message, "error");
|
|
|
|
|
setLastAction("创建页面失败", "error");
|
|
|
|
|
window.alert(message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
const focusInlineRenameInput = (id) => {
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
const input = appElement.querySelector(
|
|
|
|
|
`.tree-rename-input[data-rename-id="${CSS.escape(id)}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (!(input instanceof HTMLInputElement)) return;
|
|
|
|
|
input.focus();
|
|
|
|
|
const dotIndex = input.value.lastIndexOf(".");
|
|
|
|
|
const end = dotIndex > 0 ? dotIndex : input.value.length;
|
|
|
|
|
input.setSelectionRange(0, end);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const beginInlineRename = (renameMode, id) => {
|
|
|
|
|
inlineRenameState = { mode: renameMode, id, committing: false };
|
|
|
|
|
renderTree();
|
|
|
|
|
focusInlineRenameInput(id);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cancelInlineRename = () => {
|
|
|
|
|
inlineRenameState = { mode: null, id: null, committing: false };
|
|
|
|
|
renderTree();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const commitInlineRename = async (title) => {
|
|
|
|
|
if (!inlineRenameState.mode || !inlineRenameState.id || inlineRenameState.committing) return;
|
|
|
|
|
const trimmed = normalizeText(title);
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
cancelInlineRename();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
inlineRenameState.committing = true;
|
|
|
|
|
const renameMode = inlineRenameState.mode;
|
|
|
|
|
const id = inlineRenameState.id;
|
|
|
|
|
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
|
|
|
|
|
const documentId = renameMode === "filetree"
|
2026-05-18 17:01:35 +08:00
|
|
|
? getFileTreeRowDocumentId(item)
|
2026-05-08 00:41:03 +08:00
|
|
|
: id;
|
2026-05-18 17:01:35 +08:00
|
|
|
if (!documentId) {
|
|
|
|
|
inlineRenameState = { mode: null, id: null, committing: false };
|
|
|
|
|
setLastAction("当前资源暂不支持重命名", "error");
|
|
|
|
|
renderTree();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
try {
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "rename",
|
|
|
|
|
workspaceId,
|
2026-05-08 00:41:03 +08:00
|
|
|
documentId,
|
|
|
|
|
title: trimmed,
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
const resultDocumentId =
|
2026-04-17 23:36:24 +08:00
|
|
|
typeof result.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
2026-05-08 00:41:03 +08:00
|
|
|
: id;
|
2026-04-17 23:36:24 +08:00
|
|
|
setStatus("重命名成功");
|
2026-05-08 00:41:03 +08:00
|
|
|
setLastAction(`已重命名为 ${trimmed}`);
|
2026-04-17 23:36:24 +08:00
|
|
|
postToHost("tree.node.renamed", {
|
2026-05-08 00:41:03 +08:00
|
|
|
documentId: resultDocumentId,
|
|
|
|
|
target: { documentId: resultDocumentId },
|
|
|
|
|
payload: { documentId: resultDocumentId },
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
inlineRenameState = { mode: null, id: null, committing: false };
|
2026-04-17 23:36:24 +08:00
|
|
|
scheduleRefresh();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "重命名失败";
|
|
|
|
|
setStatus(message, "error");
|
|
|
|
|
setLastAction("重命名失败", "error");
|
2026-05-08 00:41:03 +08:00
|
|
|
inlineRenameState = { mode: null, id: null, committing: false };
|
|
|
|
|
renderTree();
|
2026-04-17 23:36:24 +08:00
|
|
|
window.alert(message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
const attachInlineRenameInput = (input, originalTitle) => {
|
|
|
|
|
input.addEventListener("click", (event) => event.stopPropagation());
|
|
|
|
|
input.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
|
|
|
input.addEventListener("keydown", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void commitInlineRename(input.value);
|
|
|
|
|
} else if (event.key === "Escape") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
cancelInlineRename();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
input.addEventListener("blur", () => {
|
|
|
|
|
if (!inlineRenameState.mode || inlineRenameState.committing) return;
|
|
|
|
|
if (input.value === originalTitle) {
|
|
|
|
|
cancelInlineRename();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void commitInlineRename(input.value);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const handleMove = async (nodeId, delta) => {
|
|
|
|
|
const item = itemById.get(nodeId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
const siblings = getSiblings(item.parentNodeId);
|
|
|
|
|
const currentIndex = siblings.findIndex((entry) => entry.nodeId === nodeId);
|
|
|
|
|
if (currentIndex === -1) return;
|
|
|
|
|
const nextIndex = currentIndex + delta;
|
|
|
|
|
if (nextIndex < 0 || nextIndex >= siblings.length) return;
|
|
|
|
|
try {
|
|
|
|
|
const result = await sendCommand({
|
|
|
|
|
action: "move",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: nodeId,
|
|
|
|
|
parentId: item.parentNodeId,
|
|
|
|
|
sortOrder: nextIndex,
|
|
|
|
|
});
|
|
|
|
|
const documentId =
|
|
|
|
|
typeof result.documentId === "string" && result.documentId.trim()
|
|
|
|
|
? result.documentId.trim()
|
|
|
|
|
: nodeId;
|
|
|
|
|
setStatus("移动页面成功");
|
|
|
|
|
setLastAction(delta < 0 ? "页面已上移" : "页面已下移");
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const bindPageRowEvents = (row, item) => {
|
|
|
|
|
if (!(row instanceof HTMLElement) || !item) return;
|
|
|
|
|
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
|
|
|
|
|
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
|
|
|
|
row.dataset.nodeId = item.nodeId;
|
|
|
|
|
row.dataset.shellMode = "page";
|
|
|
|
|
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", item.childCount > 0 ? String(expanded.has(item.nodeId)) : "false");
|
|
|
|
|
row.draggable = true;
|
|
|
|
|
row.dataset.draggable = "true";
|
|
|
|
|
row.addEventListener("focus", () => {
|
|
|
|
|
if (focusedNodeId !== item.nodeId) {
|
|
|
|
|
applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("keydown", (event) => handleRowKeyDown(event, item));
|
|
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
openContextMenu(item.nodeId, event.clientX, event.clientY);
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("dragstart", (event) => {
|
|
|
|
|
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) => {
|
|
|
|
|
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) => {
|
|
|
|
|
const relatedTarget =
|
|
|
|
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
|
|
|
|
if (relatedTarget && row.contains(relatedTarget)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (activePageDropNodeId === item.nodeId) {
|
|
|
|
|
clearPageDropFeedback();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("drop", (event) => {
|
|
|
|
|
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", () => {
|
|
|
|
|
draggingPageNodeId = "";
|
|
|
|
|
clearPageDropFeedback();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
row.querySelectorAll("[data-rust-action]").forEach((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement)) return;
|
|
|
|
|
element.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
const action = normalizeText(element.dataset.rustAction);
|
2026-04-27 10:27:15 +08:00
|
|
|
if (action === "toggle") {
|
|
|
|
|
event.preventDefault();
|
2026-04-28 16:30:51 +08:00
|
|
|
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, element);
|
2026-04-27 10:27:15 +08:00
|
|
|
} else if (action === "open") {
|
2026-04-28 16:30:51 +08:00
|
|
|
applyPageKeyboardAction({ kind: "open", nodeId: item.nodeId }, item, element);
|
2026-04-26 19:35:52 +08:00
|
|
|
} else if (action === "create") {
|
|
|
|
|
void handleCreate(item.nodeId);
|
|
|
|
|
} else if (action === "rename") {
|
2026-05-08 00:41:03 +08:00
|
|
|
beginInlineRename("page", item.nodeId);
|
2026-04-26 19:35:52 +08:00
|
|
|
} else if (action === "menu") {
|
2026-04-28 16:30:51 +08:00
|
|
|
applyPageKeyboardAction({ kind: "context_menu", nodeId: item.nodeId }, item, element);
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 10:27:15 +08:00
|
|
|
const patchPageTreeActiveDom = () => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
const nodeId = normalizeText(row.dataset.nodeId);
|
|
|
|
|
const isFocused = nodeId === focusedNodeId;
|
|
|
|
|
row.dataset.active = String(nodeId === currentActiveDocumentId);
|
|
|
|
|
row.dataset.focused = String(isFocused);
|
|
|
|
|
row.tabIndex = isFocused ? 0 : -1;
|
|
|
|
|
row.dataset.dropFeedback = String(activePageDropNodeId === nodeId);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const patchPageTreeExpansionDom = (nodeId) => {
|
|
|
|
|
if (mode !== "page") return false;
|
|
|
|
|
const normalizedNodeId = normalizeText(nodeId);
|
|
|
|
|
if (!normalizedNodeId) return false;
|
|
|
|
|
const item = itemById.get(normalizedNodeId);
|
|
|
|
|
if (!item) return false;
|
|
|
|
|
const nodeElement = appElement.querySelector(
|
|
|
|
|
`.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (!(nodeElement instanceof HTMLElement)) return false;
|
|
|
|
|
const row = nodeElement.querySelector(
|
|
|
|
|
`:scope > .tree-row[data-node-id="${CSS.escape(normalizedNodeId)}"]`,
|
|
|
|
|
);
|
|
|
|
|
const children = getSiblings(normalizedNodeId);
|
|
|
|
|
const hasChildren = item.childCount > 0 && children.length > 0;
|
|
|
|
|
const isExpanded = hasChildren && expanded.has(normalizedNodeId);
|
|
|
|
|
if (row instanceof HTMLElement) {
|
|
|
|
|
row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false");
|
|
|
|
|
const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]');
|
|
|
|
|
if (toggleButton instanceof HTMLButtonElement) {
|
|
|
|
|
toggleButton.textContent = isExpanded ? "▾" : "▸";
|
|
|
|
|
toggleButton.setAttribute(
|
|
|
|
|
"aria-label",
|
|
|
|
|
`${isExpanded ? "折叠" : "展开"} ${item.title}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!hasChildren) {
|
|
|
|
|
patchPageTreeActiveDom();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
let childrenList = Array.from(nodeElement.children).find(
|
|
|
|
|
(child) => child instanceof HTMLElement && child.classList.contains("tree-children"),
|
|
|
|
|
);
|
|
|
|
|
if (isExpanded) {
|
|
|
|
|
if (!(childrenList instanceof HTMLElement)) {
|
|
|
|
|
childrenList = document.createElement("ul");
|
|
|
|
|
childrenList.className = "tree-children";
|
|
|
|
|
children.forEach((child) => {
|
|
|
|
|
childrenList.appendChild(renderNode(child));
|
|
|
|
|
});
|
|
|
|
|
nodeElement.appendChild(childrenList);
|
|
|
|
|
}
|
|
|
|
|
childrenList.hidden = false;
|
|
|
|
|
childrenList.style.display = "";
|
|
|
|
|
} else if (childrenList instanceof HTMLElement) {
|
|
|
|
|
childrenList.hidden = true;
|
|
|
|
|
childrenList.style.display = "none";
|
|
|
|
|
}
|
|
|
|
|
patchPageTreeActiveDom();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const hydrateInitialPageTree = () => {
|
|
|
|
|
if (mode !== "page") return false;
|
|
|
|
|
const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]');
|
|
|
|
|
if (!(root instanceof HTMLElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
root.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
const nodeId = normalizeText(row.dataset.nodeId);
|
|
|
|
|
const item = itemById.get(nodeId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
bindPageRowEvents(row, item);
|
|
|
|
|
});
|
|
|
|
|
if (focusedNodeId) {
|
|
|
|
|
focusRowElement(focusedNodeId);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const syncFileTreeSelectionDom = () => {
|
|
|
|
|
if (mode !== "filetree") return;
|
|
|
|
|
appElement.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
const rowId = normalizeText(row.dataset.rowId);
|
|
|
|
|
row.dataset.selected = String(Boolean(rowId && selectedFileTreeRowIds.has(rowId)));
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openHydratedFileTreeItem = (item) => {
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(item);
|
2026-05-18 17:01:35 +08:00
|
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
2026-04-26 19:35:52 +08:00
|
|
|
const assetId = getFileTreeRowAssetId(item);
|
|
|
|
|
if (item.rowKind === "document" || item.rowKind === "index") {
|
|
|
|
|
handleNavigate(documentId || item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
|
|
|
|
postToHost("tree.asset.open", {
|
2026-05-18 17:01:35 +08:00
|
|
|
documentId: ownerDocumentId || null,
|
2026-04-26 19:35:52 +08:00
|
|
|
assetId: assetId || null,
|
2026-05-13 22:43:16 +08:00
|
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
2026-05-18 17:01:35 +08:00
|
|
|
target: { documentId: ownerDocumentId || null },
|
2026-04-26 19:35:52 +08:00
|
|
|
payload: {
|
2026-05-18 17:01:35 +08:00
|
|
|
documentId: ownerDocumentId || null,
|
2026-04-26 19:35:52 +08:00
|
|
|
assetId: assetId || null,
|
|
|
|
|
rowId: item.rowId,
|
|
|
|
|
rowKind: item.rowKind,
|
2026-05-13 22:43:16 +08:00
|
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
2026-04-26 19:35:52 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const postHydratedFileTreeDropToHost = (type, target, extra = {}) => {
|
|
|
|
|
postToHost(type, {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowId: target.rowId,
|
|
|
|
|
rowKind: target.rowKind,
|
|
|
|
|
targetRowId: target.rowId,
|
|
|
|
|
targetRowKind: target.rowKind,
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
assetId: target.assetId,
|
|
|
|
|
...extra,
|
|
|
|
|
payload: {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowId: target.rowId,
|
|
|
|
|
rowKind: target.rowKind,
|
|
|
|
|
targetRowId: target.rowId,
|
|
|
|
|
targetRowKind: target.rowKind,
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
assetId: target.assetId,
|
|
|
|
|
...extra,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const attachHydratedFileTreeDragSource = (row, item) => {
|
|
|
|
|
row.draggable = true;
|
|
|
|
|
row.addEventListener("dragstart", (event) => {
|
|
|
|
|
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
|
|
|
|
|
draggingFileTreeRowIds = rowIds;
|
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
const payload = JSON.stringify({
|
|
|
|
|
type: "mnote-file-tree-dnd",
|
|
|
|
|
version: 1,
|
|
|
|
|
rowIds,
|
|
|
|
|
});
|
|
|
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
|
|
|
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
|
|
|
|
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
|
|
|
|
|
event.dataTransfer.setData("text/plain", payload);
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("dragend", () => {
|
|
|
|
|
draggingFileTreeRowIds = [];
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bindFileTreeRootEvents = (root) => {
|
|
|
|
|
root.dataset.dropTarget = String(activeFileTreeRootDrop);
|
|
|
|
|
root.addEventListener("mousedown", (event) => {
|
|
|
|
|
if (event.target !== event.currentTarget) return;
|
|
|
|
|
clearFileTreeSelection();
|
|
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
root.addEventListener("contextmenu", (event) => {
|
|
|
|
|
if (event.target !== event.currentTarget) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
clearFileTreeSelection();
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: null,
|
|
|
|
|
assetId: null,
|
|
|
|
|
rowId: null,
|
|
|
|
|
rowKind: "root",
|
|
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
root.addEventListener("dragover", (event) => {
|
|
|
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
|
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
|
|
|
if (!internalRowIds && files.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
2026-05-08 00:41:03 +08:00
|
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
|
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
event.dataTransfer.dropEffect =
|
|
|
|
|
files.length > 0 || event.altKey ? "copy" : "move";
|
|
|
|
|
}
|
|
|
|
|
setFileTreeDropFeedback(target);
|
|
|
|
|
});
|
|
|
|
|
root.addEventListener("dragleave", (event) => {
|
|
|
|
|
const relatedTarget =
|
|
|
|
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
|
|
|
|
if (relatedTarget && root.contains(relatedTarget)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
});
|
|
|
|
|
root.addEventListener("drop", (event) => {
|
|
|
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
|
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
|
|
|
if (!internalRowIds && files.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
2026-05-08 00:41:03 +08:00
|
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
|
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
if (files.length > 0) {
|
2026-05-08 00:41:03 +08:00
|
|
|
if (sourceKind === "local_folder") {
|
|
|
|
|
void executeLocalFileTreeExternalDrop(target, files);
|
|
|
|
|
} else {
|
|
|
|
|
void (async () => {
|
|
|
|
|
const accepted = await runFileTreePreflight("dropFiles", { target, files });
|
|
|
|
|
if (!accepted) return;
|
|
|
|
|
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
|
|
|
|
|
postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, {
|
|
|
|
|
files,
|
|
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
} else {
|
2026-05-08 00:41:03 +08:00
|
|
|
if (sourceKind === "local_folder") {
|
|
|
|
|
void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true);
|
|
|
|
|
} else {
|
|
|
|
|
void (async () => {
|
|
|
|
|
const accepted = await runFileTreePreflight("move", {
|
|
|
|
|
target,
|
|
|
|
|
rowIds: internalRowIds,
|
|
|
|
|
copy: event.altKey === true,
|
|
|
|
|
});
|
|
|
|
|
if (!accepted) return;
|
|
|
|
|
setLastAction(
|
|
|
|
|
event.altKey
|
|
|
|
|
? `已发送复制拖放到 ${target.rowId || "根目录"}`
|
|
|
|
|
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
|
|
|
|
|
);
|
|
|
|
|
postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, {
|
|
|
|
|
rowIds: internalRowIds,
|
|
|
|
|
copy: event.altKey === true,
|
|
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
draggingFileTreeRowIds = [];
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bindFileTreeRowEvents = (row, item) => {
|
|
|
|
|
if (!(row instanceof HTMLElement) || !item) return;
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
2026-05-18 17:01:35 +08:00
|
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
2026-04-26 19:35:52 +08:00
|
|
|
const assetId = getFileTreeRowAssetId(item) || null;
|
|
|
|
|
row.dataset.active = String(
|
|
|
|
|
item.rowKind === "document" && documentId === currentActiveDocumentId
|
|
|
|
|
);
|
|
|
|
|
row.dataset.nodeId = item.nodeId;
|
|
|
|
|
row.dataset.rowId = item.rowId;
|
|
|
|
|
row.dataset.rowKind = item.rowKind;
|
|
|
|
|
row.dataset.documentId = documentId || "";
|
2026-05-18 17:01:35 +08:00
|
|
|
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
2026-04-26 19:35:52 +08:00
|
|
|
row.dataset.assetId = assetId || "";
|
2026-05-13 22:43:16 +08:00
|
|
|
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
|
|
|
|
? JSON.stringify(item.resourceMeta.objectIdentity)
|
|
|
|
|
: "";
|
|
|
|
|
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
2026-04-26 19:35:52 +08:00
|
|
|
row.dataset.shellMode = "filetree";
|
|
|
|
|
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
|
|
|
|
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
2026-05-08 00:41:03 +08:00
|
|
|
if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
|
|
|
|
|
row.dataset.dropPosition = activeFileTreeDropPosition;
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
row.tabIndex = 0;
|
|
|
|
|
row.setAttribute("role", "treeitem");
|
|
|
|
|
row.setAttribute("aria-level", String(item.depth + 1));
|
|
|
|
|
row.setAttribute(
|
|
|
|
|
"aria-expanded",
|
|
|
|
|
canExpandFileTreeRow(item) ? String(expanded.has(item.nodeId)) : "false",
|
|
|
|
|
);
|
|
|
|
|
row.addEventListener("click", (event) => {
|
|
|
|
|
selectFileTreeRow(item.rowId, event);
|
2026-05-08 00:41:03 +08:00
|
|
|
row.focus({ preventScroll: true });
|
2026-04-26 19:35:52 +08:00
|
|
|
syncFileTreeSelectionDom();
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("dblclick", () => {
|
|
|
|
|
openHydratedFileTreeItem(item);
|
|
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item));
|
2026-04-26 19:35:52 +08:00
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
selectFileTreeContextRow(item.rowId);
|
|
|
|
|
syncFileTreeSelectionDom();
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId: item.rowId,
|
|
|
|
|
rowKind: item.rowKind,
|
|
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
attachHydratedFileTreeDragSource(row, item);
|
2026-05-08 00:41:03 +08:00
|
|
|
patchFileTreeCutDecoration();
|
2026-04-26 19:35:52 +08:00
|
|
|
row.querySelectorAll("[data-rust-action]").forEach((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement)) return;
|
|
|
|
|
element.addEventListener("click", (event) => {
|
|
|
|
|
const action = normalizeText(element.dataset.rustAction);
|
|
|
|
|
if (action === "open") {
|
|
|
|
|
openHydratedFileTreeItem(item);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (action === "menu") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
selectFileTreeContextRow(item.rowId);
|
|
|
|
|
syncFileTreeSelectionDom();
|
|
|
|
|
const center = getElementCenter(element);
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId: item.rowId,
|
|
|
|
|
rowKind: item.rowKind,
|
|
|
|
|
clientX: center.x,
|
|
|
|
|
clientY: center.y,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hydrateInitialFileTree = () => {
|
|
|
|
|
if (mode !== "filetree") return false;
|
|
|
|
|
const root = appElement.querySelector('[data-rust-filetree-renderer="initial_v1"]');
|
|
|
|
|
if (!(root instanceof HTMLElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
visibleFileTreeRowIds = [];
|
|
|
|
|
bindFileTreeRootEvents(root);
|
|
|
|
|
root.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
const rowId = normalizeText(row.dataset.rowId);
|
|
|
|
|
const nodeId = normalizeText(row.dataset.nodeId);
|
|
|
|
|
const item = fileTreeRowById.get(rowId) || itemById.get(nodeId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
visibleFileTreeRowIds.push(item.rowId);
|
|
|
|
|
bindFileTreeRowEvents(row, item);
|
|
|
|
|
});
|
|
|
|
|
normalizeFileTreeSelectionForVisibleRows();
|
|
|
|
|
syncFileTreeSelectionDom();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bindPickerRootEvents = (row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
row.dataset.focused = String(resolvePickerRootFocused());
|
|
|
|
|
row.addEventListener("click", () => {
|
2026-04-27 10:27:15 +08:00
|
|
|
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
|
|
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
2026-04-26 19:35:52 +08:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bindPickerRowEvents = (row, item) => {
|
|
|
|
|
if (!(row instanceof HTMLElement) || !item) return;
|
|
|
|
|
row.dataset.nodeId = item.nodeId;
|
|
|
|
|
row.dataset.shellMode = "picker";
|
|
|
|
|
row.dataset.focused = String(
|
|
|
|
|
currentActivePickerItemKey === item.nodeId ||
|
|
|
|
|
(!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId),
|
|
|
|
|
);
|
|
|
|
|
row.addEventListener("click", () => {
|
2026-04-27 10:27:15 +08:00
|
|
|
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
|
|
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
2026-04-26 19:35:52 +08:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const focusPickerRowElement = (pickerItemKey) => {
|
|
|
|
|
const normalizedItemKey = normalizeText(pickerItemKey);
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
const row =
|
|
|
|
|
normalizedItemKey === "__root__"
|
|
|
|
|
? appElement.querySelector('[data-rust-rendered-row="picker-root"]')
|
|
|
|
|
: appElement.querySelector(
|
|
|
|
|
`.tree-row[data-node-id="${CSS.escape(normalizedItemKey)}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
row.focus({ preventScroll: true });
|
|
|
|
|
row.scrollIntoView({ block: "nearest" });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const patchPickerActiveDom = () => {
|
|
|
|
|
if (mode !== "picker") return;
|
|
|
|
|
appElement
|
|
|
|
|
.querySelectorAll('[data-rust-rendered-row="picker"], [data-rust-rendered-row="picker-root"]')
|
|
|
|
|
.forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
const nodeId = normalizeText(row.dataset.nodeId);
|
|
|
|
|
const isRoot = row.dataset.rustRenderedRow === "picker-root";
|
|
|
|
|
const isFocused = isRoot
|
|
|
|
|
? currentActivePickerItemKey === "__root__"
|
|
|
|
|
: currentActivePickerItemKey === nodeId ||
|
|
|
|
|
(!currentActivePickerItemKey && currentActiveDocumentId === nodeId);
|
|
|
|
|
row.dataset.focused = String(isFocused);
|
|
|
|
|
row.tabIndex = isFocused ? 0 : -1;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hydrateInitialPickerTree = () => {
|
|
|
|
|
if (mode !== "picker") return false;
|
|
|
|
|
const root = appElement.querySelector('[data-rust-picker-renderer="initial_v1"]');
|
|
|
|
|
if (!(root instanceof HTMLElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
root.querySelectorAll('[data-rust-rendered-row="picker-root"]').forEach((row) => {
|
|
|
|
|
bindPickerRootEvents(row);
|
|
|
|
|
});
|
|
|
|
|
root.querySelectorAll('[data-rust-rendered-row="picker"]').forEach((row) => {
|
|
|
|
|
if (!(row instanceof HTMLElement)) return;
|
|
|
|
|
const nodeId = normalizeText(row.dataset.nodeId);
|
|
|
|
|
const item = itemById.get(nodeId);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
bindPickerRowEvents(row, item);
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hydrateInitialRenderer = () => {
|
|
|
|
|
if (mode === "page") {
|
|
|
|
|
const usedRustInitialPageRenderer = hydrateInitialPageTree();
|
|
|
|
|
return usedRustInitialPageRenderer;
|
|
|
|
|
}
|
|
|
|
|
if (mode === "filetree") {
|
|
|
|
|
return hydrateInitialFileTree();
|
|
|
|
|
}
|
|
|
|
|
if (mode === "picker") {
|
|
|
|
|
return hydrateInitialPickerTree();
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const createKindBadge = (kind) => {
|
|
|
|
|
const badge = document.createElement("span");
|
|
|
|
|
badge.className = "tree-kind-badge";
|
|
|
|
|
badge.dataset.kind = kind;
|
|
|
|
|
badge.innerHTML =
|
|
|
|
|
kind === "mindmap"
|
|
|
|
|
? ICONS.mindmap
|
|
|
|
|
: kind === "table"
|
|
|
|
|
? ICONS.table
|
2026-04-26 04:29:23 +08:00
|
|
|
: 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"
|
2026-04-17 23:36:24 +08:00
|
|
|
? ICONS.page
|
|
|
|
|
: ICONS.file;
|
|
|
|
|
return badge;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createActionButton = (icon, testId, title, onClick, disabled) => {
|
|
|
|
|
const button = document.createElement("button");
|
|
|
|
|
button.type = "button";
|
|
|
|
|
button.className = "tree-action";
|
|
|
|
|
button.dataset.testid = testId;
|
|
|
|
|
button.title = title;
|
|
|
|
|
button.setAttribute("aria-label", title);
|
|
|
|
|
button.innerHTML = icon;
|
|
|
|
|
button.disabled = disabled || busy;
|
|
|
|
|
button.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
onClick(button);
|
|
|
|
|
});
|
|
|
|
|
return button;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderNode = (item) => {
|
|
|
|
|
const hasChildren = item.childCount > 0;
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className = "tree-row";
|
2026-04-26 04:29:23 +08:00
|
|
|
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
|
2026-04-17 23:36:24 +08:00
|
|
|
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
|
|
|
|
row.dataset.nodeId = item.nodeId;
|
|
|
|
|
row.dataset.shellMode = mode;
|
2026-04-26 04:29:23 +08:00
|
|
|
row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId);
|
2026-04-17 23:36:24 +08:00
|
|
|
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");
|
2026-04-26 04:29:23 +08:00
|
|
|
row.draggable = mode === "page";
|
|
|
|
|
row.dataset.draggable = String(mode === "page");
|
2026-04-17 23:36:24 +08:00
|
|
|
row.addEventListener("focus", () => {
|
|
|
|
|
if (focusedNodeId !== item.nodeId) {
|
2026-04-26 19:35:52 +08:00
|
|
|
applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row);
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("keydown", (event) => handleRowKeyDown(event, item));
|
|
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
if (mode !== "page") return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
openContextMenu(item.nodeId, event.clientX, event.clientY);
|
|
|
|
|
});
|
2026-04-26 04:29:23 +08:00
|
|
|
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();
|
|
|
|
|
});
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
if (hasChildren) {
|
|
|
|
|
const toggleButton = document.createElement("button");
|
|
|
|
|
toggleButton.type = "button";
|
|
|
|
|
toggleButton.className = "tree-toggle";
|
|
|
|
|
toggleButton.setAttribute("data-testid", "tree-node-toggle");
|
|
|
|
|
toggleButton.setAttribute(
|
|
|
|
|
"aria-label",
|
|
|
|
|
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
|
|
|
|
|
);
|
|
|
|
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
|
|
|
|
toggleButton.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
2026-04-28 16:30:51 +08:00
|
|
|
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
|
|
|
|
row.appendChild(toggleButton);
|
|
|
|
|
} else {
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
row.appendChild(spacer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
row.appendChild(createKindBadge("page"));
|
|
|
|
|
|
|
|
|
|
const linkButton = document.createElement("button");
|
|
|
|
|
linkButton.type = "button";
|
|
|
|
|
linkButton.className = "tree-link";
|
|
|
|
|
linkButton.setAttribute("data-testid", "tree-node-open");
|
|
|
|
|
linkButton.setAttribute("aria-label", `打开 ${item.title}`);
|
2026-04-27 10:27:15 +08:00
|
|
|
linkButton.addEventListener("click", () => {
|
|
|
|
|
if (mode === "picker") {
|
|
|
|
|
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
|
|
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handleNavigate(item.nodeId);
|
|
|
|
|
});
|
2026-04-17 23:36:24 +08:00
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
if (inlineRenameState.mode === "page" && inlineRenameState.id === item.nodeId) {
|
|
|
|
|
const renameInput = document.createElement("input");
|
|
|
|
|
renameInput.type = "text";
|
|
|
|
|
renameInput.className = "tree-rename-input";
|
|
|
|
|
renameInput.value = item.title;
|
|
|
|
|
renameInput.setAttribute("aria-label", `重命名 ${item.title}`);
|
|
|
|
|
renameInput.dataset.renameId = item.nodeId;
|
|
|
|
|
attachInlineRenameInput(renameInput, item.title);
|
|
|
|
|
linkButton.appendChild(renameInput);
|
|
|
|
|
} else {
|
|
|
|
|
const titleElement = document.createElement("span");
|
|
|
|
|
titleElement.className = "tree-link-title";
|
|
|
|
|
titleElement.textContent = item.title;
|
|
|
|
|
linkButton.appendChild(titleElement);
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
const metaElement = document.createElement("span");
|
|
|
|
|
metaElement.className = "tree-link-meta";
|
|
|
|
|
metaElement.textContent = `${item.nodeId} · ${item.childCount} 个子页面`;
|
|
|
|
|
linkButton.appendChild(metaElement);
|
|
|
|
|
row.appendChild(linkButton);
|
|
|
|
|
|
|
|
|
|
const actions = document.createElement("div");
|
|
|
|
|
actions.className = "tree-actions";
|
2026-04-18 09:38:16 +08:00
|
|
|
const siblingRows = getSiblings(item.parentNodeId);
|
|
|
|
|
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
|
2026-04-17 23:36:24 +08:00
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.add,
|
|
|
|
|
"tree-action-create",
|
|
|
|
|
`在 ${item.title} 下新建子页面`,
|
|
|
|
|
() => handleCreate(item.nodeId),
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (mode === "page") {
|
2026-04-18 09:38:16 +08:00
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.edit,
|
|
|
|
|
"tree-action-rename",
|
|
|
|
|
`重命名 ${item.title}`,
|
2026-05-08 00:41:03 +08:00
|
|
|
() => beginInlineRename("page", item.nodeId),
|
2026-04-18 09:38:16 +08:00
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.up,
|
|
|
|
|
"tree-action-move-up",
|
|
|
|
|
`上移 ${item.title}`,
|
|
|
|
|
() => void handleMove(item.nodeId, -1),
|
|
|
|
|
siblingIndex <= 0,
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.more,
|
|
|
|
|
"tree-action-menu",
|
|
|
|
|
`打开 ${item.title} 的更多操作`,
|
|
|
|
|
(button) => {
|
|
|
|
|
const center = getElementCenter(button);
|
|
|
|
|
openContextMenu(
|
|
|
|
|
item.nodeId,
|
|
|
|
|
center.x,
|
|
|
|
|
center.y,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
row.appendChild(actions);
|
|
|
|
|
|
|
|
|
|
const nodeElement = document.createElement("li");
|
|
|
|
|
nodeElement.className = "tree-node";
|
|
|
|
|
nodeElement.dataset.nodeId = item.nodeId;
|
|
|
|
|
nodeElement.appendChild(row);
|
|
|
|
|
|
|
|
|
|
if (hasChildren && expanded.has(item.nodeId)) {
|
|
|
|
|
const children = getSiblings(item.nodeId);
|
|
|
|
|
if (children.length > 0) {
|
|
|
|
|
const childrenList = document.createElement("ul");
|
|
|
|
|
childrenList.className = "tree-children";
|
|
|
|
|
children.forEach((child) => {
|
|
|
|
|
childrenList.appendChild(renderNode(child));
|
|
|
|
|
});
|
|
|
|
|
nodeElement.appendChild(childrenList);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodeElement;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderFileTree = () => {
|
|
|
|
|
appElement.innerHTML = "";
|
2026-04-24 06:10:18 +08:00
|
|
|
visibleFileTreeRowIds = [];
|
|
|
|
|
clearFileTreeDropFeedback();
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
const fileRoot = document.createElement("div");
|
|
|
|
|
fileRoot.className = "tree-root";
|
|
|
|
|
fileRoot.setAttribute("role", "tree");
|
2026-04-24 06:10:18 +08:00
|
|
|
fileRoot.dataset.dropTarget = String(activeFileTreeRootDrop);
|
|
|
|
|
fileRoot.addEventListener("mousedown", (event) => {
|
|
|
|
|
if (event.target !== event.currentTarget) return;
|
|
|
|
|
clearFileTreeSelection();
|
|
|
|
|
});
|
2026-04-17 23:36:24 +08:00
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const openFileTreeItem = (item) => {
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(item);
|
2026-05-18 17:01:35 +08:00
|
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
2026-04-24 06:10:18 +08:00
|
|
|
const assetId = getFileTreeRowAssetId(item);
|
2026-05-08 00:41:03 +08:00
|
|
|
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
2026-04-24 06:10:18 +08:00
|
|
|
handleNavigate(documentId || item.nodeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
|
|
|
|
postToHost("tree.asset.open", {
|
2026-05-18 17:01:35 +08:00
|
|
|
documentId: ownerDocumentId || null,
|
2026-04-24 06:10:18 +08:00
|
|
|
assetId: assetId || null,
|
2026-05-13 22:43:16 +08:00
|
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
2026-05-18 17:01:35 +08:00
|
|
|
target: { documentId: ownerDocumentId || null },
|
2026-04-24 06:10:18 +08:00
|
|
|
payload: {
|
2026-05-18 17:01:35 +08:00
|
|
|
documentId: ownerDocumentId || null,
|
2026-04-24 06:10:18 +08:00
|
|
|
assetId: assetId || null,
|
|
|
|
|
rowId: item.rowId,
|
|
|
|
|
rowKind: item.rowKind,
|
2026-05-13 22:43:16 +08:00
|
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
2026-04-24 06:10:18 +08:00
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
const postFileTreeDropToHost = (type, target, extra = {}) => {
|
|
|
|
|
postToHost(type, {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowId: target.rowId,
|
|
|
|
|
rowKind: target.rowKind,
|
|
|
|
|
targetRowId: target.rowId,
|
|
|
|
|
targetRowKind: target.rowKind,
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
assetId: target.assetId,
|
|
|
|
|
...extra,
|
|
|
|
|
payload: {
|
|
|
|
|
workspaceId,
|
|
|
|
|
rowId: target.rowId,
|
|
|
|
|
rowKind: target.rowKind,
|
|
|
|
|
targetRowId: target.rowId,
|
|
|
|
|
targetRowKind: target.rowKind,
|
|
|
|
|
documentId: target.documentId,
|
|
|
|
|
assetId: target.assetId,
|
|
|
|
|
...extra,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const attachFileTreeDragSource = (row, item) => {
|
|
|
|
|
row.draggable = true;
|
|
|
|
|
row.addEventListener("dragstart", (event) => {
|
|
|
|
|
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
|
|
|
|
|
draggingFileTreeRowIds = rowIds;
|
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
const payload = JSON.stringify({
|
|
|
|
|
type: "mnote-file-tree-dnd",
|
|
|
|
|
version: 1,
|
|
|
|
|
rowIds,
|
|
|
|
|
});
|
|
|
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
|
|
|
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
|
|
|
|
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
|
|
|
|
|
event.dataTransfer.setData("text/plain", payload);
|
|
|
|
|
}
|
|
|
|
|
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
|
|
|
|
|
});
|
|
|
|
|
row.addEventListener("dragend", () => {
|
|
|
|
|
draggingFileTreeRowIds = [];
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const appendFileTreeRow = (container, item) => {
|
|
|
|
|
const children = getSiblings(item.nodeId);
|
|
|
|
|
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
|
|
|
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
2026-05-18 17:01:35 +08:00
|
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
2026-04-24 06:10:18 +08:00
|
|
|
const assetId = getFileTreeRowAssetId(item) || null;
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className = "tree-row";
|
2026-04-24 06:10:18 +08:00
|
|
|
row.style.marginLeft = `${item.depth * 22}px`;
|
2026-04-26 04:29:23 +08:00
|
|
|
row.dataset.active = String(
|
|
|
|
|
item.rowKind === "document" && documentId === currentActiveDocumentId
|
|
|
|
|
);
|
2026-04-24 06:10:18 +08:00
|
|
|
row.dataset.nodeId = item.nodeId;
|
|
|
|
|
row.dataset.rowId = item.rowId;
|
|
|
|
|
row.dataset.rowKind = item.rowKind;
|
|
|
|
|
row.dataset.documentId = documentId || "";
|
2026-05-18 17:01:35 +08:00
|
|
|
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
2026-04-24 06:10:18 +08:00
|
|
|
row.dataset.assetId = assetId || "";
|
2026-05-13 22:43:16 +08:00
|
|
|
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
|
|
|
|
? JSON.stringify(item.resourceMeta.objectIdentity)
|
|
|
|
|
: "";
|
|
|
|
|
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
2026-04-17 23:36:24 +08:00
|
|
|
row.dataset.shellMode = "filetree";
|
2026-04-24 06:10:18 +08:00
|
|
|
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
|
|
|
|
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
2026-05-08 00:41:03 +08:00
|
|
|
if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
|
|
|
|
|
row.dataset.dropPosition = activeFileTreeDropPosition;
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
row.setAttribute(
|
|
|
|
|
"data-testid",
|
|
|
|
|
item.rowKind === "document"
|
|
|
|
|
? "filetree-doc-row"
|
|
|
|
|
: item.rowKind === "index"
|
|
|
|
|
? "filetree-index-row"
|
|
|
|
|
: "filetree-asset-row",
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
row.tabIndex = 0;
|
|
|
|
|
row.setAttribute("role", "treeitem");
|
2026-04-24 06:10:18 +08:00
|
|
|
row.setAttribute("aria-level", String(item.depth + 1));
|
2026-04-17 23:36:24 +08:00
|
|
|
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
|
2026-04-24 06:10:18 +08:00
|
|
|
visibleFileTreeRowIds.push(item.rowId);
|
|
|
|
|
|
|
|
|
|
row.addEventListener("click", (event) => {
|
|
|
|
|
selectFileTreeRow(item.rowId, event);
|
2026-04-17 23:36:24 +08:00
|
|
|
renderTree();
|
2026-05-08 00:41:03 +08:00
|
|
|
const nextRow = appElement.querySelector(
|
|
|
|
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(item.rowId)}"]`,
|
|
|
|
|
);
|
|
|
|
|
if (nextRow instanceof HTMLElement) {
|
|
|
|
|
nextRow.focus({ preventScroll: true });
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
2026-04-24 06:10:18 +08:00
|
|
|
row.addEventListener("dblclick", () => {
|
|
|
|
|
openFileTreeItem(item);
|
|
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item));
|
2026-04-17 23:36:24 +08:00
|
|
|
row.addEventListener("contextmenu", (event) => {
|
|
|
|
|
event.preventDefault();
|
2026-04-24 06:10:18 +08:00
|
|
|
selectFileTreeContextRow(item.rowId);
|
2026-04-17 23:36:24 +08:00
|
|
|
renderTree();
|
|
|
|
|
openFileTreeContextMenu({
|
2026-04-24 06:10:18 +08:00
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId: item.rowId,
|
|
|
|
|
rowKind: item.rowKind,
|
2026-04-17 23:36:24 +08:00
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-24 06:10:18 +08:00
|
|
|
attachFileTreeDragSource(row, item);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
if (hasBranches) {
|
|
|
|
|
const toggleButton = document.createElement("button");
|
|
|
|
|
toggleButton.type = "button";
|
|
|
|
|
toggleButton.className = "tree-toggle";
|
2026-04-24 06:10:18 +08:00
|
|
|
toggleButton.setAttribute(
|
|
|
|
|
"aria-label",
|
|
|
|
|
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
|
|
|
|
toggleButton.addEventListener("click", (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleExpand(item.nodeId);
|
|
|
|
|
});
|
|
|
|
|
row.appendChild(toggleButton);
|
|
|
|
|
} else {
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
row.appendChild(spacer);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
row.appendChild(createKindBadge(getFileTreeRowIconKind(item)));
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const linkButton = document.createElement("button");
|
|
|
|
|
linkButton.type = "button";
|
|
|
|
|
linkButton.className = "tree-link";
|
2026-04-24 06:10:18 +08:00
|
|
|
if (item.rowKind === "document") {
|
|
|
|
|
linkButton.setAttribute("data-testid", "filetree-doc-open");
|
|
|
|
|
} else if (item.rowKind === "asset" || item.rowKind === "asset_folder") {
|
|
|
|
|
linkButton.setAttribute("data-testid", "filetree-asset-open");
|
|
|
|
|
}
|
|
|
|
|
linkButton.addEventListener("click", () => openFileTreeItem(item));
|
2026-05-08 00:41:03 +08:00
|
|
|
if (inlineRenameState.mode === "filetree" && inlineRenameState.id === item.rowId) {
|
|
|
|
|
const renameInput = document.createElement("input");
|
|
|
|
|
renameInput.type = "text";
|
|
|
|
|
renameInput.className = "tree-rename-input";
|
|
|
|
|
renameInput.value = item.title;
|
|
|
|
|
renameInput.setAttribute("aria-label", `重命名 ${item.title}`);
|
|
|
|
|
renameInput.dataset.renameId = item.rowId;
|
|
|
|
|
attachInlineRenameInput(renameInput, item.title);
|
|
|
|
|
linkButton.appendChild(renameInput);
|
|
|
|
|
} else {
|
|
|
|
|
const title = document.createElement("span");
|
|
|
|
|
title.className = "tree-link-title";
|
|
|
|
|
title.textContent = item.title;
|
|
|
|
|
linkButton.appendChild(title);
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
const meta = document.createElement("span");
|
|
|
|
|
meta.className = "tree-link-meta";
|
2026-04-24 06:10:18 +08:00
|
|
|
meta.textContent = getFileTreeRowMetaLabel(item);
|
2026-04-17 23:36:24 +08:00
|
|
|
linkButton.appendChild(meta);
|
|
|
|
|
row.appendChild(linkButton);
|
2026-04-24 06:10:18 +08:00
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
const actions = document.createElement("div");
|
|
|
|
|
actions.className = "tree-actions";
|
2026-05-18 17:01:35 +08:00
|
|
|
if (getFileTreeRowDocumentId(item)) {
|
|
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.edit,
|
|
|
|
|
"filetree-action-rename",
|
|
|
|
|
`重命名 ${item.title}`,
|
|
|
|
|
() => beginInlineRename("filetree", item.rowId),
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
actions.appendChild(
|
|
|
|
|
createActionButton(
|
|
|
|
|
ICONS.more,
|
|
|
|
|
"filetree-action-menu",
|
|
|
|
|
`打开 ${item.title} 的更多操作`,
|
|
|
|
|
(button) => {
|
|
|
|
|
const center = getElementCenter(button);
|
|
|
|
|
openFileTreeContextMenu({
|
2026-04-24 06:10:18 +08:00
|
|
|
documentId,
|
|
|
|
|
assetId,
|
|
|
|
|
rowId: item.rowId,
|
|
|
|
|
rowKind: item.rowKind,
|
2026-04-17 23:36:24 +08:00
|
|
|
clientX: center.x,
|
|
|
|
|
clientY: center.y,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
row.appendChild(actions);
|
2026-04-24 06:10:18 +08:00
|
|
|
container.appendChild(row);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
if (!hasBranches || !expanded.has(item.nodeId)) return;
|
|
|
|
|
children.forEach((child) => appendFileTreeRow(container, child));
|
2026-04-17 23:36:24 +08:00
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
fileRoot.addEventListener("dragover", (event) => {
|
|
|
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
|
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
|
|
|
if (!internalRowIds && files.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
2026-05-08 00:41:03 +08:00
|
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
|
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
event.dataTransfer.dropEffect =
|
|
|
|
|
files.length > 0 || event.altKey ? "copy" : "move";
|
|
|
|
|
}
|
|
|
|
|
setFileTreeDropFeedback(target);
|
|
|
|
|
});
|
|
|
|
|
fileRoot.addEventListener("dragleave", (event) => {
|
|
|
|
|
const relatedTarget =
|
|
|
|
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
|
|
|
|
if (relatedTarget && fileRoot.contains(relatedTarget)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
});
|
2026-05-08 00:41:03 +08:00
|
|
|
fileRoot.addEventListener("contextmenu", (event) => {
|
|
|
|
|
if (event.target !== event.currentTarget) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
clearFileTreeSelection();
|
|
|
|
|
openFileTreeContextMenu({
|
|
|
|
|
documentId: null,
|
|
|
|
|
assetId: null,
|
|
|
|
|
rowId: null,
|
|
|
|
|
rowKind: "root",
|
|
|
|
|
clientX: event.clientX,
|
|
|
|
|
clientY: event.clientY,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-24 06:10:18 +08:00
|
|
|
fileRoot.addEventListener("drop", (event) => {
|
|
|
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
|
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
|
|
|
if (!internalRowIds && files.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
2026-05-08 00:41:03 +08:00
|
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
|
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
if (files.length > 0) {
|
2026-05-08 00:41:03 +08:00
|
|
|
if (sourceKind === "local_folder") {
|
|
|
|
|
void executeLocalFileTreeExternalDrop(target, files);
|
|
|
|
|
} else {
|
|
|
|
|
void (async () => {
|
|
|
|
|
const accepted = await runFileTreePreflight("dropFiles", { target, files });
|
|
|
|
|
if (!accepted) return;
|
|
|
|
|
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
|
|
|
|
|
postFileTreeDropToHost("tree.filetree.external-drop", target, {
|
|
|
|
|
files,
|
|
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
} else {
|
2026-05-08 00:41:03 +08:00
|
|
|
if (sourceKind === "local_folder") {
|
|
|
|
|
void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true);
|
|
|
|
|
} else {
|
|
|
|
|
void (async () => {
|
|
|
|
|
const accepted = await runFileTreePreflight("move", {
|
|
|
|
|
target,
|
|
|
|
|
rowIds: internalRowIds,
|
|
|
|
|
copy: event.altKey === true,
|
|
|
|
|
});
|
|
|
|
|
if (!accepted) return;
|
|
|
|
|
setLastAction(
|
|
|
|
|
event.altKey
|
|
|
|
|
? `已发送复制拖放到 ${target.rowId || "根目录"}`
|
|
|
|
|
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
|
|
|
|
|
);
|
|
|
|
|
postFileTreeDropToHost("tree.filetree.internal-drop", target, {
|
|
|
|
|
rowIds: internalRowIds,
|
|
|
|
|
copy: event.altKey === true,
|
|
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
}
|
|
|
|
|
draggingFileTreeRowIds = [];
|
|
|
|
|
clearFileTreeDropFeedback();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
if (roots.length === 0) {
|
|
|
|
|
const empty = document.createElement("div");
|
|
|
|
|
empty.className = "tree-empty";
|
|
|
|
|
empty.textContent = "当前 file tree 没有可渲染的页面。";
|
2026-04-24 06:10:18 +08:00
|
|
|
fileRoot.appendChild(empty);
|
|
|
|
|
} else {
|
|
|
|
|
roots.forEach((item) => appendFileTreeRow(fileRoot, item));
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
normalizeFileTreeSelectionForVisibleRows();
|
2026-04-17 23:36:24 +08:00
|
|
|
appElement.appendChild(fileRoot);
|
2026-05-08 00:41:03 +08:00
|
|
|
patchFileTreeCutDecoration();
|
2026-04-17 23:36:24 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderTree = () => {
|
|
|
|
|
if (mode === "filetree") {
|
|
|
|
|
renderFileTree();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
appElement.innerHTML = "";
|
|
|
|
|
if (mode === "picker" && allowRootPick) {
|
|
|
|
|
const rootButton = document.createElement("button");
|
|
|
|
|
rootButton.type = "button";
|
|
|
|
|
rootButton.className = "tree-row";
|
|
|
|
|
rootButton.setAttribute("data-testid", "tree-picker-root");
|
2026-04-26 04:29:23 +08:00
|
|
|
rootButton.dataset.focused = String(resolvePickerRootFocused());
|
2026-04-27 10:27:15 +08:00
|
|
|
rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1;
|
2026-04-17 23:36:24 +08:00
|
|
|
rootButton.addEventListener("click", () => {
|
2026-04-27 10:27:15 +08:00
|
|
|
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
|
|
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
2026-04-17 23:36:24 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const spacer = document.createElement("div");
|
|
|
|
|
spacer.className = "tree-spacer";
|
|
|
|
|
rootButton.appendChild(spacer);
|
|
|
|
|
|
|
|
|
|
const label = document.createElement("div");
|
|
|
|
|
label.className = "tree-link";
|
|
|
|
|
const title = document.createElement("span");
|
|
|
|
|
title.className = "tree-link-title";
|
|
|
|
|
title.textContent = "根目录";
|
|
|
|
|
const meta = document.createElement("span");
|
|
|
|
|
meta.className = "tree-link-meta";
|
|
|
|
|
meta.textContent = "选择工作空间根目录";
|
|
|
|
|
label.appendChild(title);
|
|
|
|
|
label.appendChild(meta);
|
|
|
|
|
rootButton.appendChild(label);
|
|
|
|
|
appElement.appendChild(rootButton);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (roots.length === 0) {
|
|
|
|
|
const empty = document.createElement("div");
|
|
|
|
|
empty.className = "tree-empty";
|
|
|
|
|
empty.textContent =
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "当前 projection 没有可选择的页面。"
|
|
|
|
|
: "当前 projection 没有可渲染的页面,点击上方按钮先创建一个根页面。";
|
|
|
|
|
appElement.appendChild(empty);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const list = document.createElement("ul");
|
|
|
|
|
list.className = "tree-root";
|
|
|
|
|
list.setAttribute("role", "tree");
|
|
|
|
|
roots.forEach((item) => {
|
|
|
|
|
list.appendChild(renderNode(item));
|
|
|
|
|
});
|
|
|
|
|
appElement.appendChild(list);
|
|
|
|
|
if (mode === "page" && focusedNodeId) {
|
|
|
|
|
focusRowElement(focusedNodeId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
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();
|
2026-04-26 19:35:52 +08:00
|
|
|
if (mode === "picker" && usedRustInitialRenderer) {
|
|
|
|
|
patchPickerActiveDom();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
renderTree();
|
|
|
|
|
if (mode === "page" && focusedNodeId) {
|
|
|
|
|
focusRowElement(focusedNodeId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
createRootButton.addEventListener("click", () => {
|
|
|
|
|
if (mode === "picker") return;
|
|
|
|
|
void handleCreate(null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const emitReady = () => {
|
|
|
|
|
postToHost("tree.ready", {
|
|
|
|
|
workspaceId,
|
|
|
|
|
payload: { workspaceId },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const usedRustInitialRenderer = hydrateInitialRenderer();
|
|
|
|
|
if (!usedRustInitialRenderer) {
|
|
|
|
|
renderTree();
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
if (mode === "page" && focusedNodeId) {
|
|
|
|
|
postPageFocusChange(focusedNodeId);
|
|
|
|
|
}
|
2026-04-24 06:10:18 +08:00
|
|
|
if (mode === "filetree") {
|
|
|
|
|
emitFileTreeSelectionChange();
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
if (mode === "filetree" && initialRenameRowId) {
|
|
|
|
|
const url = new URL(window.location.href);
|
|
|
|
|
url.searchParams.delete("renameRowId");
|
|
|
|
|
window.history.replaceState(null, "", url.toString());
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (fileTreeRowById.has(initialRenameRowId)) {
|
|
|
|
|
beginInlineRename("filetree", initialRenameRowId);
|
|
|
|
|
}
|
|
|
|
|
}, 120);
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
setStatus(
|
|
|
|
|
mode === "picker"
|
|
|
|
|
? "Tree picker 已就绪,可以展开目录并选择目标页面。"
|
|
|
|
|
: mode === "filetree"
|
|
|
|
|
? "File tree shell 已就绪,可以打开页面与附件。"
|
|
|
|
|
: "Tree shell 已就绪,可以进行展开、创建、重命名和排序操作。",
|
|
|
|
|
);
|
|
|
|
|
setLastAction("已向宿主发送 ready 消息。");
|
|
|
|
|
emitReady();
|
|
|
|
|
window.setTimeout(emitReady, 300);
|
|
|
|
|
window.setTimeout(emitReady, 1200);
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"##;
|
|
|
|
|
|
|
|
|
|
template
|
|
|
|
|
.replace("__WORKSPACE_ID__", &escape_html(workspace_id))
|
|
|
|
|
.replace("__ROOT_LABEL__", &escape_html(root_label))
|
|
|
|
|
.replace("__ACTIVE_LABEL__", &escape_html(active_label))
|
|
|
|
|
.replace("__PROJECTION_JSON__", &escape_html(&projection_json))
|
2026-04-26 19:35:52 +08:00
|
|
|
.replace("__INITIAL_TREE_HTML__", &initial_tree_html)
|
2026-04-17 23:36:24 +08:00
|
|
|
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
|
|
|
|
(
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
Json(json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"result": result,
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
pub async fn local_folder_watch(
|
2026-05-23 23:38:42 +08:00
|
|
|
State(state): State<AppState>,
|
2026-05-08 00:41:03 +08:00
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<LocalFolderWatchQuery>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
|
2026-05-19 08:07:17 +08:00
|
|
|
.map_err(|error| error.with_context(&context))?;
|
2026-05-08 00:41:03 +08:00
|
|
|
let revision = local_folder_watch_revision(&query.root_uri)?;
|
|
|
|
|
Ok(json_response(
|
|
|
|
|
&context,
|
|
|
|
|
json!({
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
"rootUri": revision.root_uri,
|
|
|
|
|
"revision": revision.revision,
|
|
|
|
|
"entryCount": revision.entry_count,
|
|
|
|
|
"latestModifiedMs": revision.latest_modified_ms,
|
|
|
|
|
}),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
pub async fn filetree_drop_preflight(
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Json(payload): Json<Value>,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let workspace_id = payload
|
|
|
|
|
.get("workspaceId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.or_else(|| context.workspace.workspace_id.clone())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"filetree_drop_preflight_workspace_missing",
|
|
|
|
|
"缺少 workspaceId",
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
let target_document_id = payload
|
|
|
|
|
.get("targetDocumentId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
|
|
|
|
let envelope_context = TreeCommandEnvelopeContext::default();
|
|
|
|
|
let command = RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "tree.filetree.drop.preflight".into(),
|
|
|
|
|
command_id: format!("filetree_drop_preflight_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: build_workspace_source_wire(&context, &workspace_id, &envelope_context),
|
|
|
|
|
target: Some(build_tree_target(&workspace_id, target_document_id, None)),
|
|
|
|
|
payload,
|
|
|
|
|
preflight_data: None,
|
|
|
|
|
reason: Some("filetree-drop-preflight tree.filetree.drop.preflight".into()),
|
|
|
|
|
refs: vec!["file-tree-shell".into()],
|
|
|
|
|
dry_run: true,
|
|
|
|
|
validate_only: true,
|
|
|
|
|
};
|
|
|
|
|
let plan = build_runtime_command_plan(&context, Some(&workspace_id), command)?;
|
|
|
|
|
let file_tree_drop_plan = plan
|
|
|
|
|
.args_json
|
|
|
|
|
.get("fileTreeDropPlan")
|
|
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::internal("filetree drop preflight 未返回计划").with_context(&context)
|
|
|
|
|
})?;
|
|
|
|
|
Ok((
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
Json(json!({
|
|
|
|
|
"requestId": context.trace.request_id,
|
|
|
|
|
"traceId": context.trace.trace_id,
|
|
|
|
|
"plan": file_tree_drop_plan,
|
|
|
|
|
})),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
pub async fn tree_shell(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
Query(query): Query<TreeShellQuery>,
|
|
|
|
|
) -> Result<Response, WebError> {
|
|
|
|
|
let effective_context = override_actor_context(&context, query.actor_id.as_deref());
|
|
|
|
|
let mode = normalize_tree_mode(query.mode.as_deref());
|
|
|
|
|
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
|
|
|
|
|
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
|
2026-05-08 00:41:03 +08:00
|
|
|
let source_kind = query
|
|
|
|
|
.source_kind
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
|
|
|
|
let (effective_workspace_id, snapshot) = if source_kind == Some("local_folder") {
|
|
|
|
|
let root_uri = query
|
|
|
|
|
.root_uri
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
|
|
|
|
})?;
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_read_access_with_state(&state, &effective_context, root_uri)
|
2026-05-19 08:07:17 +08:00
|
|
|
.map_err(|error| error.with_context(&effective_context))?;
|
2026-05-08 00:41:03 +08:00
|
|
|
(
|
|
|
|
|
local_workspace_id_from_root_uri(root_uri)?,
|
|
|
|
|
if mode == "filetree" {
|
|
|
|
|
load_local_folder_file_tree_snapshot(root_uri)?
|
2026-04-18 05:43:49 +08:00
|
|
|
} else {
|
2026-05-08 00:41:03 +08:00
|
|
|
load_local_folder_page_tree_snapshot(root_uri)?
|
2026-04-18 05:43:49 +08:00
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
let effective_workspace_id = resolve_effective_workspace_id(
|
|
|
|
|
&effective_context,
|
|
|
|
|
query.workspace_id.as_deref(),
|
|
|
|
|
true,
|
|
|
|
|
)?
|
|
|
|
|
.expect("workspace_required 已确保存在");
|
|
|
|
|
let snapshot = load_projection_snapshot(
|
|
|
|
|
state.config(),
|
|
|
|
|
&effective_context,
|
|
|
|
|
&ProjectionSnapshotSpec {
|
|
|
|
|
workspace_id: &effective_workspace_id,
|
|
|
|
|
root_node_id: query.root_node_id.as_deref(),
|
|
|
|
|
depth: query.depth,
|
|
|
|
|
query: None,
|
|
|
|
|
max_results: None,
|
|
|
|
|
projection: if mode == "filetree" {
|
|
|
|
|
KernelProjectionKind::FileTree
|
|
|
|
|
} else {
|
|
|
|
|
KernelProjectionKind::PageTree
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
(effective_workspace_id, snapshot)
|
|
|
|
|
};
|
2026-04-17 23:36:24 +08:00
|
|
|
let html = build_tree_shell_html(
|
|
|
|
|
&effective_workspace_id,
|
|
|
|
|
query.root_node_id.as_deref(),
|
|
|
|
|
query.active_document_id.as_deref(),
|
2026-04-26 04:29:23 +08:00
|
|
|
query.focused_document_id.as_deref(),
|
|
|
|
|
query.active_picker_item_key.as_deref(),
|
2026-04-17 23:36:24 +08:00
|
|
|
&normalize_channel(query.channel),
|
|
|
|
|
query.host.as_deref(),
|
|
|
|
|
&effective_context,
|
2026-04-18 05:43:49 +08:00
|
|
|
&snapshot.projection,
|
2026-04-17 23:36:24 +08:00
|
|
|
mode,
|
|
|
|
|
allow_root_pick,
|
|
|
|
|
&exclude_ids,
|
2026-04-18 05:43:49 +08:00
|
|
|
&snapshot.dataset,
|
2026-04-17 23:36:24 +08:00
|
|
|
);
|
|
|
|
|
let mut response = Html(html).into_response();
|
2026-04-18 09:38:16 +08:00
|
|
|
response.headers_mut().insert(
|
|
|
|
|
header::CONTENT_TYPE,
|
|
|
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn create_command_wire(
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
request: TreeCommandRequest,
|
2026-05-08 00:41:03 +08:00
|
|
|
envelope_context: &TreeCommandEnvelopeContext,
|
2026-04-17 23:36:24 +08:00
|
|
|
) -> Result<RuntimeCommandEnvelopeWire, WebError> {
|
|
|
|
|
match request {
|
|
|
|
|
TreeCommandRequest::Create {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
title,
|
|
|
|
|
access_scope,
|
|
|
|
|
content,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
let parent_id = read_optional_non_empty(parent_id);
|
2026-04-18 09:38:16 +08:00
|
|
|
let access_scope =
|
|
|
|
|
read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
|
2026-04-17 23:36:24 +08:00
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
2026-04-18 09:38:16 +08:00
|
|
|
name: "tree.node.create".into(),
|
2026-04-17 23:36:24 +08:00
|
|
|
command_id: format!("tree_create_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
2026-04-18 09:38:16 +08:00
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
Some(document_id.as_str()),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
2026-05-08 00:41:03 +08:00
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"parentId": parent_id,
|
|
|
|
|
"title": title,
|
|
|
|
|
"accessScope": access_scope,
|
|
|
|
|
"content": content.unwrap_or_else(|| Value::Array(Vec::new())),
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
2026-04-26 04:29:23 +08:00
|
|
|
preflight_data: None,
|
2026-04-17 23:36:24 +08:00
|
|
|
reason: Some("tree-shell create".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::CreateFolder { .. } => Err(WebError::bad_request_code(
|
|
|
|
|
"tree_command_validation",
|
|
|
|
|
"convex_workspace 暂不支持 folder create capability",
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_validate")),
|
2026-04-17 23:36:24 +08:00
|
|
|
TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
title,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
2026-04-18 09:38:16 +08:00
|
|
|
name: "tree.node.rename".into(),
|
2026-04-17 23:36:24 +08:00
|
|
|
command_id: format!("tree_rename_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
2026-04-18 09:38:16 +08:00
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
Some(document_id.as_str()),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
2026-05-08 00:41:03 +08:00
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"title": title,
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
2026-04-26 04:29:23 +08:00
|
|
|
preflight_data: None,
|
2026-04-17 23:36:24 +08:00
|
|
|
reason: Some("tree-shell rename".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Move {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
sort_order,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
let sort_order = ensure_sort_order(sort_order, context)?;
|
|
|
|
|
let parent_id = read_optional_non_empty(parent_id);
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
2026-04-18 09:38:16 +08:00
|
|
|
name: "tree.subtree.move".into(),
|
2026-04-17 23:36:24 +08:00
|
|
|
command_id: format!("tree_move_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
2026-04-18 09:38:16 +08:00
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
Some(document_id.as_str()),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
2026-05-08 00:41:03 +08:00
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"parentId": parent_id,
|
|
|
|
|
"sortOrder": sort_order,
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
2026-04-26 04:29:23 +08:00
|
|
|
preflight_data: None,
|
2026-04-17 23:36:24 +08:00
|
|
|
reason: Some("tree-shell move".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::Archive {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "tree.node.archive".into(),
|
|
|
|
|
command_id: format!("tree_archive_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
|
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
Some(document_id.as_str()),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
|
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
|
|
|
|
preflight_data: None,
|
|
|
|
|
reason: Some("tree-shell archive".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Restore {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "tree.node.restore".into(),
|
|
|
|
|
command_id: format!("tree_restore_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
|
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
Some(document_id.as_str()),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
|
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
|
|
|
|
preflight_data: None,
|
|
|
|
|
reason: Some("tree-shell restore".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Copy {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
target_parent_id,
|
|
|
|
|
items,
|
|
|
|
|
title: _,
|
|
|
|
|
} => {
|
|
|
|
|
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
let copy_items = if items.is_empty() {
|
|
|
|
|
vec![json!({
|
|
|
|
|
"documentId": fallback_document_id,
|
|
|
|
|
"recursive": true,
|
|
|
|
|
})]
|
|
|
|
|
} else {
|
|
|
|
|
items
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|item| {
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"documentId": ensure_non_empty(&item.document_id, "items.documentId", context)?,
|
|
|
|
|
"recursive": item.recursive,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<Vec<_>, WebError>>()?
|
|
|
|
|
};
|
|
|
|
|
let target_parent_id = read_optional_non_empty(target_parent_id);
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "tree.subtree.copy".into(),
|
|
|
|
|
command_id: format!("tree_copy_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
|
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
|
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
target_parent_id.as_deref(),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
|
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
"targetParentId": target_parent_id,
|
|
|
|
|
"items": copy_items,
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
|
|
|
|
preflight_data: None,
|
|
|
|
|
reason: Some("tree-shell copy".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::DropFiles { .. } => Err(WebError::bad_request_code(
|
|
|
|
|
"tree_command_validation",
|
|
|
|
|
"convex_workspace 外部文件 drop 需要走上传 preflight / object storage executor",
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_validate")),
|
2026-04-29 12:24:44 +08:00
|
|
|
TreeCommandRequest::Purge {
|
|
|
|
|
workspace_id: _,
|
|
|
|
|
document_id,
|
|
|
|
|
} => {
|
|
|
|
|
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
|
|
|
|
|
Ok(RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "tree.node.purge".into(),
|
|
|
|
|
command_id: format!("tree_purge_{}", context.trace.request_id),
|
|
|
|
|
idempotency_key: context.source.idempotency_key.clone(),
|
|
|
|
|
actor: bridge_runtime::RuntimeActorWire {
|
|
|
|
|
actor_type: context.auth.actor_type.clone(),
|
|
|
|
|
actor_id: context.auth.actor_id.clone(),
|
|
|
|
|
session_id: context.auth.session_id.clone(),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
source: build_workspace_source_wire(context, workspace_id, envelope_context),
|
2026-04-29 12:24:44 +08:00
|
|
|
target: Some(build_tree_target(
|
|
|
|
|
workspace_id,
|
|
|
|
|
Some(document_id.as_str()),
|
|
|
|
|
None,
|
|
|
|
|
)),
|
2026-05-08 00:41:03 +08:00
|
|
|
payload: attach_tree_command_envelope_context(
|
|
|
|
|
json!({
|
|
|
|
|
"documentId": document_id,
|
|
|
|
|
"workspaceId": workspace_id,
|
|
|
|
|
}),
|
|
|
|
|
envelope_context,
|
|
|
|
|
),
|
2026-04-29 12:24:44 +08:00
|
|
|
preflight_data: None,
|
|
|
|
|
reason: Some("tree-shell purge".into()),
|
|
|
|
|
refs: vec!["mnote-web-tree".into()],
|
|
|
|
|
dry_run: false,
|
|
|
|
|
validate_only: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 06:58:17 +08:00
|
|
|
async fn load_tree_move_preflight_data(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
workspace_id: &str,
|
|
|
|
|
) -> Result<Value, WebError> {
|
|
|
|
|
let spec = ProjectionSnapshotSpec {
|
|
|
|
|
workspace_id,
|
|
|
|
|
root_node_id: None,
|
|
|
|
|
depth: Some(99),
|
|
|
|
|
projection: KernelProjectionKind::SidebarTree,
|
|
|
|
|
query: None,
|
|
|
|
|
max_results: None,
|
|
|
|
|
};
|
|
|
|
|
let snapshot = load_projection_snapshot(state.config(), context, &spec)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"tree_move_preflight_snapshot_failed",
|
|
|
|
|
format!("移动前排序快照加载失败: {}", error.message()),
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header("x-error-phase", "tree_move_preflight_snapshot")
|
|
|
|
|
})?;
|
|
|
|
|
let documents = snapshot
|
|
|
|
|
.dataset
|
|
|
|
|
.get("documents")
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_else(|| json!([]));
|
|
|
|
|
Ok(json!({ "documents": documents }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
async fn resolve_tree_create_workspace_id(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
context: &RequestContext,
|
|
|
|
|
requested_workspace_id: Option<&str>,
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
) -> Result<String, WebError> {
|
|
|
|
|
if let Some(workspace_id) =
|
|
|
|
|
resolve_effective_workspace_id(context, requested_workspace_id, false)?
|
|
|
|
|
{
|
|
|
|
|
return Ok(workspace_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
|
|
|
|
|
let parent_meta =
|
|
|
|
|
fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?;
|
|
|
|
|
if let Some(workspace_id) = parent_meta
|
|
|
|
|
.get("workspace_id")
|
|
|
|
|
.or_else(|| parent_meta.get("workspaceId"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
{
|
|
|
|
|
return Ok(workspace_id.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let bootstrap = execute_convex_mutation_by_name(
|
|
|
|
|
state.config(),
|
|
|
|
|
context,
|
|
|
|
|
"workspaces:ensureDefaultWorkspace",
|
|
|
|
|
json!({
|
|
|
|
|
"fallbackName": context.auth.actor_id,
|
|
|
|
|
"workspaceIdIfCreate": generate_tree_document_id(),
|
|
|
|
|
}),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
"tree_command_workspace_bootstrap",
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
bootstrap
|
|
|
|
|
.get("activeWorkspaceId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_gateway_code(
|
|
|
|
|
"workspace_bootstrap_bad_response",
|
|
|
|
|
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
|
|
|
|
|
)
|
|
|
|
|
.with_context(context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_workspace_bootstrap")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
pub async fn tree_command(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Extension(context): Extension<RequestContext>,
|
|
|
|
|
body: String,
|
|
|
|
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
|
|
|
|
let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| {
|
|
|
|
|
WebError::bad_request_code(
|
|
|
|
|
"tree_command_invalid_json",
|
|
|
|
|
format!("tree command 请求体非法: {error}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_decode")
|
|
|
|
|
})?;
|
2026-05-08 00:41:03 +08:00
|
|
|
let envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
|
2026-04-17 23:36:24 +08:00
|
|
|
let request = match raw_request.action.trim() {
|
|
|
|
|
"create" => TreeCommandRequest::Create {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: read_optional_non_empty(raw_request.document_id)
|
|
|
|
|
.unwrap_or_else(generate_tree_document_id),
|
|
|
|
|
parent_id: raw_request.parent_id,
|
|
|
|
|
title: normalize_title(raw_request.title),
|
|
|
|
|
access_scope: raw_request.access_scope,
|
|
|
|
|
content: raw_request.content,
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
"createFolder" | "create_folder" | "folder.create" => TreeCommandRequest::CreateFolder {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: read_optional_non_empty(raw_request.document_id)
|
|
|
|
|
.unwrap_or_else(generate_tree_document_id),
|
|
|
|
|
parent_id: raw_request.parent_id,
|
|
|
|
|
title: normalize_title(raw_request.title.or_else(|| Some("新建文件夹".into()))),
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
"rename" => TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
title: normalize_title(raw_request.title),
|
|
|
|
|
},
|
|
|
|
|
"move" => TreeCommandRequest::Move {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
parent_id: raw_request.parent_id,
|
|
|
|
|
sort_order: raw_request.sort_order.unwrap_or(-1),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
"archive" | "delete" | "trash" => TreeCommandRequest::Archive {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
},
|
|
|
|
|
"restore" => TreeCommandRequest::Restore {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
},
|
|
|
|
|
"copy" => TreeCommandRequest::Copy {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
target_parent_id: raw_request.target_parent_id.or(raw_request.parent_id),
|
|
|
|
|
items: raw_request.items,
|
|
|
|
|
title: raw_request.title,
|
|
|
|
|
},
|
|
|
|
|
"dropFiles" | "drop_files" => TreeCommandRequest::DropFiles {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
parent_id: raw_request.parent_id,
|
|
|
|
|
files_json: match raw_request.content {
|
|
|
|
|
Some(Value::String(value)) => value,
|
|
|
|
|
Some(value) => value.to_string(),
|
|
|
|
|
None => "[]".into(),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-29 12:24:44 +08:00
|
|
|
"purge" => TreeCommandRequest::Purge {
|
|
|
|
|
workspace_id: raw_request.workspace_id,
|
|
|
|
|
document_id: raw_request.document_id.unwrap_or_default(),
|
|
|
|
|
},
|
2026-04-17 23:36:24 +08:00
|
|
|
other => {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"tree_command_validation",
|
|
|
|
|
format!("不支持的 tree action: {other}"),
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_validate"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let requested_workspace_id = match &request {
|
|
|
|
|
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::CreateFolder { workspace_id, .. } => workspace_id.as_deref(),
|
2026-04-17 23:36:24 +08:00
|
|
|
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(),
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::Archive { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
TreeCommandRequest::Restore { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
TreeCommandRequest::Copy { workspace_id, .. } => workspace_id.as_deref(),
|
|
|
|
|
TreeCommandRequest::DropFiles { workspace_id, .. } => workspace_id.as_deref(),
|
2026-04-29 12:24:44 +08:00
|
|
|
TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(),
|
2026-04-17 23:36:24 +08:00
|
|
|
};
|
|
|
|
|
let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
|
|
|
|
|
match &request {
|
|
|
|
|
TreeCommandRequest::Create {
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
title,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"create",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
parent_id.clone(),
|
|
|
|
|
Some(title.clone()),
|
|
|
|
|
None,
|
|
|
|
|
),
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::CreateFolder {
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
title,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"createFolder",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
parent_id.clone(),
|
|
|
|
|
Some(title.clone()),
|
|
|
|
|
None,
|
|
|
|
|
),
|
2026-04-17 23:36:24 +08:00
|
|
|
TreeCommandRequest::Rename {
|
2026-04-18 09:38:16 +08:00
|
|
|
document_id, title, ..
|
2026-04-17 23:36:24 +08:00
|
|
|
} => (
|
|
|
|
|
"rename",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
None,
|
|
|
|
|
Some(title.clone()),
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
TreeCommandRequest::Move {
|
|
|
|
|
document_id,
|
|
|
|
|
parent_id,
|
|
|
|
|
sort_order,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"move",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
parent_id.clone(),
|
|
|
|
|
None,
|
|
|
|
|
Some(*sort_order),
|
|
|
|
|
),
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::Archive { document_id, .. } => {
|
|
|
|
|
("delete", document_id.clone(), None, None, None)
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Restore { document_id, .. } => {
|
|
|
|
|
("restore", document_id.clone(), None, None, None)
|
|
|
|
|
}
|
|
|
|
|
TreeCommandRequest::Copy {
|
|
|
|
|
document_id,
|
|
|
|
|
target_parent_id,
|
|
|
|
|
title,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"copy",
|
|
|
|
|
document_id.clone(),
|
|
|
|
|
target_parent_id.clone(),
|
|
|
|
|
title.clone(),
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
TreeCommandRequest::DropFiles {
|
|
|
|
|
parent_id,
|
|
|
|
|
files_json,
|
|
|
|
|
..
|
|
|
|
|
} => (
|
|
|
|
|
"dropFiles",
|
|
|
|
|
String::new(),
|
|
|
|
|
parent_id.clone(),
|
|
|
|
|
Some(files_json.clone()),
|
|
|
|
|
None,
|
|
|
|
|
),
|
2026-04-29 14:36:24 +08:00
|
|
|
TreeCommandRequest::Purge { document_id, .. } => {
|
|
|
|
|
("purge", document_id.clone(), None, None, None)
|
|
|
|
|
}
|
2026-04-17 23:36:24 +08:00
|
|
|
};
|
2026-05-08 00:41:03 +08:00
|
|
|
if envelope_context.source_kind.as_deref() == Some("local_folder") {
|
|
|
|
|
let root_uri = envelope_context
|
|
|
|
|
.root_uri
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
})?;
|
2026-05-23 23:38:42 +08:00
|
|
|
ensure_local_workspace_access_with_state(
|
|
|
|
|
&state,
|
|
|
|
|
&context,
|
|
|
|
|
root_uri,
|
|
|
|
|
LocalAccessMode::Write,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|error| error.with_context(&context))?;
|
2026-05-20 19:04:05 +08:00
|
|
|
let execution = execute_local_tree_command_with_sort(
|
2026-05-08 00:41:03 +08:00
|
|
|
root_uri,
|
|
|
|
|
action,
|
|
|
|
|
&requested_document_id,
|
|
|
|
|
requested_parent_id.as_deref(),
|
|
|
|
|
requested_title.as_deref(),
|
2026-05-20 19:04:05 +08:00
|
|
|
requested_sort_order,
|
2026-05-08 00:41:03 +08:00
|
|
|
)
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
error
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header("x-error-phase", "tree_local_executor")
|
|
|
|
|
})?;
|
|
|
|
|
return Ok(json_response(
|
|
|
|
|
&context,
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": local_workspace_id_from_root_uri(root_uri)?,
|
|
|
|
|
"action": action,
|
|
|
|
|
"documentId": execution
|
|
|
|
|
.get("documentId")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or(&requested_document_id),
|
|
|
|
|
"parentId": requested_parent_id,
|
|
|
|
|
"title": requested_title,
|
|
|
|
|
"sortOrder": requested_sort_order,
|
|
|
|
|
"updatedAt": Value::Null,
|
|
|
|
|
"execution": execution,
|
|
|
|
|
"artifacts": Value::Null,
|
|
|
|
|
"artifactError": Value::Null,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
let effective_workspace_id = match &request {
|
|
|
|
|
TreeCommandRequest::Create { parent_id, .. } => {
|
|
|
|
|
resolve_tree_create_workspace_id(
|
|
|
|
|
&state,
|
|
|
|
|
&context,
|
|
|
|
|
requested_workspace_id,
|
|
|
|
|
parent_id.as_deref(),
|
|
|
|
|
)
|
|
|
|
|
.await?
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
TreeCommandRequest::CreateFolder { .. } => {
|
|
|
|
|
return Err(WebError::bad_request_code(
|
|
|
|
|
"tree_command_validation",
|
|
|
|
|
"convex_workspace 暂不支持 folder create capability",
|
|
|
|
|
)
|
|
|
|
|
.with_context(&context)
|
|
|
|
|
.with_header("x-error-phase", "tree_command_validate"));
|
|
|
|
|
}
|
2026-04-29 12:24:44 +08:00
|
|
|
_ => resolve_effective_workspace_id(&context, requested_workspace_id, true)?
|
|
|
|
|
.expect("workspace_required 已确保存在"),
|
|
|
|
|
};
|
2026-04-30 06:58:17 +08:00
|
|
|
let needs_move_preflight = matches!(&request, TreeCommandRequest::Move { .. });
|
2026-05-08 00:41:03 +08:00
|
|
|
let mut command_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
&effective_workspace_id,
|
|
|
|
|
request,
|
|
|
|
|
&envelope_context,
|
|
|
|
|
)?;
|
2026-04-30 06:58:17 +08:00
|
|
|
if needs_move_preflight {
|
|
|
|
|
command_wire.preflight_data =
|
|
|
|
|
Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?);
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
let execution = execute_runtime_command_via_convex_with_artifacts(
|
2026-05-17 12:58:27 +08:00
|
|
|
&state,
|
2026-04-17 23:36:24 +08:00
|
|
|
&context,
|
|
|
|
|
Some(&effective_workspace_id),
|
|
|
|
|
command_wire,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
let response_document_id = execution
|
2026-04-26 19:35:52 +08:00
|
|
|
.result
|
2026-04-17 23:36:24 +08:00
|
|
|
.get("id")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
.unwrap_or(requested_document_id);
|
2026-04-26 19:35:52 +08:00
|
|
|
let artifacts = execution
|
|
|
|
|
.artifacts
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
|
|
|
|
.unwrap_or(Value::Null);
|
|
|
|
|
let artifact_error = execution
|
|
|
|
|
.artifact_error
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|message| Value::String(message.clone()))
|
|
|
|
|
.unwrap_or(Value::Null);
|
2026-04-17 23:36:24 +08:00
|
|
|
|
|
|
|
|
Ok(json_response(
|
|
|
|
|
&context,
|
|
|
|
|
json!({
|
|
|
|
|
"workspaceId": effective_workspace_id,
|
|
|
|
|
"action": action,
|
|
|
|
|
"documentId": response_document_id,
|
|
|
|
|
"parentId": requested_parent_id,
|
|
|
|
|
"title": requested_title,
|
|
|
|
|
"sortOrder": requested_sort_order,
|
2026-04-26 19:35:52 +08:00
|
|
|
"updatedAt": execution.result.get("updated_at").cloned().unwrap_or(Value::Null),
|
|
|
|
|
"execution": execution.result,
|
|
|
|
|
"artifacts": artifacts,
|
|
|
|
|
"artifactError": artifact_error,
|
2026-04-17 23:36:24 +08:00
|
|
|
}),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:30:51 +08:00
|
|
|
pub async fn reduce_tree_shell_runtime(
|
|
|
|
|
Json(body): Json<TreeShellRuntimeRequest>,
|
|
|
|
|
) -> Json<TreeShellRuntimeResult> {
|
|
|
|
|
Json(reduce_tree_shell_runtime_request(body))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-05-16 07:11:06 +08:00
|
|
|
use super::{
|
|
|
|
|
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
|
|
|
|
TreeCommandRequest,
|
|
|
|
|
};
|
2026-04-29 12:24:44 +08:00
|
|
|
use crate::app::{build_app, AppConfig, AppState};
|
2026-04-18 09:38:16 +08:00
|
|
|
use crate::context::RequestContext;
|
|
|
|
|
use crate::routes::command_support::build_runtime_command_plan;
|
2026-04-17 23:36:24 +08:00
|
|
|
use axum::body::Body;
|
2026-05-19 08:07:17 +08:00
|
|
|
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
2026-05-23 23:38:42 +08:00
|
|
|
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
2026-04-17 23:36:24 +08:00
|
|
|
use serde_json::Value;
|
|
|
|
|
use tower::util::ServiceExt;
|
|
|
|
|
|
|
|
|
|
fn app() -> axum::Router {
|
|
|
|
|
build_app(AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
2026-04-29 12:24:44 +08:00
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
enable_legacy_next_compat: true,
|
2026-04-23 07:38:34 +08:00
|
|
|
enable_debug_shell_routes: true,
|
2026-05-16 22:03:14 +08:00
|
|
|
enable_editor_actor: true,
|
2026-04-17 23:36:24 +08:00
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
2026-04-24 06:10:18 +08:00
|
|
|
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
2026-04-29 12:24:44 +08:00
|
|
|
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
|
2026-04-17 23:36:24 +08:00
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
}))
|
2026-05-19 08:07:17 +08:00
|
|
|
.layer(axum::middleware::from_fn(inject_test_actor))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn inject_test_actor(
|
|
|
|
|
mut request: axum::extract::Request,
|
|
|
|
|
next: axum::middleware::Next,
|
|
|
|
|
) -> axum::response::Response {
|
|
|
|
|
request
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.entry("x-mnote-actor-id")
|
|
|
|
|
.or_insert(HeaderValue::from_static("user_test"));
|
|
|
|
|
request
|
|
|
|
|
.headers_mut()
|
|
|
|
|
.entry("x-mnote-actor-type")
|
|
|
|
|
.or_insert(HeaderValue::from_static("user"));
|
|
|
|
|
next.run(request).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
|
|
|
|
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
|
|
|
|
actor_id,
|
|
|
|
|
&format!("file://{}", root.display()),
|
|
|
|
|
)
|
|
|
|
|
.expect("init local workspace");
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
#[test]
|
|
|
|
|
fn filetree_rows_select_active_mindmap_asset_in_object_shell() {
|
|
|
|
|
let projection = serde_json::json!({
|
|
|
|
|
"items": [
|
|
|
|
|
{
|
|
|
|
|
"rowId": "doc:doc_1",
|
|
|
|
|
"rowKind": "document",
|
|
|
|
|
"nodeId": "doc_1",
|
|
|
|
|
"title": "页面.md",
|
|
|
|
|
"resourceMeta": {
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"objectIdentity": {
|
|
|
|
|
"objectKind": "page",
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"assetId": null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"rowId": "asset:mind_1",
|
|
|
|
|
"rowKind": "asset",
|
|
|
|
|
"nodeId": "asset:mind_1",
|
|
|
|
|
"parentNodeId": "doc_1",
|
|
|
|
|
"title": "思维导图.json",
|
|
|
|
|
"iconHint": "mindmap",
|
|
|
|
|
"resourceMeta": {
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"assetId": "mind_1",
|
|
|
|
|
"objectIdentity": {
|
|
|
|
|
"objectKind": "mindmap",
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
"assetId": "mind_1"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let rows = collect_filetree_render_rows(&projection, Some("doc_1"), Some("asset:mind_1"));
|
|
|
|
|
let doc_row = rows
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|row| row.row_id == "doc:doc_1")
|
|
|
|
|
.expect("doc row");
|
|
|
|
|
let mindmap_row = rows
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|row| row.row_id == "asset:mind_1")
|
|
|
|
|
.expect("mindmap row");
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!doc_row.selected,
|
|
|
|
|
"对象页应避免把父页面行重新选中,防止 mindmap 文件行点击后闪回父页面"
|
|
|
|
|
);
|
|
|
|
|
assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_returns_interactive_html_document() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&activeDocumentId=page_child&channel=test-shell")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let content_type = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("content-type")
|
|
|
|
|
.and_then(|value| value.to_str().ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
assert!(content_type.contains("text/html"));
|
|
|
|
|
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("tree-create-root"));
|
|
|
|
|
assert!(html.contains("tree.ready"));
|
|
|
|
|
assert!(html.contains("test-shell"));
|
|
|
|
|
assert!(html.contains("tree-action-menu"));
|
|
|
|
|
assert!(html.contains("tree.page.context-menu"));
|
2026-04-26 04:29:23 +08:00
|
|
|
assert!(html.contains("tree.page.expand.changed"));
|
|
|
|
|
assert!(html.contains("tree.page.focus.changed"));
|
|
|
|
|
assert!(html.contains("tree.shell.state.patch"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\""));
|
|
|
|
|
assert!(html.contains("applyPageKeyboardAction"));
|
2026-04-28 16:30:51 +08:00
|
|
|
assert!(html.contains("reducePageActionWithRuntime"));
|
|
|
|
|
let apply_page_action_start = html
|
|
|
|
|
.find("const applyPageKeyboardAction = (action, item, sourceElement) => {")
|
|
|
|
|
.expect("applyPageKeyboardAction should be embedded");
|
|
|
|
|
let apply_page_action_end = html[apply_page_action_start..]
|
|
|
|
|
.find("\n const postPickerFocusChange")
|
|
|
|
|
.expect("applyPageKeyboardAction should end before picker focus handler");
|
|
|
|
|
let apply_page_action_body =
|
|
|
|
|
&html[apply_page_action_start..apply_page_action_start + apply_page_action_end];
|
|
|
|
|
assert!(
|
|
|
|
|
!apply_page_action_body.contains("toggleExpand("),
|
|
|
|
|
"page keyboard/expand should prefer runtime result instead of directly toggling local expansion state"
|
|
|
|
|
);
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(html.contains("patchPageTreeActiveDom"));
|
|
|
|
|
assert!(html.contains("patchPageTreeExpansionDom"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
|
|
|
|
|
assert!(html.contains("data-rust-rendered-row=\"page\""));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(html.contains("data-rust-action=\"toggle\""));
|
|
|
|
|
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("hydrateInitialPageTree"));
|
|
|
|
|
assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
2026-05-14 05:52:08 +08:00
|
|
|
assert!(html.contains("applyCreatedDocumentLocally"));
|
|
|
|
|
assert!(html.contains("applyRemovedDocumentLocally"));
|
|
|
|
|
assert!(html.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
2026-05-14 15:10:33 +08:00
|
|
|
assert!(
|
|
|
|
|
html.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
|
|
|
|
|
);
|
2026-04-26 04:29:23 +08:00
|
|
|
assert!(html.contains("application/x-mnote-page-tree-node"));
|
|
|
|
|
assert!(html.contains("页面已拖放到"));
|
2026-04-17 23:36:24 +08:00
|
|
|
assert!(html.contains("setAttribute(\"role\", \"treeitem\")"));
|
|
|
|
|
assert!(html.contains("setAttribute(\"aria-level\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_picker_mode_hides_command_toolbar_and_supports_root_pick() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&mode=picker&allowRootPick=1&excludeIds=page_child")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("\"mode\":\"picker\""));
|
|
|
|
|
assert!(html.contains("\"allowRootPick\":true"));
|
|
|
|
|
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
|
|
|
|
|
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
|
2026-04-17 23:36:24 +08:00
|
|
|
assert!(html.contains("tree.pick.root"));
|
2026-04-26 04:29:23 +08:00
|
|
|
assert!(html.contains("tree.picker.command"));
|
|
|
|
|
assert!(html.contains("tree.picker.focus.changed"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
|
|
|
|
assert!(html.contains("applyPickerStateAction"));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(html.contains("postPickerPickResultToHost"));
|
|
|
|
|
assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
|
|
|
|
|
assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
|
|
|
|
|
assert!(html.contains("const shouldFocusDom = options.focusDom === true"));
|
|
|
|
|
assert!(html.contains("if (shouldFocusDom) focusPickerRowElement"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("patchPickerActiveDom"));
|
|
|
|
|
assert!(html.contains("hydrateInitialPickerTree"));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(html.contains("tabindex=\""));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
2026-04-24 06:10:18 +08:00
|
|
|
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_filetree_mode_embeds_asset_state() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&mode=filetree")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("filetree-doc-row"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
|
|
|
|
|
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
|
2026-04-17 23:36:24 +08:00
|
|
|
assert!(html.contains("\"mediaAssets\""));
|
2026-04-24 06:10:18 +08:00
|
|
|
assert!(html.contains("tree.filetree.selection.changed"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\""));
|
|
|
|
|
assert!(html.contains("applyFileTreeSelectionAction"));
|
2026-04-24 06:10:18 +08:00
|
|
|
assert!(html.contains("tree.filetree.internal-drop"));
|
|
|
|
|
assert!(html.contains("tree.filetree.external-drop"));
|
|
|
|
|
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
|
|
|
|
assert!(html.contains("\"resourceMeta\""));
|
2026-05-18 17:01:35 +08:00
|
|
|
assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {"));
|
|
|
|
|
assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
|
|
|
|
assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))"));
|
|
|
|
|
assert!(html.contains("if (getFileTreeRowDocumentId(item))"));
|
|
|
|
|
assert!(html.contains("documentId: ownerDocumentId || null"));
|
2026-04-24 06:10:18 +08:00
|
|
|
assert!(html.contains("dragover"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(html.contains("hydrateInitialFileTree"));
|
|
|
|
|
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot() {
|
|
|
|
|
let root =
|
|
|
|
|
std::env::temp_dir().join(format!("mnote-local-folder-source-{}", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create local docs dir");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md");
|
|
|
|
|
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
|
|
|
|
|
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-08 00:41:03 +08:00
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(format!(
|
|
|
|
|
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
|
|
|
|
))
|
2026-05-19 08:07:17 +08:00
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-08 00:41:03 +08:00
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("local_folder"));
|
|
|
|
|
assert!(html.contains("README.md"));
|
|
|
|
|
assert!(html.contains("child.md"));
|
|
|
|
|
assert!(html.contains("image.png"));
|
|
|
|
|
assert!(html.contains("data-row-kind=\"folder\""));
|
|
|
|
|
assert!(html.contains("data-row-kind=\"markdown\""));
|
|
|
|
|
assert!(html.contains("data-document-id=\"local-md:README.md\""));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 23:38:42 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_local_folder_allows_sqlite_directory_read_grant() {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mnote-local-folder-sqlite-read-grant-{}",
|
|
|
|
|
std::process::id()
|
|
|
|
|
));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local root");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Shared\n").expect("write local md");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
init_local_workspace(&root, "owner_user");
|
|
|
|
|
|
|
|
|
|
let state = AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
enable_legacy_next_compat: true,
|
|
|
|
|
enable_debug_shell_routes: true,
|
|
|
|
|
enable_editor_actor: true,
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
query_fixtures_json: None,
|
|
|
|
|
mutation_fixtures_json: None,
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
});
|
|
|
|
|
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.upsert_user(UpsertUserInput {
|
|
|
|
|
id: Some(user_id.into()),
|
|
|
|
|
email: Some(format!("{user_id}@example.com")),
|
|
|
|
|
username: user_id.into(),
|
|
|
|
|
display_name: user_id.into(),
|
|
|
|
|
role: role.map(str::to_string),
|
|
|
|
|
password_hash: None,
|
|
|
|
|
})
|
|
|
|
|
.expect("upsert grant user");
|
|
|
|
|
}
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.grant_directory_access(DirectoryGrantInput {
|
|
|
|
|
user_id: "shujuan".into(),
|
|
|
|
|
workspace_id: None,
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: root
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.expect("canonical root")
|
|
|
|
|
.display()
|
|
|
|
|
.to_string(),
|
|
|
|
|
permission: "read".into(),
|
|
|
|
|
recursive: true,
|
|
|
|
|
capabilities: vec![],
|
|
|
|
|
source: "admin".into(),
|
|
|
|
|
created_by: Some("liaibo".into()),
|
|
|
|
|
})
|
|
|
|
|
.expect("grant sqlite read");
|
|
|
|
|
|
|
|
|
|
let response = build_app(state)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(format!(
|
|
|
|
|
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
|
|
|
|
))
|
|
|
|
|
.header("x-mnote-actor-id", "shujuan")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn local_folder_tree_shell_does_not_reload_page_for_refresh() {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mnote-local-folder-no-reload-{}",
|
|
|
|
|
std::process::id()
|
|
|
|
|
));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local folder root");
|
|
|
|
|
std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md");
|
|
|
|
|
std::fs::write(root.join("asset.txt"), "asset").expect("write local asset");
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-16 07:11:06 +08:00
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(format!(
|
|
|
|
|
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
|
|
|
|
))
|
2026-05-19 08:07:17 +08:00
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-16 07:11:06 +08:00
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("/api/tree/local-folder-watch"));
|
|
|
|
|
assert!(html.contains("refreshLocalFolderSnapshot"));
|
|
|
|
|
assert!(!html.contains("window.location.reload"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_page_mode_can_open_local_folder_md_only_snapshot() {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mnote-local-page-tree-source-{}",
|
|
|
|
|
std::process::id()
|
|
|
|
|
));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create local docs dir");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
root.join("README.md"),
|
|
|
|
|
"---\ntitle: Frontmatter Title\n---\n# Ignored H1\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write local md");
|
|
|
|
|
std::fs::write(root.join("docs").join("child.md"), "# Child H1\n").expect("write child md");
|
|
|
|
|
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
|
|
|
|
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-08 00:41:03 +08:00
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri(format!(
|
|
|
|
|
"/tree?mode=page&sourceKind=local_folder&rootUri={root_uri}"
|
|
|
|
|
))
|
2026-05-19 08:07:17 +08:00
|
|
|
.header("x-mnote-actor-id", "user_test")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
2026-05-08 00:41:03 +08:00
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(html.contains("local_folder"));
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(html.contains("README"));
|
|
|
|
|
assert!(html.contains("child"));
|
2026-05-21 15:45:11 +08:00
|
|
|
// 标题优先级:frontmatter title > H1 > 文件名。
|
|
|
|
|
// README.md 有 frontmatter title "Frontmatter Title",因此应显示。
|
|
|
|
|
assert!(html.contains("Frontmatter Title"));
|
|
|
|
|
// child.md 无 frontmatter title,使用 H1 "Child H1"。
|
|
|
|
|
assert!(html.contains("Child H1"));
|
2026-05-08 00:41:03 +08:00
|
|
|
assert!(html.contains(">docs<") || html.contains("docs"));
|
|
|
|
|
assert!(!html.contains("image.png"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
|
|
|
|
|
) {
|
|
|
|
|
let root =
|
|
|
|
|
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local root");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let create_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"新页面"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
let create_status = create_response.status();
|
|
|
|
|
let body = axum::body::to_bytes(create_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
create_status,
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&body)
|
|
|
|
|
);
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
let document_id = payload["result"]["documentId"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("document id")
|
|
|
|
|
.to_string();
|
2026-05-20 10:43:38 +08:00
|
|
|
let created_relative_path = payload["result"]["execution"]["relativePath"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("created relative path");
|
|
|
|
|
let (created_dir, created_file) = created_relative_path
|
|
|
|
|
.split_once('/')
|
|
|
|
|
.expect("created nested bundle path");
|
|
|
|
|
assert!(created_dir.starts_with("新页面"));
|
|
|
|
|
assert_eq!(created_file, format!("{created_dir}.md"));
|
|
|
|
|
assert!(root.join(created_dir).join(created_file).exists());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
document_id,
|
|
|
|
|
format!(
|
|
|
|
|
"local-md:{}",
|
|
|
|
|
crate::routes::local_folder_source::encode_local_id_segment(created_relative_path)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let rename_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"rename","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","title":"重命名页面"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
let rename_status = rename_response.status();
|
|
|
|
|
let rename_body = axum::body::to_bytes(rename_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_status,
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&rename_body)
|
|
|
|
|
);
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(!root.join(created_dir).exists());
|
|
|
|
|
assert!(root.join("重命名页面").join("重命名页面.md").exists());
|
|
|
|
|
let rename_payload: Value = serde_json::from_slice(&rename_body).expect("rename json");
|
|
|
|
|
let renamed_document_id = rename_payload["result"]["documentId"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("renamed document id")
|
|
|
|
|
.to_string();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
renamed_document_id,
|
|
|
|
|
"local-md:~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md"
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs dir");
|
|
|
|
|
let move_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{renamed_document_id}","parentId":"local-dir:docs","sortOrder":0}}"#
|
2026-05-08 00:41:03 +08:00
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
2026-05-20 10:43:38 +08:00
|
|
|
let move_status = move_response.status();
|
|
|
|
|
let move_body = axum::body::to_bytes(move_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("move body");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
move_status,
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&move_body)
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join("重命名页面").exists());
|
|
|
|
|
assert!(root
|
|
|
|
|
.join("docs")
|
|
|
|
|
.join("重命名页面")
|
|
|
|
|
.join("重命名页面.md")
|
|
|
|
|
.exists());
|
|
|
|
|
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
|
|
|
|
|
let moved_document_id = move_payload["result"]["documentId"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("moved document id")
|
|
|
|
|
.to_string();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
moved_document_id,
|
|
|
|
|
"local-md:docs~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2~2F~E9~87~8D~E5~91~BD~E5~90~8D~E9~A1~B5~E9~9D~A2.md"
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let copy_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}","parentId":"local-dir:docs"}}"#
|
2026-05-08 00:41:03 +08:00
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
let copy_status = copy_response.status();
|
|
|
|
|
let copy_body = axum::body::to_bytes(copy_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
copy_status,
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(©_body)
|
|
|
|
|
);
|
|
|
|
|
let copy_payload: Value = serde_json::from_slice(©_body).expect("copy json");
|
|
|
|
|
let copied_document_id = copy_payload["result"]["documentId"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("copied document id")
|
|
|
|
|
.to_string();
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(root
|
|
|
|
|
.join("docs")
|
|
|
|
|
.join("重命名页面 2")
|
|
|
|
|
.join("重命名页面 2.md")
|
|
|
|
|
.exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let folder_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"createFolder","sourceKind":"local_folder","rootUri":"{root_uri}","title":"资料"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(folder_response.status(), StatusCode::OK);
|
|
|
|
|
assert!(root.join("资料").is_dir());
|
|
|
|
|
|
|
|
|
|
let delete_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"#
|
2026-05-08 00:41:03 +08:00
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(!root.join("docs").join("重命名页面").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
assert!(root
|
|
|
|
|
.join(".mnote")
|
|
|
|
|
.join("trash")
|
2026-05-20 10:43:38 +08:00
|
|
|
.join("重命名页面")
|
2026-05-08 00:41:03 +08:00
|
|
|
.join("重命名页面.md")
|
|
|
|
|
.exists());
|
|
|
|
|
assert!(root.join(".mnote").join("trash-index.json").exists());
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let restore_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
2026-05-20 10:43:38 +08:00
|
|
|
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{moved_document_id}"}}"#
|
2026-05-08 00:41:03 +08:00
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
2026-05-20 10:43:38 +08:00
|
|
|
let restore_status = restore_response.status();
|
|
|
|
|
let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("restore body");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
restore_status,
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&restore_body)
|
|
|
|
|
);
|
|
|
|
|
assert!(root
|
|
|
|
|
.join("docs")
|
|
|
|
|
.join("重命名页面")
|
|
|
|
|
.join("重命名页面.md")
|
|
|
|
|
.exists());
|
|
|
|
|
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
restore_payload["result"]["documentId"].as_str(),
|
|
|
|
|
Some(moved_document_id.as_str())
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let purge_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{copied_document_id}"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(purge_response.status(), StatusCode::OK);
|
2026-05-20 10:43:38 +08:00
|
|
|
assert!(!root.join("docs").join("重命名页面 2").exists());
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 07:11:06 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index() {
|
|
|
|
|
let root =
|
|
|
|
|
std::env::temp_dir().join(format!("mnote-local-asset-trash-{}", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
|
|
|
|
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-20 11:19:42 +08:00
|
|
|
let asset_id = "local-file:docs/photo.png";
|
2026-05-16 07:11:06 +08:00
|
|
|
|
|
|
|
|
let delete_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
let delete_status = delete_response.status();
|
|
|
|
|
let delete_body = axum::body::to_bytes(delete_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
delete_status,
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&delete_body)
|
|
|
|
|
);
|
|
|
|
|
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
delete_payload["result"]["execution"]["canonicalCommand"],
|
|
|
|
|
"tree.resource.archive"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
delete_payload["result"]["execution"]["resourceKind"],
|
|
|
|
|
"local_file"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
delete_payload["result"]["execution"]["originalFilePath"],
|
|
|
|
|
"docs/photo.png"
|
|
|
|
|
);
|
|
|
|
|
assert!(!root.join("docs").join("photo.png").exists());
|
|
|
|
|
assert!(root.join(".mnote").join("trash").join("photo.png").exists());
|
|
|
|
|
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
|
|
|
|
|
.expect("trash index");
|
|
|
|
|
assert!(trash_index.contains("local-file:docs/photo.png"));
|
|
|
|
|
assert!(trash_index.contains("resourceKind"));
|
|
|
|
|
|
|
|
|
|
let restore_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(restore_response.status(), StatusCode::OK);
|
|
|
|
|
assert!(root.join("docs").join("photo.png").exists());
|
|
|
|
|
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
|
|
|
|
|
|
|
|
|
|
let delete_again_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(delete_again_response.status(), StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
let purge_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(purge_response.status(), StatusCode::OK);
|
|
|
|
|
assert!(!root.join("docs").join("photo.png").exists());
|
|
|
|
|
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
|
|
|
|
|
let trash_index_after =
|
|
|
|
|
std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
|
|
|
|
|
.expect("trash index after purge");
|
|
|
|
|
assert!(!trash_index_after.contains("local-file:docs/photo.png"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mnote-local-tree-root-escape-{}",
|
|
|
|
|
std::process::id()
|
|
|
|
|
));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local root");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
2026-05-19 08:07:17 +08:00
|
|
|
init_local_workspace(&root, "user_test");
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","parentId":"local:folder:../outside","title":"逃逸"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
let headers = response.headers().clone();
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("error json");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
|
|
assert_eq!(payload["ok"], false);
|
|
|
|
|
assert_eq!(payload["code"], "local_folder_root_escape");
|
|
|
|
|
assert!(payload["message"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.contains("root"));
|
|
|
|
|
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
headers
|
|
|
|
|
.get("x-error-code")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("local_folder_root_escape")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
headers
|
|
|
|
|
.get("x-error-phase")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
Some("tree_local_executor")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_local_folder_rejects_non_owner_root() {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mnote-local-tree-owner-denied-{}",
|
|
|
|
|
std::process::id()
|
|
|
|
|
));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local root");
|
|
|
|
|
init_local_workspace(&root, "owner_user");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("x-mnote-actor-id", "other_user")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"local-md:README.md"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("error json");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
assert_eq!(payload["ok"], false);
|
|
|
|
|
assert_eq!(payload["code"], "local_workspace_access_denied");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 23:38:42 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_local_folder_allows_sqlite_directory_write_grant() {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mnote-local-tree-sqlite-write-grant-{}",
|
|
|
|
|
std::process::id()
|
|
|
|
|
));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
std::fs::create_dir_all(&root).expect("create local root");
|
|
|
|
|
init_local_workspace(&root, "owner_user");
|
|
|
|
|
let root_uri = format!("file://{}", root.display());
|
|
|
|
|
|
|
|
|
|
let state = AppState::new(AppConfig {
|
|
|
|
|
service_name: "mnote-web".into(),
|
|
|
|
|
service_version: "0.1.0".into(),
|
|
|
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
|
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
|
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
|
|
|
enable_legacy_next_compat: true,
|
|
|
|
|
enable_debug_shell_routes: true,
|
|
|
|
|
enable_editor_actor: true,
|
|
|
|
|
hermes_base_path: "/api/hermes".into(),
|
|
|
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
|
|
|
convex_url: None,
|
|
|
|
|
convex_admin_key: None,
|
|
|
|
|
allow_dev_fixtures: true,
|
|
|
|
|
query_fixtures_json: None,
|
|
|
|
|
mutation_fixtures_json: None,
|
|
|
|
|
dev_user_id: "dev-user".into(),
|
|
|
|
|
dev_user_name: "开发用户".into(),
|
|
|
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
|
|
|
});
|
|
|
|
|
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.upsert_user(UpsertUserInput {
|
|
|
|
|
id: Some(user_id.into()),
|
|
|
|
|
email: Some(format!("{user_id}@example.com")),
|
|
|
|
|
username: user_id.into(),
|
|
|
|
|
display_name: user_id.into(),
|
|
|
|
|
role: role.map(str::to_string),
|
|
|
|
|
password_hash: None,
|
|
|
|
|
})
|
|
|
|
|
.expect("upsert grant user");
|
|
|
|
|
}
|
|
|
|
|
state
|
|
|
|
|
.control_plane()
|
|
|
|
|
.grant_directory_access(DirectoryGrantInput {
|
|
|
|
|
user_id: "shujuan".into(),
|
|
|
|
|
workspace_id: None,
|
|
|
|
|
root_uri: root_uri.clone(),
|
|
|
|
|
root_path: root
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.expect("canonical root")
|
|
|
|
|
.display()
|
|
|
|
|
.to_string(),
|
|
|
|
|
permission: "write".into(),
|
|
|
|
|
recursive: true,
|
|
|
|
|
capabilities: vec![],
|
|
|
|
|
source: "admin".into(),
|
|
|
|
|
created_by: Some("liaibo".into()),
|
|
|
|
|
})
|
|
|
|
|
.expect("grant sqlite write");
|
|
|
|
|
|
|
|
|
|
let response = build_app(state)
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("x-mnote-actor-id", "shujuan")
|
|
|
|
|
.header("x-mnote-actor-type", "user")
|
|
|
|
|
.body(Body::from(format!(
|
|
|
|
|
r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"授权新页面"}}"#
|
|
|
|
|
)))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_shell_embeds_renderer_input_contract() {
|
|
|
|
|
let filetree_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&mode=filetree&activeDocumentId=page_root")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(filetree_response.status(), StatusCode::OK);
|
|
|
|
|
let filetree_body = axum::body::to_bytes(filetree_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let filetree_html = String::from_utf8(filetree_body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(filetree_html.contains("\"rendererInput\""));
|
|
|
|
|
assert!(filetree_html.contains("\"mode\":\"fileTree\""));
|
|
|
|
|
assert!(filetree_html.contains("\"filetreeSelection\""));
|
2026-05-16 07:11:06 +08:00
|
|
|
assert!(filetree_html.contains("\"selectedRowIds\":[\"doc:page_root\"]"));
|
|
|
|
|
assert!(!filetree_html.contains("\"selectedRowIds\":[\"index:page_root\""));
|
|
|
|
|
assert!(filetree_html.contains("\"focusedRowId\":\"doc:page_root\""));
|
2026-05-06 21:44:20 +08:00
|
|
|
assert!(filetree_html.contains("data-row-id=\"doc:page_root\""));
|
2026-05-16 07:11:06 +08:00
|
|
|
assert!(!filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\""));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(filetree_html.contains("\"commandDispatcher\""));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(filetree_html.contains("\"runtimeArtifact\""));
|
|
|
|
|
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
|
2026-04-28 16:30:51 +08:00
|
|
|
assert!(filetree_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
|
|
|
|
|
assert!(filetree_html.contains(
|
|
|
|
|
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
|
|
|
|
));
|
2026-04-29 12:24:44 +08:00
|
|
|
assert!(filetree_html
|
|
|
|
|
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
2026-04-28 16:30:51 +08:00
|
|
|
assert!(filetree_html.contains(
|
|
|
|
|
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
|
|
|
|
|
));
|
2026-04-26 19:35:52 +08:00
|
|
|
|
|
|
|
|
let picker_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/tree?workspaceId=ws_demo&mode=picker&activePickerItemKey=page_child&excludeIds=page_root")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
assert_eq!(picker_response.status(), StatusCode::OK);
|
|
|
|
|
let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let picker_html = String::from_utf8(picker_body.to_vec()).expect("utf8");
|
|
|
|
|
assert!(picker_html.contains("\"mode\":\"picker\""));
|
|
|
|
|
assert!(picker_html.contains("\"activePickerItem\":\"page_child\""));
|
|
|
|
|
assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]"));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(picker_html.contains("\"runtimeArtifact\""));
|
|
|
|
|
assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
|
2026-04-28 16:30:51 +08:00
|
|
|
assert!(picker_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
|
|
|
|
|
assert!(picker_html.contains(
|
|
|
|
|
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
|
|
|
|
));
|
2026-04-29 12:24:44 +08:00
|
|
|
assert!(picker_html
|
|
|
|
|
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
2026-04-28 16:30:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_runtime_reduce_endpoint_returns_filetree_runtime_result() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/runtime/reduce")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"mode":"fileTree","requestId":"req-filetree-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"selectRow","rowId":"asset:image","modifiers":{"shiftKey":false,"ctrlKey":false,"metaKey":false}}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
|
|
|
|
|
assert_eq!(payload["mode"], Value::String("fileTree".into()));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["requestId"],
|
|
|
|
|
Value::String("req-filetree-route".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["domPatches"][0]["kind"],
|
|
|
|
|
Value::String("fileTreeState".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["domPatches"][0]["selectedRowIds"][0],
|
|
|
|
|
Value::String("asset:image".into())
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let drop_target_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/runtime/reduce")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"mode":"fileTree","requestId":"req-filetree-drop-target-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"updateDropTarget","rowId":"asset:image"}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(drop_target_response.status(), StatusCode::OK);
|
|
|
|
|
let drop_target_body = axum::body::to_bytes(drop_target_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let drop_target_payload: Value = serde_json::from_slice(&drop_target_body).expect("json");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
drop_target_payload["domPatches"][0]["dropTargetRowId"],
|
|
|
|
|
Value::String("asset:image".into())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_runtime_reduce_endpoint_returns_page_and_picker_runtime_results() {
|
|
|
|
|
let page_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/runtime/reduce")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"mode":"page","requestId":"req-page-route","environment":{"visibleNodeIds":["doc:root","doc:child"],"expandableNodeIds":["doc:root"]},"state":{"focusedId":"doc:root","expandedIds":[],"dropFeedback":null},"action":{"kind":"moveNext"}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(page_response.status(), StatusCode::OK);
|
|
|
|
|
let page_body = axum::body::to_bytes(page_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let page_payload: Value = serde_json::from_slice(&page_body).expect("json");
|
|
|
|
|
|
|
|
|
|
assert_eq!(page_payload["mode"], Value::String("page".into()));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
page_payload["requestId"],
|
|
|
|
|
Value::String("req-page-route".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
page_payload["domPatches"][0]["kind"],
|
|
|
|
|
Value::String("pageState".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
page_payload["domPatches"][0]["focusedId"],
|
|
|
|
|
Value::String("doc:child".into())
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let picker_response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/runtime/reduce")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"mode":"picker","requestId":"req-picker-route","environment":{"items":[{"itemKey":"doc:root","documentId":"doc:root","pickable":true},{"itemKey":"doc:child","documentId":"doc:child","pickable":true}],"excludedIds":[],"allowRootPick":false},"state":{"activeItemKey":"doc:child"},"action":{"kind":"pick"}}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(picker_response.status(), StatusCode::OK);
|
|
|
|
|
let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let picker_payload: Value = serde_json::from_slice(&picker_body).expect("json");
|
|
|
|
|
|
|
|
|
|
assert_eq!(picker_payload["mode"], Value::String("picker".into()));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
picker_payload["requestId"],
|
|
|
|
|
Value::String("req-picker-route".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
picker_payload["hostEvents"][0]["kind"],
|
|
|
|
|
Value::String("pickerPickDocument".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
picker_payload["hostEvents"][0]["documentId"],
|
|
|
|
|
Value::String("doc:child".into())
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_create_generates_document_id_when_missing() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"create","workspaceId":"ws_demo","parentId":"page_root","title":"新页面","accessScope":"private","content":[]}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["action"], Value::String("create".into()));
|
2026-04-18 09:38:16 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["documentId"],
|
|
|
|
|
Value::String("page_new".into())
|
|
|
|
|
);
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_create_uses_default_workspace_when_workspace_missing() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(r#"{"action":"create","title":"新页面"}"#))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["action"], Value::String("create".into()));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["workspaceId"],
|
|
|
|
|
Value::String("ws_demo".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["documentId"],
|
|
|
|
|
Value::String("page_new".into())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_purge_uses_tree_command_protocol() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["action"], Value::String("purge".into()));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["documentId"],
|
|
|
|
|
Value::String("page_child".into())
|
|
|
|
|
);
|
2026-05-18 17:01:35 +08:00
|
|
|
assert_eq!(payload["result"]["sortOrder"], Value::Null);
|
2026-04-29 14:36:24 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["execution"]["deletedCount"],
|
|
|
|
|
Value::from(1)
|
|
|
|
|
);
|
2026-04-29 12:24:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 23:36:24 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_rejects_negative_sort_order() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":-1}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_rename_defaults_blank_title_to_untitled() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":" "}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["title"], Value::String("无标题".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_move_returns_structured_payload() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":1}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(payload["result"]["action"], Value::String("move".into()));
|
2026-04-18 09:38:16 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["documentId"],
|
|
|
|
|
Value::String("page_child".into())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_command_response_includes_rust_artifact_plan_for_domain_event() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"move","workspaceId":"ws_demo","documentId":"page_child","parentId":"page_root","sortOrder":1}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["artifacts"]["domainEvent"]["eventType"],
|
|
|
|
|
Value::String("tree.subtree.moved".into())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
|
|
|
|
Value::String("move_document".into())
|
|
|
|
|
);
|
2026-05-16 22:03:14 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["sortOrder"],
|
|
|
|
|
Value::from(1)
|
|
|
|
|
);
|
2026-04-26 19:35:52 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"],
|
|
|
|
|
Value::String("move_document".into())
|
|
|
|
|
);
|
2026-05-16 22:03:14 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["sortOrder"],
|
|
|
|
|
Value::from(1)
|
|
|
|
|
);
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 09:38:16 +08:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() {
|
|
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.method("POST")
|
|
|
|
|
.uri("/api/tree/commands")
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
.header("Authorization", "Bearer demo-token")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#,
|
|
|
|
|
))
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
|
|
|
.await
|
|
|
|
|
.expect("body");
|
|
|
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
|
assert!(payload["requestId"].as_str().is_some());
|
|
|
|
|
assert!(payload["traceId"].as_str().is_some());
|
|
|
|
|
assert_eq!(payload["result"]["workspaceId"], "ws_demo");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-05-11 13:16:34 +08:00
|
|
|
async fn tree_route_contracts_compat_sidebar_route_is_not_registered_by_default() {
|
2026-04-18 09:38:16 +08:00
|
|
|
let response = app()
|
|
|
|
|
.oneshot(
|
|
|
|
|
Request::builder()
|
|
|
|
|
.uri("/api/compat/next/sidebar?workspaceId=ws_demo")
|
|
|
|
|
.header("Authorization", "Bearer demo-token")
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
.expect("request"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("response");
|
|
|
|
|
|
2026-05-11 13:16:34 +08:00
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
2026-04-18 09:38:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tree_commands_prefer_tree_protocol_names_in_command_wire() {
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::POST,
|
|
|
|
|
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
|
|
|
|
&HeaderMap::new(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let create_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Create {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_new".into(),
|
|
|
|
|
parent_id: Some("page_root".into()),
|
|
|
|
|
title: "新页面".into(),
|
|
|
|
|
access_scope: Some("private".into()),
|
|
|
|
|
content: Some(Value::Array(Vec::new())),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
&TreeCommandEnvelopeContext::default(),
|
2026-04-18 09:38:16 +08:00
|
|
|
)
|
|
|
|
|
.expect("create wire");
|
|
|
|
|
assert_eq!(create_wire.name, "tree.node.create");
|
2026-05-08 00:41:03 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
create_wire.source.source_kind.as_deref(),
|
|
|
|
|
Some("convex_workspace")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
create_wire.source.root_uri.as_deref(),
|
|
|
|
|
Some("convex://workspace/ws_demo")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
|
|
|
|
|
assert!(create_wire
|
|
|
|
|
.source
|
|
|
|
|
.capabilities
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|capability| capability == "execute-command"));
|
2026-04-18 09:38:16 +08:00
|
|
|
|
|
|
|
|
let rename_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_child".into(),
|
|
|
|
|
title: "重命名".into(),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
&TreeCommandEnvelopeContext::default(),
|
2026-04-18 09:38:16 +08:00
|
|
|
)
|
|
|
|
|
.expect("rename wire");
|
|
|
|
|
assert_eq!(rename_wire.name, "tree.node.rename");
|
|
|
|
|
|
|
|
|
|
let move_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Move {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_child".into(),
|
|
|
|
|
parent_id: Some("page_root".into()),
|
|
|
|
|
sort_order: 1,
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
&TreeCommandEnvelopeContext::default(),
|
2026-04-18 09:38:16 +08:00
|
|
|
)
|
|
|
|
|
.expect("move wire");
|
|
|
|
|
assert_eq!(move_wire.name, "tree.subtree.move");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
#[test]
|
|
|
|
|
fn tree_command_wire_carries_source_and_target_context_from_unified_envelope() {
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::POST,
|
|
|
|
|
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
|
|
|
|
&HeaderMap::new(),
|
|
|
|
|
);
|
|
|
|
|
let envelope_context = TreeCommandEnvelopeContext {
|
|
|
|
|
source_kind: Some("convex_workspace".into()),
|
|
|
|
|
root_uri: Some("convex://workspace/ws_demo".into()),
|
|
|
|
|
source_capabilities: vec![
|
|
|
|
|
"load-snapshot".into(),
|
|
|
|
|
"preflight-command".into(),
|
|
|
|
|
"execute-command".into(),
|
|
|
|
|
],
|
|
|
|
|
target_node_id: Some("doc:page_child".into()),
|
|
|
|
|
target_resource_meta: Some(serde_json::json!({
|
|
|
|
|
"resourceKind": "document",
|
|
|
|
|
"documentId": "page_child",
|
|
|
|
|
"workspaceId": "ws_demo"
|
|
|
|
|
})),
|
|
|
|
|
selection: Some(serde_json::json!({
|
|
|
|
|
"rowIds": ["doc:page_child"]
|
|
|
|
|
})),
|
|
|
|
|
operation: Some("tree.node.rename".into()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rename_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_child".into(),
|
|
|
|
|
title: "重命名".into(),
|
|
|
|
|
},
|
|
|
|
|
&envelope_context,
|
|
|
|
|
)
|
|
|
|
|
.expect("rename wire");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_wire.source.source_kind.as_deref(),
|
|
|
|
|
Some("convex_workspace")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_wire.source.root_uri.as_deref(),
|
|
|
|
|
Some("convex://workspace/ws_demo")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_wire.payload["targetNodeId"],
|
|
|
|
|
serde_json::json!("doc:page_child")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_wire.payload["targetResourceMeta"]["documentId"],
|
|
|
|
|
serde_json::json!("page_child")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_wire.payload["selection"]["rowIds"][0],
|
|
|
|
|
serde_json::json!("doc:page_child")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rename_wire.payload["operation"],
|
|
|
|
|
serde_json::json!("tree.node.rename")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 09:38:16 +08:00
|
|
|
#[test]
|
|
|
|
|
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
|
|
|
|
|
let context = RequestContext::from_http_parts(
|
|
|
|
|
&Method::POST,
|
|
|
|
|
&"/api/tree/commands".parse::<Uri>().expect("uri"),
|
|
|
|
|
&HeaderMap::new(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let tree_create_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Create {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_new".into(),
|
|
|
|
|
parent_id: Some("page_root".into()),
|
|
|
|
|
title: "新页面".into(),
|
|
|
|
|
access_scope: Some("private".into()),
|
|
|
|
|
content: Some(Value::Array(Vec::new())),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
&TreeCommandEnvelopeContext::default(),
|
2026-04-18 09:38:16 +08:00
|
|
|
)
|
|
|
|
|
.expect("tree create wire");
|
|
|
|
|
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "documents.create".into(),
|
|
|
|
|
..tree_create_wire.clone()
|
|
|
|
|
};
|
|
|
|
|
let tree_create_plan =
|
|
|
|
|
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
|
|
|
|
|
.expect("tree create plan");
|
|
|
|
|
let compat_create_plan =
|
|
|
|
|
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
|
|
|
|
|
.expect("compat create plan");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tree_create_plan.function_name,
|
|
|
|
|
"documents:createWithParentReference"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tree_create_plan.function_name,
|
|
|
|
|
compat_create_plan.function_name
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let tree_rename_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Rename {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_child".into(),
|
|
|
|
|
title: "重命名".into(),
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
&TreeCommandEnvelopeContext::default(),
|
2026-04-18 09:38:16 +08:00
|
|
|
)
|
|
|
|
|
.expect("tree rename wire");
|
|
|
|
|
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "documents.title.update".into(),
|
|
|
|
|
..tree_rename_wire.clone()
|
|
|
|
|
};
|
|
|
|
|
let tree_rename_plan =
|
|
|
|
|
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
|
|
|
|
|
.expect("tree rename plan");
|
|
|
|
|
let compat_rename_plan =
|
|
|
|
|
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
|
|
|
|
|
.expect("compat rename plan");
|
|
|
|
|
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tree_rename_plan.function_name,
|
|
|
|
|
compat_rename_plan.function_name
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let tree_move_wire = create_command_wire(
|
|
|
|
|
&context,
|
|
|
|
|
"ws_demo",
|
|
|
|
|
TreeCommandRequest::Move {
|
|
|
|
|
workspace_id: Some("ws_demo".into()),
|
|
|
|
|
document_id: "page_child".into(),
|
|
|
|
|
parent_id: Some("page_root".into()),
|
|
|
|
|
sort_order: 1,
|
|
|
|
|
},
|
2026-05-08 00:41:03 +08:00
|
|
|
&TreeCommandEnvelopeContext::default(),
|
2026-04-18 09:38:16 +08:00
|
|
|
)
|
|
|
|
|
.expect("tree move wire");
|
|
|
|
|
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
|
|
|
|
name: "documents.move".into(),
|
|
|
|
|
..tree_move_wire.clone()
|
|
|
|
|
};
|
|
|
|
|
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
|
|
|
|
|
.expect("tree move plan");
|
|
|
|
|
let compat_move_plan =
|
|
|
|
|
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
|
|
|
|
|
.expect("compat move plan");
|
|
|
|
|
assert_eq!(tree_move_plan.function_name, "documents:move");
|
|
|
|
|
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
|
2026-04-17 23:36:24 +08:00
|
|
|
}
|
|
|
|
|
}
|