checkpoint before gfm ast parser design
This commit is contained in:
@@ -76,7 +76,7 @@ impl PageAggregateBuilder {
|
||||
wide_layout: false,
|
||||
small_text: false,
|
||||
layout_density: "normal".to_string(),
|
||||
show_heading_numbers: true,
|
||||
show_heading_numbers: false,
|
||||
show_toc: false,
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
|
||||
@@ -31,6 +31,10 @@ pub fn runtime_context(
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
tenant_id: context.workspace.tenant_id.clone(),
|
||||
auth_token: context.auth.authorization.clone(),
|
||||
|
||||
@@ -53,6 +53,21 @@ pub async fn next_ai_agent_run(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(provider) = explicit_agent_provider(&payload) {
|
||||
if provider == "hermes" {
|
||||
return run_direct_hermes_agent(&context, &payload).await;
|
||||
}
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"ai_provider_bridge_unavailable",
|
||||
format!(
|
||||
"{provider} provider 需要可用的 Next AI bridge,不能静默降级到本地页面工具 host。"
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
|
||||
}
|
||||
|
||||
if let Ok(response) = run_local_mnote_cli_ai_host(&state, &context, &payload).await {
|
||||
return Ok(response);
|
||||
}
|
||||
@@ -118,6 +133,304 @@ pub async fn next_ai_agent_run(
|
||||
build_ai_sse_proxy_response(upstream_response, &context)
|
||||
}
|
||||
|
||||
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
let provider = payload
|
||||
.get("options")
|
||||
.and_then(|value| value.get("ai"))
|
||||
.and_then(|value| value.get("provider"))
|
||||
.and_then(Value::as_str)?
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match provider.as_str() {
|
||||
"hermes" => Some("hermes"),
|
||||
"codex" => Some("codex"),
|
||||
"claudecode" => Some("claudecode"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_direct_hermes_agent(
|
||||
context: &RequestContext,
|
||||
payload: &Value,
|
||||
) -> Result<Response, WebError> {
|
||||
let base_url = resolve_hermes_api_base_url();
|
||||
let api_key = read_env_or_dotenv("MNOTE_HERMES_API_KEY").ok_or_else(|| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_bridge_unconfigured",
|
||||
"未配置 MNOTE_HERMES_API_KEY,无法直连 Hermes API。",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes")
|
||||
})?;
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(180))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| WebError::internal(format!("Hermes HTTP 客户端创建失败: {error}")))?;
|
||||
|
||||
let start_url = reqwest::Url::parse(&format!("{}/v1/runs", base_url.trim_end_matches('/')))
|
||||
.map_err(|error| WebError::internal(format!("Hermes runs URL 非法: {error}")))?;
|
||||
let start_response = client
|
||||
.post(start_url)
|
||||
.bearer_auth(&api_key)
|
||||
.json(&build_hermes_run_payload(payload))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_bridge_start_error",
|
||||
format!("Hermes run 启动请求失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes")
|
||||
})?;
|
||||
|
||||
if !start_response.status().is_success() {
|
||||
let status = start_response.status();
|
||||
let body = start_response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("读取 Hermes 错误响应失败: {error}"));
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"hermes_bridge_start_rejected",
|
||||
format!("Hermes run 启动返回 HTTP {}: {}", status.as_u16(), body),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes"));
|
||||
}
|
||||
|
||||
let started: Value = start_response.json().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_bridge_bad_start_response",
|
||||
format!("Hermes run 启动响应不是合法 JSON: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes")
|
||||
})?;
|
||||
let run_id = started
|
||||
.get("run_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_bridge_missing_run_id",
|
||||
"Hermes run 启动响应缺少 run_id。",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes")
|
||||
})?;
|
||||
|
||||
let mut events_url =
|
||||
reqwest::Url::parse(&format!("{}/v1/runs/", base_url.trim_end_matches('/')))
|
||||
.map_err(|error| WebError::internal(format!("Hermes events URL 非法: {error}")))?;
|
||||
events_url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| WebError::internal("Hermes events URL 不支持路径拼接"))?
|
||||
.pop_if_empty()
|
||||
.push(run_id)
|
||||
.push("events");
|
||||
|
||||
let events_response = client
|
||||
.get(events_url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_bridge_events_error",
|
||||
format!("Hermes 事件流请求失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes")
|
||||
})?;
|
||||
|
||||
if !events_response.status().is_success() {
|
||||
let status = events_response.status();
|
||||
let body = events_response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("读取 Hermes 事件流错误响应失败: {error}"));
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"hermes_bridge_events_rejected",
|
||||
format!("Hermes 事件流返回 HTTP {}: {}", status.as_u16(), body),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes"));
|
||||
}
|
||||
|
||||
let events_text = events_response.text().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_bridge_events_read_error",
|
||||
format!("读取 Hermes 事件流失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-mnote-web-owner", "mnote-web")
|
||||
.with_header("x-upstream-service", "hermes-api")
|
||||
.with_header("x-mnote-ai-execution-owner", "hermes")
|
||||
})?;
|
||||
|
||||
build_hermes_sse_response(context, parse_sse_data_json_values(&events_text))
|
||||
}
|
||||
|
||||
fn resolve_hermes_api_base_url() -> String {
|
||||
read_env_or_dotenv("MNOTE_HERMES_API_BASE_URL")
|
||||
.unwrap_or_else(|| "http://127.0.0.1:8642".into())
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_hermes_run_payload(payload: &Value) -> Value {
|
||||
let messages = payload
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let input: Vec<Value> = messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
json!({
|
||||
"role": message.get("role").and_then(Value::as_str).unwrap_or("user"),
|
||||
"content": message.get("content").and_then(Value::as_str).unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let conversation_history = if input.len() > 1 {
|
||||
input[..input.len() - 1].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let session_id = payload
|
||||
.get("options")
|
||||
.and_then(|value| value.get("ai"))
|
||||
.and_then(|value| value.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
json!({
|
||||
"input": input,
|
||||
"conversation_history": conversation_history,
|
||||
"session_id": session_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sse_data_json_values(text: &str) -> Vec<Value> {
|
||||
text.split("\n\n")
|
||||
.filter_map(|frame| {
|
||||
let data = frame
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(str::trim_start)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if data.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<Value>(&data).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sse_frame(event: &str, data: Value) -> String {
|
||||
format!("event: {event}\ndata: {}\n\n", data.to_string())
|
||||
}
|
||||
|
||||
fn build_hermes_sse_response(
|
||||
context: &RequestContext,
|
||||
events: Vec<Value>,
|
||||
) -> Result<Response, WebError> {
|
||||
let mut body = String::new();
|
||||
let mut assistant_text = String::new();
|
||||
let mut completed = false;
|
||||
body.push_str(&sse_frame(
|
||||
"ready",
|
||||
json!({"ok": true, "bridgeOwner": "hermes", "requestId": context.trace.request_id}),
|
||||
));
|
||||
for event in events {
|
||||
let event_name = event.get("event").and_then(Value::as_str).unwrap_or("");
|
||||
match event_name {
|
||||
"message.delta" => {
|
||||
if let Some(delta) = event.get("delta").and_then(Value::as_str) {
|
||||
assistant_text.push_str(delta);
|
||||
body.push_str(&sse_frame("assistant_delta", json!({"text": delta})));
|
||||
}
|
||||
}
|
||||
"run.completed" => {
|
||||
completed = true;
|
||||
let output = event
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| assistant_text.trim());
|
||||
let text = if output.is_empty() {
|
||||
"(无输出)"
|
||||
} else {
|
||||
output
|
||||
};
|
||||
body.push_str(&sse_frame("assistant_message", json!({"text": text})));
|
||||
body.push_str(&sse_frame(
|
||||
"completion",
|
||||
json!({"ok": true, "text": text, "steps": 1}),
|
||||
));
|
||||
}
|
||||
"run.failed" => {
|
||||
completed = true;
|
||||
let message = event
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Hermes 执行失败");
|
||||
body.push_str(&sse_frame(
|
||||
"error",
|
||||
json!({"ok": false, "message": message}),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !completed {
|
||||
let text = assistant_text.trim();
|
||||
let text = if text.is_empty() {
|
||||
"(无输出)"
|
||||
} else {
|
||||
text
|
||||
};
|
||||
body.push_str(&sse_frame("assistant_message", json!({"text": text})));
|
||||
body.push_str(&sse_frame(
|
||||
"completion",
|
||||
json!({"ok": true, "text": text, "steps": 1}),
|
||||
));
|
||||
}
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
.header(header::CONNECTION, "keep-alive")
|
||||
.header("x-accel-buffering", "no")
|
||||
.header("x-mnote-ai-execution-owner", "hermes")
|
||||
.body(Body::from(body))
|
||||
.map_err(|error| WebError::internal(format!("Hermes SSE 响应构造失败: {error}")))?;
|
||||
stamp_owner_header(response.headers_mut());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn proxy_ai_agent_run_to_next(
|
||||
base_url: &str,
|
||||
context: &RequestContext,
|
||||
@@ -619,9 +932,14 @@ pub async fn next_sidebar(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::compat::{
|
||||
build_hermes_run_payload, build_hermes_sse_response, parse_sse_data_json_values,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::response::IntoResponse;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -709,6 +1027,108 @@ mod tests {
|
||||
assert!(!text.contains("legacy_next_compat_disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_payload_uses_page_messages_and_session_id() {
|
||||
let payload = json!({
|
||||
"messages": [
|
||||
{"role": "system", "content": "系统约束"},
|
||||
{"role": "user", "content": "总结当前页面"}
|
||||
],
|
||||
"options": {
|
||||
"ai": {
|
||||
"provider": "hermes",
|
||||
"sessionId": "hermes-session-1"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let hermes_payload = build_hermes_run_payload(&payload);
|
||||
|
||||
assert_eq!(hermes_payload["session_id"], "hermes-session-1");
|
||||
assert_eq!(hermes_payload["input"][1]["content"], "总结当前页面");
|
||||
assert_eq!(hermes_payload["conversation_history"][0]["role"], "system");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_sse_is_translated_to_page_ai_events() {
|
||||
let events = parse_sse_data_json_values(
|
||||
"data: {\"event\":\"message.delta\",\"delta\":\"Hel\"}\n\n\
|
||||
data: {\"event\":\"message.delta\",\"delta\":\"lo\"}\n\n\
|
||||
data: {\"event\":\"run.completed\",\"output\":\"Hello\"}\n\n",
|
||||
);
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/ai-agent/run".parse::<Uri>().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
|
||||
let response = build_hermes_sse_response(&context, events).expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-execution-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("hermes")
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("event: assistant_message"));
|
||||
assert!(text.contains("event: completion"));
|
||||
assert!(text.contains("Hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() {
|
||||
let response = build_app(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:9".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
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(),
|
||||
}))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai-agent/run")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-execution-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("provider-bridge-unavailable")
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("codex"));
|
||||
assert!(!text.contains("mnote-cli"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_agent_run_proxies_to_next_ai_route_when_legacy_next_compat_enabled() {
|
||||
let next_app = axum::Router::new().route(
|
||||
|
||||
@@ -2,6 +2,9 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||
use crate::routes::local_folder_source::{
|
||||
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
resolve_effective_workspace_id,
|
||||
@@ -37,6 +40,8 @@ pub struct DocumentMetaQuery {
|
||||
pub struct DocumentSaveRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub editor_document: Option<Value>,
|
||||
@@ -51,6 +56,8 @@ pub struct DocumentSaveRequest {
|
||||
pub struct DocumentTitleRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub title: String,
|
||||
pub command_name: Option<String>,
|
||||
}
|
||||
@@ -60,6 +67,8 @@ pub struct DocumentTitleRequest {
|
||||
pub struct DocumentOptionsRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub options: Value,
|
||||
pub command_name: Option<String>,
|
||||
@@ -501,6 +510,19 @@ pub async fn save(
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
||||
let root_uri = body
|
||||
.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)
|
||||
})?;
|
||||
let result = save_local_markdown_page(root_uri, document_id, &body.content)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
@@ -520,6 +542,10 @@ pub async fn save(
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
@@ -582,6 +608,10 @@ pub async fn purge(
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: None,
|
||||
@@ -620,6 +650,19 @@ pub async fn title(
|
||||
WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context),
|
||||
);
|
||||
}
|
||||
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
||||
let root_uri = body
|
||||
.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)
|
||||
})?;
|
||||
let result = update_local_markdown_title(root_uri, document_id, title)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
@@ -634,6 +677,10 @@ pub async fn title(
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
@@ -691,6 +738,19 @@ pub async fn options(
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
||||
let root_uri = body
|
||||
.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)
|
||||
})?;
|
||||
let result = update_local_page_options(root_uri, document_id, &body.options)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
@@ -705,6 +765,10 @@ pub async fn options(
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: effective_workspace_id.clone(),
|
||||
@@ -1028,4 +1092,114 @@ mod tests {
|
||||
"page.layout.updateOptions"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_documents_save_title_and_options_write_to_disk() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-documents-write-{}",
|
||||
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"),
|
||||
"---\nmnote_id: local-stable\ntitle: Old Title\n---\n# Old\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let document_id = "local-mdid:local-stable";
|
||||
|
||||
let title_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/title")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"title": "New Local Title"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("title response");
|
||||
assert_eq!(title_response.status(), StatusCode::OK);
|
||||
|
||||
let save_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/save")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 2 },
|
||||
"content": [{ "type": "text", "text": "Saved Heading" }]
|
||||
},
|
||||
{
|
||||
"id": "paragraph_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "Saved body" }]
|
||||
}
|
||||
],
|
||||
"blockCount": 2
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("save response");
|
||||
assert_eq!(save_response.status(), StatusCode::OK);
|
||||
|
||||
let options_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/options")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"options": {
|
||||
"wideLayout": true,
|
||||
"showToc": false
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("options response");
|
||||
assert_eq!(options_response.status(), StatusCode::OK);
|
||||
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("mnote_id: local-stable"));
|
||||
assert!(markdown.contains("title: New Local Title"));
|
||||
assert!(markdown.contains("## Saved Heading"));
|
||||
assert!(markdown.contains("Saved body"));
|
||||
|
||||
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
|
||||
.expect("read page options");
|
||||
let options_json: Value = serde_json::from_str(&options).expect("options json");
|
||||
assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
|
||||
assert_eq!(options_json["pages"][document_id]["showToc"], false);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
|
||||
use crate::routes::web_shell::{
|
||||
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
|
||||
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
render_document_title_controller_script, render_editor_island_adapter_script,
|
||||
render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||
use crate::workspace_shell::{
|
||||
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
|
||||
@@ -40,6 +44,8 @@ struct GatewayManifest {
|
||||
pub(crate) struct RootEntryQuery {
|
||||
page_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
source_kind: Option<String>,
|
||||
root_uri: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn gateway_health(State(state): State<AppState>) -> Response {
|
||||
@@ -156,47 +162,129 @@ pub async fn root_entry(
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
let is_local_folder = query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
== Some("local_folder");
|
||||
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
|
||||
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
||||
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
|
||||
let requested_or_recent_page_id =
|
||||
choose_root_entry_active_page_id(requested_page_id, recent_page_id, None, None);
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id,
|
||||
recent_page_id,
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html = load_sidebar_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
selected_active_page_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html = load_file_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
selected_active_page_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
sidebar_tree_html,
|
||||
file_tree_html,
|
||||
selected_active_page_id,
|
||||
active_source_kind,
|
||||
active_root_uri,
|
||||
) = if is_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")
|
||||
})?;
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("id"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("local-folder")
|
||||
.to_string();
|
||||
let requested_or_recent_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&snapshot.dataset,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
"本地文件夹",
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html =
|
||||
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(root_uri, selected_active_page_id.as_deref())?;
|
||||
(
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
sidebar_tree_html,
|
||||
file_tree_html,
|
||||
selected_active_page_id,
|
||||
Some("local_folder".to_string()),
|
||||
Some(root_uri.to_string()),
|
||||
)
|
||||
} else {
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
let requested_or_recent_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html = load_sidebar_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
selected_active_page_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html = load_file_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
selected_active_page_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
(
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
sidebar_tree_html,
|
||||
file_tree_html,
|
||||
selected_active_page_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
@@ -228,6 +316,8 @@ pub async fn root_entry(
|
||||
&context,
|
||||
&active_page_id,
|
||||
Some(workspace_id.as_str()),
|
||||
active_source_kind.as_deref(),
|
||||
active_root_uri.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -237,7 +327,12 @@ pub async fn root_entry(
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
let snapshot_json =
|
||||
serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
||||
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
|
||||
let bootstrap_json = build_editor_bootstrap_json(
|
||||
&aggregate,
|
||||
&context,
|
||||
active_source_kind.as_deref(),
|
||||
active_root_uri.as_deref(),
|
||||
);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::document::DocumentPage
|
||||
title={title.to_string()}
|
||||
@@ -985,6 +1080,47 @@ mod tests {
|
||||
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_folder_without_debug_tree_route() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
|
||||
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.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);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains("Local Root"));
|
||||
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
|
||||
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
|
||||
assert!(!html.contains("legacy_next_compat_disabled"));
|
||||
assert!(!html.contains(r#"href="/tree"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_redirects_anonymous_viewer_to_auth() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ mod gateway;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
mod local_folder_source;
|
||||
mod mindmap_shell;
|
||||
mod query_support;
|
||||
mod search;
|
||||
@@ -91,6 +92,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/kernel/edges", get(kernel::edges))
|
||||
.route("/api/kernel/graph", get(kernel::graph))
|
||||
.route("/api/tree/commands", post(tree::tree_command))
|
||||
.route(
|
||||
"/api/tree/local-folder-watch",
|
||||
get(tree::local_folder_watch),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/runtime/reduce",
|
||||
post(tree::reduce_tree_shell_runtime),
|
||||
|
||||
@@ -70,6 +70,10 @@ pub fn runtime_context(
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
tenant_id: context.workspace.tenant_id.clone(),
|
||||
auth_token: context.auth.authorization.clone(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,10 @@ use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||
@@ -39,6 +43,8 @@ const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentShellQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -59,6 +65,8 @@ pub async fn document_page_shell(
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
query.source_kind.as_deref(),
|
||||
query.root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let title = aggregate.head.title.as_str();
|
||||
@@ -73,14 +81,28 @@ pub async fn document_page_shell(
|
||||
)
|
||||
.await;
|
||||
apply_active_page(&mut workspace_projection, Some(&document_id));
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html =
|
||||
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let is_local_folder = query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
== Some("local_folder");
|
||||
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
|
||||
let root_uri = query.root_uri.as_deref().unwrap_or_default();
|
||||
(
|
||||
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
@@ -92,7 +114,12 @@ pub async fn document_page_shell(
|
||||
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
||||
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
|
||||
let bootstrap_json = build_editor_bootstrap_json(
|
||||
&aggregate,
|
||||
&context,
|
||||
query.source_kind.as_deref(),
|
||||
query.root_uri.as_deref(),
|
||||
);
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<DocumentPage
|
||||
title={title.to_string()}
|
||||
@@ -139,11 +166,21 @@ pub async fn document_page_shell(
|
||||
pub(crate) fn build_editor_bootstrap_json(
|
||||
aggregate: &PageAggregate,
|
||||
context: &RequestContext,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> String {
|
||||
serde_json::to_string(&json!({
|
||||
"schema": "mnote.editor_bootstrap.v1",
|
||||
"documentId": aggregate.identity.document_id,
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"sourceKind": source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace"),
|
||||
"rootUri": root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(""),
|
||||
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
@@ -165,7 +202,10 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
input.setAttribute('data-title-controller', CONTRACT);
|
||||
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
|
||||
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
|
||||
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim();
|
||||
const sourceKind = (query.get('sourceKind') || '').trim();
|
||||
const rootUri = (query.get('rootUri') || '').trim();
|
||||
let lastSavedTitle = input.value.trim() || '无标题';
|
||||
let saving = false;
|
||||
|
||||
@@ -233,6 +273,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
workspaceId: workspaceId || null,
|
||||
sourceKind: sourceKind || undefined,
|
||||
rootUri: rootUri || undefined,
|
||||
title,
|
||||
commandName: 'page.head.updateTitle',
|
||||
}),
|
||||
@@ -317,6 +359,37 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return '';
|
||||
};
|
||||
|
||||
const legacyStylesToTiptapMarks = (styles) => {
|
||||
if (!styles || typeof styles !== 'object') return [];
|
||||
const marks = [];
|
||||
if (styles.bold) marks.push({ type: 'bold' });
|
||||
if (styles.italic) marks.push({ type: 'italic' });
|
||||
if (styles.underline) marks.push({ type: 'underline' });
|
||||
if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' });
|
||||
if (styles.code || styles.inlineCode) marks.push({ type: 'code' });
|
||||
const href = typeof styles.link === 'string' && styles.link.trim()
|
||||
? styles.link.trim()
|
||||
: typeof styles.href === 'string' && styles.href.trim()
|
||||
? styles.href.trim()
|
||||
: '';
|
||||
if (href) marks.push({ type: 'link', attrs: { href } });
|
||||
return marks;
|
||||
};
|
||||
|
||||
const legacyInlineContentToTiptap = (value) => {
|
||||
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
|
||||
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
|
||||
if (value && typeof value === 'object') {
|
||||
const text = typeof value.text === 'string' ? value.text : '';
|
||||
if (text) {
|
||||
const marks = legacyStylesToTiptapMarks(value.styles);
|
||||
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
|
||||
}
|
||||
return legacyInlineContentToTiptap(value.content || value.contentNodes);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const legacyBlockToTiptap = (block, index = 0) => {
|
||||
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
||||
const blockId = typeof block?.id === 'string' && block.id.trim()
|
||||
@@ -324,8 +397,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: typeof block?.blockId === 'string' && block.blockId.trim()
|
||||
? block.blockId.trim()
|
||||
: `block-${index + 1}`;
|
||||
const text = flattenText(block?.content);
|
||||
const content = text ? [{ type: 'text', text }] : [];
|
||||
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
|
||||
const textAlign = typeof block?.props?.textAlign === 'string'
|
||||
? block.props.textAlign
|
||||
: typeof block?.props?.text_align === 'string'
|
||||
@@ -337,10 +409,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: [];
|
||||
const withListChildren = (itemType, listType, attrs = {}) => ({
|
||||
type: listType,
|
||||
attrs: { blockId, ...attrs },
|
||||
attrs: { blockId },
|
||||
content: [{
|
||||
type: itemType,
|
||||
attrs: { blockId },
|
||||
attrs: { blockId, ...attrs },
|
||||
content: [
|
||||
{ type: 'paragraph', attrs: { blockId }, content },
|
||||
...nestedChildren,
|
||||
@@ -458,7 +530,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return node.content.flatMap((child) => {
|
||||
if (child?.type === 'text') {
|
||||
const text = typeof child.text === 'string' ? child.text : '';
|
||||
return text ? [{ type: 'text', text }] : [];
|
||||
if (!text) return [];
|
||||
const styles = {};
|
||||
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
|
||||
if (mark?.type === 'bold') styles.bold = true;
|
||||
if (mark?.type === 'italic') styles.italic = true;
|
||||
if (mark?.type === 'underline') styles.underline = true;
|
||||
if (mark?.type === 'strike') styles.strike = true;
|
||||
if (mark?.type === 'code') styles.code = true;
|
||||
if (mark?.type === 'link') {
|
||||
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
|
||||
}
|
||||
if (child?.type === 'hardBreak') {
|
||||
return [{ type: 'text', text: '\n' }];
|
||||
@@ -544,7 +629,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? { ...(block.props || {}) }
|
||||
: undefined,
|
||||
content: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
if (!text) return null;
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}),
|
||||
};
|
||||
}).filter(Boolean)
|
||||
: '',
|
||||
}));
|
||||
|
||||
@@ -577,6 +671,21 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
pageOptions: aggregate.layout?.pageOptions || {},
|
||||
};
|
||||
|
||||
const clearEmbeddedLocalDraft = () => {
|
||||
if (bootstrap.sourceKind !== 'local_folder') return;
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
const base = 'mnote.leptos-tiptap-spike.document';
|
||||
const keys = [
|
||||
`${base}:${bootstrap.workspaceId}:${bootstrap.documentId}`,
|
||||
`${base}:${bootstrap.documentId}`,
|
||||
];
|
||||
for (const key of keys) storage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.warn('mnote local folder 草稿清理失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
let saveTimer = 0;
|
||||
let lastSavedSerialized = '';
|
||||
const normalizeBridgeValue = (value) => {
|
||||
@@ -623,6 +732,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
body: JSON.stringify({
|
||||
documentId: bootstrap.documentId,
|
||||
workspaceId: bootstrap.workspaceId,
|
||||
sourceKind: bootstrap.sourceKind,
|
||||
rootUri: bootstrap.rootUri,
|
||||
revision: editorMeta.revision,
|
||||
conflictDetectionKey: editorMeta.conflictDetectionKey,
|
||||
editorDocument,
|
||||
@@ -687,6 +798,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
throw new Error('island runtime 导出不完整');
|
||||
}
|
||||
await runtime.default(wasmUrl);
|
||||
clearEmbeddedLocalDraft();
|
||||
const mountId = runtime.mount(root, mountOptions);
|
||||
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
||||
@@ -812,6 +924,8 @@ pub async fn documents_page_compat(
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
@@ -847,6 +961,8 @@ pub async fn page_aggregate(
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
query.source_kind.as_deref(),
|
||||
query.root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
@@ -876,7 +992,19 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<PageAggregate, WebError> {
|
||||
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
|
||||
let root_uri = root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
}
|
||||
|
||||
let meta = load_document_meta_result(
|
||||
state,
|
||||
context,
|
||||
@@ -944,6 +1072,7 @@ fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static(shell));
|
||||
}
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
|
||||
pub(crate) fn escape_html(value: &str) -> String {
|
||||
@@ -1123,11 +1252,35 @@ pub(crate) async fn load_file_tree_html(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_sidebar_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let rows = collect_page_tree_render_rows(&snapshot.projection);
|
||||
Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
rows,
|
||||
active_node_id: active_document_id.map(ToOwned::to_owned),
|
||||
focused_node_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_file_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
|
||||
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
|
||||
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -1262,6 +1415,13 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-kernel")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -1273,4 +1433,130 @@ mod tests {
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", 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"),
|
||||
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(
|
||||
payload["result"]["identity"]["documentId"],
|
||||
"local-md:README.md"
|
||||
);
|
||||
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
||||
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
||||
assert_eq!(payload["result"]["body"]["revision"], 0);
|
||||
assert!(payload["result"]["body"]["content"]
|
||||
.to_string()
|
||||
.contains("Local Heading"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
|
||||
.expect("write child md");
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Local Shell"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("Child Page"));
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/documents/doc_1?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("legacyInlineContentToTiptap"));
|
||||
assert!(html.contains("legacyStylesToTiptapMarks"));
|
||||
assert!(html.contains("marks.push({ type: 'code' })"));
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(!html.contains(
|
||||
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,7 @@ pub fn DocumentPage(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let page_show_heading_numbers = page_options
|
||||
.get("showHeadingNumbers")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let page_show_heading_numbers = false;
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
|
||||
<main
|
||||
|
||||
@@ -10,11 +10,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var PAGE_DRAG_MIME = 'application/x-mnote-page-tree-node';
|
||||
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
|
||||
var MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode';
|
||||
var MNOTE_RECENT_LOCAL_ROOTS_KEY = 'mnote.localFolder.recentRoots';
|
||||
var MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = 'mnote.global.showHeadingNumbers';
|
||||
var mnoteNavigationInFlight = '';
|
||||
var draggingPageNodeId = '';
|
||||
var activePageDropRow = null;
|
||||
var draggingFileTreeRowIds = [];
|
||||
var activeFileTreeDropRow = null;
|
||||
var sidebarFileTreeSelection = {
|
||||
selectedRowIds: new Set(),
|
||||
anchorRowId: null,
|
||||
focusedRowId: null
|
||||
};
|
||||
var projectionRefreshTimer = 0;
|
||||
var activeTreeContextMenu = null;
|
||||
var pageUiState = {
|
||||
@@ -24,7 +31,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiOpen: false,
|
||||
pageAiBusy: false,
|
||||
pageAiMessages: [],
|
||||
pageAiSuggestionIndex: 0
|
||||
pageAiSuggestionIndex: 0,
|
||||
pageAiProvider: 'hermes',
|
||||
pageAiPage: 'chat',
|
||||
pageAiSessions: [],
|
||||
pageAiActiveSessionId: ''
|
||||
};
|
||||
|
||||
function closestAction(target, selector) {
|
||||
@@ -69,7 +80,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
layoutDensity: 'normal',
|
||||
showHeadingNumbers: true,
|
||||
showHeadingNumbers: false,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
@@ -95,6 +106,28 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return pageUiState.pageOptions;
|
||||
}
|
||||
|
||||
function readGlobalShowHeadingNumbers() {
|
||||
try {
|
||||
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : '';
|
||||
return raw === 'true' || raw === '1';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGlobalShowHeadingNumbers(value) {
|
||||
try {
|
||||
if (window.localStorage) {
|
||||
window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false');
|
||||
}
|
||||
} catch (_) {}
|
||||
document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value)));
|
||||
}
|
||||
|
||||
function effectiveShowHeadingNumbers(options) {
|
||||
return readGlobalShowHeadingNumbers();
|
||||
}
|
||||
|
||||
function pageOptionIsSupported(name) {
|
||||
return name === 'wideLayout'
|
||||
|| name === 'smallText'
|
||||
@@ -187,6 +220,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function applyPageOptionsToShell() {
|
||||
var options = currentPageOptions();
|
||||
var globalShowHeadingNumbers = readGlobalShowHeadingNumbers();
|
||||
var showHeadingNumbers = effectiveShowHeadingNumbers(options);
|
||||
var shell = document.querySelector('.document-shell');
|
||||
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
|
||||
@@ -195,26 +230,27 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
shell.style.width = '100%';
|
||||
shell.style.maxWidth = options.wideLayout ? '980px' : '760px';
|
||||
}
|
||||
if (editorRoot instanceof HTMLElement) {
|
||||
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
editorRoot.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
editorRoot.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
|
||||
}
|
||||
if (editorSurface instanceof HTMLElement) {
|
||||
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
editorSurface.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
editorSurface.setAttribute('data-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
editorSurface.setAttribute('data-show-heading-numbers', String(showHeadingNumbers));
|
||||
}
|
||||
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
document.documentElement.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
document.documentElement.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
document.documentElement.setAttribute('data-global-show-heading-numbers', String(globalShowHeadingNumbers));
|
||||
document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
}
|
||||
|
||||
function normalizeSidebarTreeMode(value) {
|
||||
@@ -262,6 +298,280 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
|
||||
}
|
||||
|
||||
function copyWorkspaceSourceParams(targetUrl) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
['sourceKind', 'rootUri'].forEach(function(name) {
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) targetUrl.searchParams.set(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
function currentWorkspaceSourcePayload() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var payload = {};
|
||||
['sourceKind', 'rootUri'].forEach(function(name) {
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) payload[name] = value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readRecentLocalRoots() {
|
||||
try {
|
||||
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_RECENT_LOCAL_ROOTS_KEY) : '';
|
||||
var parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter(function(value) {
|
||||
return typeof value === 'string' && value.trim();
|
||||
}).slice(0, 10) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function rememberLocalRoot(rootUri) {
|
||||
try {
|
||||
if (!window.localStorage) return;
|
||||
var roots = readRecentLocalRoots().filter(function(value) { return value !== rootUri; });
|
||||
roots.unshift(rootUri);
|
||||
window.localStorage.setItem(MNOTE_RECENT_LOCAL_ROOTS_KEY, JSON.stringify(roots.slice(0, 10)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function pathToFileRootUri(value) {
|
||||
var trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^file:\/\//i.test(trimmed)) return trimmed;
|
||||
if (trimmed.charAt(0) !== '/') return '';
|
||||
return 'file://' + trimmed.split('/').map(function(part, index) {
|
||||
return index === 0 ? '' : encodeURIComponent(part);
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function openLocalFolderRoot(rootUri) {
|
||||
rememberLocalRoot(rootUri);
|
||||
var targetUrl = new URL('/', window.location.origin);
|
||||
targetUrl.searchParams.set('treeView', 'filetree');
|
||||
targetUrl.searchParams.set('sourceKind', 'local_folder');
|
||||
targetUrl.searchParams.set('rootUri', rootUri);
|
||||
window.location.href = targetUrl.toString();
|
||||
}
|
||||
|
||||
function closeLocalFolderDialog() {
|
||||
var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
}
|
||||
|
||||
function readPathFromDirectoryFiles(files) {
|
||||
var first = files && files.length ? files[0] : null;
|
||||
if (!first) return '';
|
||||
var rawPath = typeof first.path === 'string' ? first.path : '';
|
||||
var relative = typeof first.webkitRelativePath === 'string' ? first.webkitRelativePath : '';
|
||||
if (rawPath && relative) {
|
||||
var suffix = relative.split('/').filter(Boolean).join('/');
|
||||
if (suffix && rawPath.endsWith(suffix)) {
|
||||
return rawPath.slice(0, rawPath.length - suffix.length).replace(/[\/\\]$/, '');
|
||||
}
|
||||
}
|
||||
if (rawPath) return rawPath;
|
||||
return '';
|
||||
}
|
||||
|
||||
function requestBrowserFolderChoice(statusNode) {
|
||||
return new Promise(function(resolve) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.setAttribute('webkitdirectory', '');
|
||||
input.setAttribute('directory', '');
|
||||
input.style.position = 'fixed';
|
||||
input.style.left = '-9999px';
|
||||
input.addEventListener('change', function() {
|
||||
var selectedPath = readPathFromDirectoryFiles(input.files || []);
|
||||
if (input.parentElement) input.parentElement.removeChild(input);
|
||||
if (!selectedPath && statusNode instanceof HTMLElement) {
|
||||
statusNode.textContent = '当前浏览器没有暴露本机绝对路径,请在下方输入路径。';
|
||||
}
|
||||
resolve(selectedPath);
|
||||
}, { once: true });
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
async function requestNativeFolderChoice(statusNode) {
|
||||
var desktopPicker = window.__mnoteDesktop && typeof window.__mnoteDesktop.selectLocalFolder === 'function'
|
||||
? window.__mnoteDesktop.selectLocalFolder
|
||||
: null;
|
||||
if (desktopPicker) {
|
||||
var selected = await desktopPicker();
|
||||
return typeof selected === 'string' ? selected : '';
|
||||
}
|
||||
if (typeof window.showDirectoryPicker === 'function') {
|
||||
var handle = await window.showDirectoryPicker({ mode: 'read' });
|
||||
var handlePath = handle && (handle.path || handle.mnotePath || handle.nativePath);
|
||||
if (typeof handlePath === 'string' && handlePath.trim()) return handlePath;
|
||||
if (statusNode instanceof HTMLElement) {
|
||||
statusNode.textContent = '已选择“' + (handle && handle.name ? handle.name : '文件夹') + '”,但浏览器没有暴露本机绝对路径,请在下方确认路径。';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return requestBrowserFolderChoice(statusNode);
|
||||
}
|
||||
|
||||
function openLocalFolderDialog(initialMessage) {
|
||||
closeLocalFolderDialog();
|
||||
var recent = readRecentLocalRoots();
|
||||
var dialog = document.createElement('div');
|
||||
dialog.className = 'mnote-local-folder-dialog';
|
||||
dialog.setAttribute('data-testid', 'mnote-local-folder-dialog');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.style.position = 'fixed';
|
||||
dialog.style.inset = '0';
|
||||
dialog.style.zIndex = '2147483646';
|
||||
dialog.style.background = 'rgba(15, 23, 42, 0.28)';
|
||||
dialog.style.display = 'flex';
|
||||
dialog.style.alignItems = 'center';
|
||||
dialog.style.justifyContent = 'center';
|
||||
var card = document.createElement('div');
|
||||
card.className = 'mnote-local-folder-dialog__card';
|
||||
card.style.width = 'min(640px, calc(100vw - 32px))';
|
||||
card.style.background = '#fff';
|
||||
card.style.border = '1px solid rgba(27, 28, 28, 0.12)';
|
||||
card.style.borderRadius = '8px';
|
||||
card.style.padding = '16px';
|
||||
card.style.boxShadow = '0 18px 48px rgba(15, 23, 42, 0.22)';
|
||||
card.style.display = 'grid';
|
||||
card.style.gap = '12px';
|
||||
var title = document.createElement('h2');
|
||||
title.textContent = '打开本地文件夹';
|
||||
title.style.margin = '0';
|
||||
title.style.fontSize = '18px';
|
||||
title.style.lineHeight = '1.4';
|
||||
var status = document.createElement('p');
|
||||
status.className = 'mnote-local-folder-dialog__status';
|
||||
status.setAttribute('data-testid', 'mnote-local-folder-status');
|
||||
status.textContent = initialMessage || '选择一个本机文件夹,或输入绝对路径。';
|
||||
status.style.margin = '0';
|
||||
status.style.color = '#4b5563';
|
||||
status.style.fontSize = '13px';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.placeholder = '/mnt/Data1T/mnote/design/04-tree-domain/done';
|
||||
input.setAttribute('data-testid', 'mnote-local-folder-path-input');
|
||||
input.value = recent.length ? recent[0].replace(/^file:\/\//, '') : '';
|
||||
input.style.width = '100%';
|
||||
input.style.boxSizing = 'border-box';
|
||||
input.style.border = '1px solid rgba(27, 28, 28, 0.18)';
|
||||
input.style.borderRadius = '6px';
|
||||
input.style.padding = '10px 12px';
|
||||
input.style.fontSize = '14px';
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'mnote-local-folder-dialog__actions';
|
||||
actions.style.display = 'flex';
|
||||
actions.style.gap = '8px';
|
||||
actions.style.justifyContent = 'flex-end';
|
||||
var choose = document.createElement('button');
|
||||
choose.type = 'button';
|
||||
choose.textContent = '选择文件夹';
|
||||
choose.setAttribute('data-testid', 'mnote-local-folder-native-picker');
|
||||
var cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.textContent = '取消';
|
||||
var confirm = document.createElement('button');
|
||||
confirm.type = 'button';
|
||||
confirm.textContent = '打开';
|
||||
confirm.setAttribute('data-testid', 'mnote-local-folder-open-confirm');
|
||||
[choose, cancel, confirm].forEach(function(button) {
|
||||
button.style.border = '1px solid rgba(27, 28, 28, 0.14)';
|
||||
button.style.borderRadius = '6px';
|
||||
button.style.padding = '8px 12px';
|
||||
button.style.background = '#fff';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.fontSize = '14px';
|
||||
});
|
||||
function submit() {
|
||||
var rootUri = pathToFileRootUri(input.value);
|
||||
if (!rootUri) {
|
||||
status.textContent = '请输入以 / 开头的绝对路径,或 file:// URI。';
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
closeLocalFolderDialog();
|
||||
openLocalFolderRoot(rootUri);
|
||||
}
|
||||
choose.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
requestNativeFolderChoice(status).then(function(selectedPath) {
|
||||
if (selectedPath) {
|
||||
input.value = selectedPath;
|
||||
submit();
|
||||
}
|
||||
}).catch(function(error) {
|
||||
status.textContent = error && error.name === 'AbortError'
|
||||
? '已取消选择。'
|
||||
: '无法打开系统文件夹选择器,请输入路径。';
|
||||
});
|
||||
});
|
||||
cancel.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeLocalFolderDialog();
|
||||
});
|
||||
confirm.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
});
|
||||
input.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
actions.appendChild(choose);
|
||||
actions.appendChild(cancel);
|
||||
actions.appendChild(confirm);
|
||||
card.appendChild(title);
|
||||
card.appendChild(status);
|
||||
card.appendChild(input);
|
||||
if (recent.length) {
|
||||
var recentList = document.createElement('div');
|
||||
recentList.className = 'mnote-local-folder-dialog__recent';
|
||||
recent.slice(0, 5).forEach(function(rootUri) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = rootUri.replace(/^file:\/\//, '');
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
closeLocalFolderDialog();
|
||||
openLocalFolderRoot(rootUri);
|
||||
});
|
||||
recentList.appendChild(button);
|
||||
});
|
||||
card.appendChild(recentList);
|
||||
}
|
||||
if (recent.length) {
|
||||
var recentTitle = document.createElement('div');
|
||||
recentTitle.style.fontSize = '12px';
|
||||
recentTitle.style.color = '#6b7280';
|
||||
recentTitle.textContent = '最近使用';
|
||||
card.insertBefore(recentTitle, actions);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
dialog.appendChild(card);
|
||||
dialog.addEventListener('click', function(event) {
|
||||
if (event.target === dialog) closeLocalFolderDialog();
|
||||
});
|
||||
document.body.appendChild(dialog);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function requestOpenLocalFolder() {
|
||||
openLocalFolderDialog('');
|
||||
}
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
|
||||
@@ -273,17 +583,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
async function dispatchTreeCommand(trigger, body) {
|
||||
setCommandPending(trigger, true);
|
||||
var commandBody = Object.assign({}, currentWorkspaceSourcePayload(), body || {});
|
||||
try {
|
||||
var response = await fetch('/api/tree/commands', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify(commandBody)
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || !payload.result) {
|
||||
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: body, result: payload.result } }));
|
||||
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: commandBody, result: payload.result } }));
|
||||
return payload.result;
|
||||
} catch (error) {
|
||||
setCommandPending(trigger, false);
|
||||
@@ -319,6 +630,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
if (treeView === 'filetree') targetUrl.searchParams.set('treeView', 'filetree');
|
||||
copyWorkspaceSourceParams(targetUrl);
|
||||
var url = targetUrl.pathname + targetUrl.search;
|
||||
if (mnoteNavigationInFlight === url) return;
|
||||
mnoteNavigationInFlight = url;
|
||||
@@ -611,6 +923,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function documentHref(documentId, workspaceId) {
|
||||
var url = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin);
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
copyWorkspaceSourceParams(url);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -827,6 +1140,67 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}, x, y, trigger || row);
|
||||
}
|
||||
|
||||
function visibleFileTreeRows() {
|
||||
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
|
||||
.filter(function(row) { return row instanceof HTMLElement && row.offsetParent !== null; });
|
||||
}
|
||||
|
||||
function syncSidebarFileTreeSelection() {
|
||||
var rows = visibleFileTreeRows();
|
||||
rows.forEach(function(row) {
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
row.setAttribute('data-selected', String(Boolean(rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId))));
|
||||
row.setAttribute('data-focused', String(rowId === sidebarFileTreeSelection.focusedRowId));
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('tree.filetree.selection.changed', {
|
||||
detail: {
|
||||
selectedRowIds: Array.from(sidebarFileTreeSelection.selectedRowIds),
|
||||
anchorRowId: sidebarFileTreeSelection.anchorRowId,
|
||||
focusedRowId: sidebarFileTreeSelection.focusedRowId
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function selectSidebarFileTreeRow(row, modifiers) {
|
||||
if (!(row instanceof HTMLElement)) return [];
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
if (!rowId) return [];
|
||||
var rows = visibleFileTreeRows();
|
||||
var visibleRowIds = rows.map(function(item) { return item.getAttribute('data-row-id') || ''; }).filter(Boolean);
|
||||
var selected = new Set(sidebarFileTreeSelection.selectedRowIds);
|
||||
var shiftKey = Boolean(modifiers && modifiers.shiftKey);
|
||||
var ctrlKey = Boolean(modifiers && (modifiers.ctrlKey || modifiers.metaKey));
|
||||
if (shiftKey && sidebarFileTreeSelection.anchorRowId) {
|
||||
var anchorIndex = visibleRowIds.indexOf(sidebarFileTreeSelection.anchorRowId);
|
||||
var targetIndex = visibleRowIds.indexOf(rowId);
|
||||
if (anchorIndex >= 0 && targetIndex >= 0) {
|
||||
selected = new Set(visibleRowIds.slice(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex) + 1));
|
||||
} else {
|
||||
selected = new Set([rowId]);
|
||||
sidebarFileTreeSelection.anchorRowId = rowId;
|
||||
}
|
||||
} else if (ctrlKey) {
|
||||
if (selected.has(rowId) && selected.size > 1) selected.delete(rowId);
|
||||
else selected.add(rowId);
|
||||
sidebarFileTreeSelection.anchorRowId = rowId;
|
||||
} else {
|
||||
selected = new Set([rowId]);
|
||||
sidebarFileTreeSelection.anchorRowId = rowId;
|
||||
}
|
||||
sidebarFileTreeSelection.selectedRowIds = selected;
|
||||
sidebarFileTreeSelection.focusedRowId = rowId;
|
||||
syncSidebarFileTreeSelection();
|
||||
return Array.from(selected);
|
||||
}
|
||||
|
||||
function selectedSidebarFileTreeRowIdsForDrag(row) {
|
||||
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
|
||||
if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
|
||||
return Array.from(sidebarFileTreeSelection.selectedRowIds);
|
||||
}
|
||||
return rowId ? [rowId] : [];
|
||||
}
|
||||
|
||||
function ensureSearchModal() {
|
||||
var existing = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
@@ -1066,6 +1440,23 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function createGlobalHeadingNumbersRow() {
|
||||
return '' +
|
||||
'<label class="wolai-page-setting-row" data-page-setting-row="globalShowHeadingNumbers">' +
|
||||
'<span class="wolai-page-setting-copy">' +
|
||||
'<span class="wolai-page-setting-label">标题自动编号</span>' +
|
||||
'</span>' +
|
||||
'<input type="checkbox" class="wolai-page-setting-checkbox" data-global-option-checkbox="showHeadingNumbers" />' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function renderGlobalOptions(popover) {
|
||||
var globalHeadingNumbers = readGlobalShowHeadingNumbers();
|
||||
popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) {
|
||||
input.checked = globalHeadingNumbers;
|
||||
});
|
||||
}
|
||||
|
||||
function createPageFontRow() {
|
||||
return '' +
|
||||
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
|
||||
@@ -1199,6 +1590,147 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
];
|
||||
}
|
||||
|
||||
function pageAiStorageKey() {
|
||||
return 'doc_ai_sessions:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
var now = Date.now();
|
||||
return {
|
||||
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
|
||||
title: title || '新会话',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiNormalizeSessions(sessions) {
|
||||
return (Array.isArray(sessions) ? sessions : [])
|
||||
.slice(0, 20)
|
||||
.map(function(session) {
|
||||
return {
|
||||
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
createdAt: Number(session && session.createdAt || Date.now()),
|
||||
updatedAt: Number(session && session.updatedAt || Date.now()),
|
||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-40) : []
|
||||
};
|
||||
})
|
||||
.sort(function(a, b) {
|
||||
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(pageAiStorageKey());
|
||||
if (!raw) {
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions ? parsed.sessions : []);
|
||||
if (!sessions.length) {
|
||||
var fallback = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fallback];
|
||||
pageUiState.pageAiActiveSessionId = fallback.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
return;
|
||||
}
|
||||
pageUiState.pageAiSessions = sessions;
|
||||
var activeId = String(parsed && parsed.activeSessionId || '').trim();
|
||||
var active = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
|
||||
pageUiState.pageAiActiveSessionId = active.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : [];
|
||||
} catch (_) {
|
||||
var reset = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [reset];
|
||||
pageUiState.pageAiActiveSessionId = reset.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
try {
|
||||
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function pageAiCurrentSession() {
|
||||
return pageUiState.pageAiSessions.find(function(session) {
|
||||
return session.id === pageUiState.pageAiActiveSessionId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiSetActiveSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiStartNewSession() {
|
||||
var session = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions));
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiProviderLabel(provider) {
|
||||
if (provider === 'codex') return 'Codex';
|
||||
if (provider === 'claudecode') return 'ClaudeCode';
|
||||
return 'Hermes';
|
||||
}
|
||||
|
||||
function renderPageAiProviderButtons() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
|
||||
var provider = button.getAttribute('data-page-ai-provider') || 'hermes';
|
||||
var active = provider === pageUiState.pageAiProvider;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
var subtitle = drawer.querySelector('.wolai-page-ai-subtitle');
|
||||
if (subtitle instanceof HTMLElement) {
|
||||
subtitle.textContent = '当前页面上下文优先 · ' + pageAiProviderLabel(pageUiState.pageAiProvider);
|
||||
}
|
||||
}
|
||||
|
||||
function humanizePageAiResponse(rawText, promptText) {
|
||||
var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider);
|
||||
var text = String(rawText || '').trim();
|
||||
if (!text) {
|
||||
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”,但这次没有返回可读内容。';
|
||||
}
|
||||
if (text.startsWith('{')) {
|
||||
try {
|
||||
var payload = JSON.parse(text);
|
||||
var operation = payload && payload.operation ? payload.operation : {};
|
||||
var normalized = operation && operation.normalized_input ? operation.normalized_input : {};
|
||||
var args = normalized && normalized.args ? normalized.args : {};
|
||||
var documentId = searchText(args.documentId || args.pageId || currentDocumentId());
|
||||
var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get');
|
||||
return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 mnote-cli 建立页面级工具调用,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。';
|
||||
} catch (_) {
|
||||
return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。';
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function ensurePageAiDrawer() {
|
||||
var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
@@ -1212,7 +1744,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-header">' +
|
||||
'<div class="wolai-page-ai-header-copy">' +
|
||||
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">智能问答</h2>' +
|
||||
'<div class="wolai-page-ai-subtitle">当前页面上下文优先 · mnote-cli</div>' +
|
||||
'<div class="wolai-page-ai-subtitle">当前页面上下文优先 · Hermes</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
|
||||
'</div>' +
|
||||
@@ -1230,7 +1762,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-toolbar">' +
|
||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-action="model">mnote-cli</button>' +
|
||||
'<div class="wolai-page-ai-provider-group">' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="codex" aria-pressed="false">Codex</button>' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="claudecode" aria-pressed="false">ClaudeCode</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-input-row">' +
|
||||
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
|
||||
@@ -1258,6 +1794,24 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var conversation = drawer.querySelector('[data-page-ai-conversation]');
|
||||
if (!(conversation instanceof HTMLElement)) return;
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
if (!pageUiState.pageAiSessions.length) {
|
||||
conversation.innerHTML = '<div class="wolai-page-ai-empty">尚未产生历史会话。</div>';
|
||||
return;
|
||||
}
|
||||
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
|
||||
var preview = Array.isArray(session.messages) && session.messages.length
|
||||
? session.messages.slice(-1)[0].content
|
||||
: '暂无消息';
|
||||
var active = session.id === pageUiState.pageAiActiveSessionId;
|
||||
return '' +
|
||||
'<button type="button" class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||
'<div class="wolai-page-ai-message-role">会话</div>' +
|
||||
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '新会话') + '</strong><br />' + escapeHtml(preview) + '</div>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
if (!pageUiState.pageAiMessages.length) {
|
||||
conversation.innerHTML = '<div class="wolai-page-ai-empty">围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。</div>';
|
||||
return;
|
||||
@@ -1273,8 +1827,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function openPageAiDrawer() {
|
||||
pageAiLoadSessions();
|
||||
renderPageAiSuggestions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiProviderButtons();
|
||||
var drawer = ensurePageAiDrawer();
|
||||
drawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
@@ -1320,12 +1876,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (pageUiState.pageAiBusy) return;
|
||||
var prompt = searchText(text);
|
||||
if (!prompt) return;
|
||||
pageAiLoadSessions();
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiBusy = true;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
var currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
if (currentSession.title === '新会话') {
|
||||
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
|
||||
}
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
try {
|
||||
var response = await fetch('/api/ai-agent/run', {
|
||||
@@ -1354,7 +1920,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
},
|
||||
options: {
|
||||
searxng: true,
|
||||
ai: { provider: 'hermes' }
|
||||
ai: { provider: pageUiState.pageAiProvider }
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -1372,13 +1938,25 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantText || '当前页面 AI 已接通 `mnote-cli`,但这次没有返回文本结果。'
|
||||
content: humanizePageAiResponse(assistantText, prompt)
|
||||
});
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: '当前页面 AI 抽屉已接到 `mnote-cli` 主链,但本次请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' 当前请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
});
|
||||
currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
} finally {
|
||||
pageUiState.pageAiBusy = false;
|
||||
renderPageAiConversation();
|
||||
@@ -1404,7 +1982,6 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
createPageOptionRow('wideLayout', 'checkbox') +
|
||||
createPageOptionRow('smallText', 'checkbox') +
|
||||
createPageOptionRow('showToc', 'checkbox') +
|
||||
createPageOptionRow('showHeadingNumbers', 'checkbox') +
|
||||
createPageOptionRow('protectEditing', 'checkbox') +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-section" data-page-settings-panel="custom" hidden>' +
|
||||
@@ -1415,7 +1992,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
createPageOptionRow('showBlockRefCount', 'checkbox') +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
||||
'<div class="wolai-page-settings-global-note">当前 Rust Web 壳仅保留最小全局设置入口,真正的全局偏好仍待后续统一收口。</div>' +
|
||||
createGlobalHeadingNumbersRow() +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-actions">' +
|
||||
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">页面历史...</button>' +
|
||||
@@ -1443,6 +2020,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var value = key === 'layoutDensity' ? String(options.layoutDensity || 'normal') : String(options.pageFont || 'default');
|
||||
select.value = value;
|
||||
});
|
||||
renderGlobalOptions(popover);
|
||||
var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]');
|
||||
if (statsNode instanceof HTMLElement) {
|
||||
var stats = computeLivePageStats();
|
||||
@@ -1479,6 +2057,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
body: JSON.stringify({
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
...currentWorkspaceSourcePayload(),
|
||||
options: nextOptions,
|
||||
commandName: 'page.layout.updateOptions'
|
||||
})
|
||||
@@ -1611,6 +2190,21 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
|
||||
if (pageAiProvider) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiProvider = pageAiProvider.getAttribute('data-page-ai-provider') || 'hermes';
|
||||
renderPageAiProviderButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
pageAiSetActiveSession(pageAiSession.getAttribute('data-page-ai-session') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
|
||||
if (pageAiSuggestion) {
|
||||
e.preventDefault();
|
||||
@@ -1626,7 +2220,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
|
||||
if (pageAiNewSession) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiMessages = [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiStartNewSession();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiHistory = closestAction(e.target, '[data-page-ai-action="history"]');
|
||||
if (pageAiHistory) {
|
||||
e.preventDefault();
|
||||
pageAiLoadSessions();
|
||||
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
||||
renderPageAiConversation();
|
||||
return;
|
||||
}
|
||||
@@ -1662,6 +2265,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]');
|
||||
if (localFolderTrigger) {
|
||||
e.preventDefault();
|
||||
requestOpenLocalFolder();
|
||||
return;
|
||||
}
|
||||
|
||||
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
|
||||
if (createTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -1691,18 +2301,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
if (fileAction === 'menu') {
|
||||
e.preventDefault();
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
|
||||
var point = rowCenter(fileBtn || fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
openFileTreeContextMenu(fileRow, point.x, point.y, fileBtn || fileRow);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
fileTree.querySelectorAll('.tree-row[data-selected="true"]').forEach(function(row) {
|
||||
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'false');
|
||||
});
|
||||
fileRow.setAttribute('data-selected', 'true');
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
|
||||
if ((rowKind === 'document' || rowKind === 'index') && documentId) {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
|
||||
} else if (assetId) {
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId });
|
||||
@@ -1755,6 +2366,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
|
||||
if (fileRow) {
|
||||
event.preventDefault();
|
||||
var contextRowId = fileRow.getAttribute('data-row-id') || '';
|
||||
if (contextRowId && !sidebarFileTreeSelection.selectedRowIds.has(contextRowId)) {
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
|
||||
}
|
||||
openFileTreeContextMenu(fileRow, event.clientX, event.clientY, fileRow);
|
||||
return;
|
||||
}
|
||||
@@ -1792,6 +2407,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
|
||||
if (globalCheckbox instanceof HTMLInputElement) {
|
||||
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
return;
|
||||
}
|
||||
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
|
||||
if (checkbox instanceof HTMLInputElement) {
|
||||
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
|
||||
@@ -1880,8 +2502,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"][draggable="true"]');
|
||||
if (fileRow) {
|
||||
var rowId = fileRow.getAttribute('data-row-id') || '';
|
||||
draggingFileTreeRowIds = rowId ? [rowId] : [];
|
||||
draggingFileTreeRowIds = selectedSidebarFileTreeRowIdsForDrag(fileRow);
|
||||
if (event.dataTransfer) {
|
||||
var payload = JSON.stringify({ type: 'mnote-file-tree-dnd', version: 1, rowIds: draggingFileTreeRowIds });
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
@@ -2014,6 +2635,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var activeRow = tree.querySelector('.tree-row[data-node-id="' + cssEscape(activeId) + '"]');
|
||||
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'true');
|
||||
}
|
||||
var initiallySelectedFileRows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-selected="true"]');
|
||||
initiallySelectedFileRows.forEach(function(row) {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
if (!rowId) return;
|
||||
sidebarFileTreeSelection.selectedRowIds.add(rowId);
|
||||
if (!sidebarFileTreeSelection.anchorRowId) sidebarFileTreeSelection.anchorRowId = rowId;
|
||||
sidebarFileTreeSelection.focusedRowId = rowId;
|
||||
});
|
||||
syncSidebarFileTreeSelection();
|
||||
restoreSidebarTreeTab();
|
||||
})();
|
||||
"##;
|
||||
@@ -2078,6 +2709,13 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
var bootstrap = readBootstrap();
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sourceKind = (params.get('sourceKind') || '').trim();
|
||||
if (sourceKind === 'local_folder') {
|
||||
applyTransport('local-folder-static');
|
||||
applyStatus('static');
|
||||
return;
|
||||
}
|
||||
applyTransport(bootstrap.transport || 'convex-command-log-sse');
|
||||
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
|
||||
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
|
||||
@@ -2220,6 +2858,7 @@ pub fn PageLayout(
|
||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
|
||||
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
||||
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
|
||||
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
|
||||
<a href="/more" title="更多" aria-label="更多"><span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span></a>
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
@@ -2269,6 +2908,8 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_SIDEBAR_TREE_MODE_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
|
||||
assert!(SIDEBAR_TREE_JS.contains("restoreSidebarTreeTab"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("treeView"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
|
||||
@@ -2281,6 +2922,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.asset.open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -265,20 +265,25 @@ a:hover {
|
||||
.wolai-quick-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 12px 18px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a {
|
||||
.wolai-quick-actions a,
|
||||
.wolai-quick-actions button {
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: #4B4B4B;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a .nav-icon {
|
||||
.wolai-quick-actions a .nav-icon,
|
||||
.wolai-quick-actions button .nav-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -495,7 +500,8 @@ a:hover {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a {
|
||||
.mnote-sidebar-nav a,
|
||||
.mnote-sidebar-nav button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
@@ -509,7 +515,8 @@ a:hover {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a:hover {
|
||||
.mnote-sidebar-nav a:hover,
|
||||
.mnote-sidebar-nav button:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
@@ -517,7 +524,8 @@ a:hover {
|
||||
background: var(--wolai-bg-active);
|
||||
}
|
||||
|
||||
.mnote-sidebar-nav a .nav-icon {
|
||||
.mnote-sidebar-nav a .nav-icon,
|
||||
.mnote-sidebar-nav button .nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1268,23 +1276,26 @@ body {
|
||||
}
|
||||
|
||||
.wolai-quick-actions {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10.4px;
|
||||
padding: 5px 8px 11px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a,
|
||||
.wolai-quick-actions button,
|
||||
.mnote-sidebar-nav a {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a {
|
||||
.wolai-quick-actions a,
|
||||
.wolai-quick-actions button {
|
||||
height: 30px;
|
||||
color: #3F3D39;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a:hover {
|
||||
.wolai-quick-actions a:hover,
|
||||
.wolai-quick-actions button:hover {
|
||||
background: rgba(27, 28, 28, 0.07);
|
||||
}
|
||||
|
||||
@@ -2460,6 +2471,12 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-provider-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-suggestion {
|
||||
min-height: 32px;
|
||||
padding: 8px 10px;
|
||||
@@ -2490,6 +2507,18 @@ body {
|
||||
background: #F7F7F7;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message--history {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message--history.is-active {
|
||||
border-color: rgba(27, 28, 28, 0.28);
|
||||
background: #F4F8FF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message--user {
|
||||
background: #F4F8FF;
|
||||
}
|
||||
@@ -2512,6 +2541,11 @@ body {
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-model-chip.is-active {
|
||||
background: #1B1C1C;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -783,6 +783,7 @@ mod tests {
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct FileTreeRuntimeRow {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeRuntimeState {
|
||||
pub active_row_id: Option<String>,
|
||||
pub selection: FileTreeSelectionState,
|
||||
pub drag_row_ids: Vec<String>,
|
||||
pub drag_effect: Option<TreeShellDragEffect>,
|
||||
@@ -70,6 +71,18 @@ pub enum FileTreeRuntimeAction {
|
||||
OpenRow {
|
||||
row_id: String,
|
||||
},
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
FocusFirst,
|
||||
FocusLast,
|
||||
OpenFocused,
|
||||
ContextMenuFocused,
|
||||
BeginRenameFocused,
|
||||
DeleteSelection,
|
||||
CopySelection,
|
||||
CutSelection,
|
||||
PasteIntoFocused,
|
||||
Escape,
|
||||
ContextMenuRow {
|
||||
row_id: String,
|
||||
},
|
||||
@@ -90,6 +103,11 @@ pub enum FileTreeIntentEvent {
|
||||
row_id: String,
|
||||
target: FileTreeOpenTarget,
|
||||
},
|
||||
KeyboardCommand {
|
||||
command: FileTreeKeyboardCommand,
|
||||
row_ids: Vec<String>,
|
||||
target_row_id: Option<String>,
|
||||
},
|
||||
InternalDrop {
|
||||
target_row_id: Option<String>,
|
||||
target: Option<FileTreeOpenTarget>,
|
||||
@@ -103,6 +121,16 @@ pub enum FileTreeIntentEvent {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FileTreeKeyboardCommand {
|
||||
Rename,
|
||||
Delete,
|
||||
Copy,
|
||||
Cut,
|
||||
Paste,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
@@ -241,16 +269,86 @@ impl FileTreeRuntimeState {
|
||||
}
|
||||
FileTreeRuntimeAction::OpenRow { row_id } => {
|
||||
if let Some(target) = resolve_open_target(env, &row_id) {
|
||||
let mut state = self.clone();
|
||||
state.active_row_id = Some(row_id);
|
||||
transition(
|
||||
self.clone(),
|
||||
[FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open {
|
||||
target,
|
||||
})],
|
||||
state,
|
||||
[
|
||||
FileTreeRuntimeOutput::DomPatch,
|
||||
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
transition(self.clone(), [])
|
||||
}
|
||||
}
|
||||
FileTreeRuntimeAction::FocusNext => transition(
|
||||
focus_relative_row(self, env, 1),
|
||||
[FileTreeRuntimeOutput::DomPatch],
|
||||
),
|
||||
FileTreeRuntimeAction::FocusPrevious => transition(
|
||||
focus_relative_row(self, env, -1),
|
||||
[FileTreeRuntimeOutput::DomPatch],
|
||||
),
|
||||
FileTreeRuntimeAction::FocusFirst => transition(
|
||||
focus_absolute_row(self, env, 0),
|
||||
[FileTreeRuntimeOutput::DomPatch],
|
||||
),
|
||||
FileTreeRuntimeAction::FocusLast => transition(
|
||||
focus_absolute_row(self, env, env.visible_row_ids.len().saturating_sub(1)),
|
||||
[FileTreeRuntimeOutput::DomPatch],
|
||||
),
|
||||
FileTreeRuntimeAction::OpenFocused => {
|
||||
if let Some(row_id) = self.selection.focused_row_id.clone() {
|
||||
self.reduce(env, FileTreeRuntimeAction::OpenRow { row_id })
|
||||
} else {
|
||||
transition(self.clone(), [])
|
||||
}
|
||||
}
|
||||
FileTreeRuntimeAction::ContextMenuFocused => {
|
||||
if let Some(row_id) = self.selection.focused_row_id.clone() {
|
||||
self.reduce(env, FileTreeRuntimeAction::ContextMenuRow { row_id })
|
||||
} else {
|
||||
transition(self.clone(), [])
|
||||
}
|
||||
}
|
||||
FileTreeRuntimeAction::BeginRenameFocused => keyboard_command_transition(
|
||||
self,
|
||||
FileTreeKeyboardCommand::Rename,
|
||||
selected_or_focused_rows(self),
|
||||
self.selection.focused_row_id.clone(),
|
||||
),
|
||||
FileTreeRuntimeAction::DeleteSelection => keyboard_command_transition(
|
||||
self,
|
||||
FileTreeKeyboardCommand::Delete,
|
||||
selected_or_focused_rows(self),
|
||||
self.selection.focused_row_id.clone(),
|
||||
),
|
||||
FileTreeRuntimeAction::CopySelection => keyboard_command_transition(
|
||||
self,
|
||||
FileTreeKeyboardCommand::Copy,
|
||||
selected_or_focused_rows(self),
|
||||
self.selection.focused_row_id.clone(),
|
||||
),
|
||||
FileTreeRuntimeAction::CutSelection => keyboard_command_transition(
|
||||
self,
|
||||
FileTreeKeyboardCommand::Cut,
|
||||
selected_or_focused_rows(self),
|
||||
self.selection.focused_row_id.clone(),
|
||||
),
|
||||
FileTreeRuntimeAction::PasteIntoFocused => keyboard_command_transition(
|
||||
self,
|
||||
FileTreeKeyboardCommand::Paste,
|
||||
Vec::new(),
|
||||
self.selection.focused_row_id.clone(),
|
||||
),
|
||||
FileTreeRuntimeAction::Escape => {
|
||||
let mut state = self.clone();
|
||||
state.drag_row_ids = Vec::new();
|
||||
state.drag_effect = None;
|
||||
state.drop_target_row_id = None;
|
||||
transition(state, [FileTreeRuntimeOutput::DomPatch])
|
||||
}
|
||||
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
|
||||
if let Some(target) = resolve_open_target(env, &row_id) {
|
||||
transition(
|
||||
@@ -267,6 +365,75 @@ impl FileTreeRuntimeState {
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_relative_row(
|
||||
state: &FileTreeRuntimeState,
|
||||
env: &FileTreeRuntimeEnvironment,
|
||||
offset: isize,
|
||||
) -> FileTreeRuntimeState {
|
||||
if env.visible_row_ids.is_empty() {
|
||||
return state.clone();
|
||||
}
|
||||
let current = state
|
||||
.selection
|
||||
.focused_row_id
|
||||
.as_ref()
|
||||
.and_then(|row_id| {
|
||||
env.visible_row_ids
|
||||
.iter()
|
||||
.position(|candidate| candidate == row_id)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let next = if offset.is_negative() {
|
||||
current.saturating_sub(offset.unsigned_abs())
|
||||
} else {
|
||||
(current + offset as usize).min(env.visible_row_ids.len() - 1)
|
||||
};
|
||||
focus_absolute_row(state, env, next)
|
||||
}
|
||||
|
||||
fn focus_absolute_row(
|
||||
state: &FileTreeRuntimeState,
|
||||
env: &FileTreeRuntimeEnvironment,
|
||||
index: usize,
|
||||
) -> FileTreeRuntimeState {
|
||||
let mut next = state.clone();
|
||||
next.selection.focused_row_id = env.visible_row_ids.get(index).cloned();
|
||||
next
|
||||
}
|
||||
|
||||
fn selected_or_focused_rows(state: &FileTreeRuntimeState) -> Vec<String> {
|
||||
if !state.selection.selected_row_ids.is_empty() {
|
||||
return state.selection.selected_row_ids.iter().cloned().collect();
|
||||
}
|
||||
state
|
||||
.selection
|
||||
.focused_row_id
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn keyboard_command_transition(
|
||||
state: &FileTreeRuntimeState,
|
||||
command: FileTreeKeyboardCommand,
|
||||
row_ids: Vec<String>,
|
||||
target_row_id: Option<String>,
|
||||
) -> FileTreeRuntimeTransition {
|
||||
if row_ids.is_empty() && !matches!(command, FileTreeKeyboardCommand::Paste) {
|
||||
return transition(state.clone(), []);
|
||||
}
|
||||
transition(
|
||||
state.clone(),
|
||||
[FileTreeRuntimeOutput::Intent(
|
||||
FileTreeIntentEvent::KeyboardCommand {
|
||||
command,
|
||||
row_ids,
|
||||
target_row_id,
|
||||
},
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_open_target(
|
||||
env: &FileTreeRuntimeEnvironment,
|
||||
row_id: &str,
|
||||
@@ -309,8 +476,9 @@ fn transition<const N: usize>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FileTreeIntentEvent, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment,
|
||||
FileTreeRuntimeOutput, FileTreeRuntimeRow, FileTreeRuntimeState,
|
||||
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeOpenTarget, FileTreeRuntimeAction,
|
||||
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeRow,
|
||||
FileTreeRuntimeState,
|
||||
};
|
||||
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
|
||||
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
|
||||
@@ -436,6 +604,7 @@ mod tests {
|
||||
},
|
||||
},
|
||||
)));
|
||||
assert_eq!(transition.state.active_row_id.as_deref(), Some("doc:root"));
|
||||
|
||||
let transition = transition.state.reduce(
|
||||
&env(),
|
||||
@@ -551,4 +720,70 @@ mod tests {
|
||||
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_runtime_keyboard_contract_separates_active_selection_and_focus() {
|
||||
let state = FileTreeRuntimeState {
|
||||
active_row_id: Some("doc:root".into()),
|
||||
selection: crate::tree_shell::filetree_selection::FileTreeSelectionState::from_selected(
|
||||
&["doc:root".to_string()],
|
||||
),
|
||||
..FileTreeRuntimeState::default()
|
||||
};
|
||||
|
||||
let focused = state.reduce(&env(), FileTreeRuntimeAction::FocusNext);
|
||||
assert_eq!(focused.state.active_row_id.as_deref(), Some("doc:root"));
|
||||
assert_eq!(
|
||||
focused.state.selection.focused_row_id.as_deref(),
|
||||
Some("index:root")
|
||||
);
|
||||
assert!(focused
|
||||
.state
|
||||
.selection
|
||||
.selected_row_ids
|
||||
.contains("doc:root"));
|
||||
|
||||
let opened = focused
|
||||
.state
|
||||
.reduce(&env(), FileTreeRuntimeAction::OpenFocused);
|
||||
assert_eq!(opened.state.active_row_id.as_deref(), Some("index:root"));
|
||||
assert!(opened.outputs.contains(&FileTreeRuntimeOutput::Intent(
|
||||
FileTreeIntentEvent::Open {
|
||||
target: FileTreeOpenTarget::Index {
|
||||
document_id: "root".into(),
|
||||
},
|
||||
},
|
||||
)));
|
||||
|
||||
let rename = focused
|
||||
.state
|
||||
.reduce(&env(), FileTreeRuntimeAction::BeginRenameFocused);
|
||||
assert!(rename.outputs.contains(&FileTreeRuntimeOutput::Intent(
|
||||
FileTreeIntentEvent::KeyboardCommand {
|
||||
command: FileTreeKeyboardCommand::Rename,
|
||||
row_ids: vec!["doc:root".into()],
|
||||
target_row_id: Some("index:root".into()),
|
||||
},
|
||||
)));
|
||||
let cut = focused
|
||||
.state
|
||||
.reduce(&env(), FileTreeRuntimeAction::CutSelection);
|
||||
assert!(cut.outputs.contains(&FileTreeRuntimeOutput::Intent(
|
||||
FileTreeIntentEvent::KeyboardCommand {
|
||||
command: FileTreeKeyboardCommand::Cut,
|
||||
row_ids: vec!["doc:root".into()],
|
||||
target_row_id: Some("index:root".into()),
|
||||
},
|
||||
)));
|
||||
let escaped = FileTreeRuntimeState {
|
||||
drag_row_ids: vec!["doc:root".into()],
|
||||
drag_effect: Some(TreeShellDragEffect::Move),
|
||||
drop_target_row_id: Some("index:root".into()),
|
||||
..focused.state
|
||||
}
|
||||
.reduce(&env(), FileTreeRuntimeAction::Escape);
|
||||
assert!(escaped.state.drag_row_ids.is_empty());
|
||||
assert_eq!(escaped.state.drag_effect, None);
|
||||
assert_eq!(escaped.state.drop_target_row_id, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ impl Default for TreeShellRuntimeArtifactBoundary {
|
||||
"fileTreeContextMenu",
|
||||
"fileTreeInternalDrop",
|
||||
"fileTreeExternalDrop",
|
||||
"fileTreeKeyboardCommand",
|
||||
"pickerPickRoot",
|
||||
"pickerPickDocument",
|
||||
]),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::drag_drop_state::TreeShellDragEffect;
|
||||
use super::filetree_runtime::{
|
||||
FileTreeIntentEvent, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeOutput,
|
||||
FileTreeRuntimeState,
|
||||
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeRuntimeAction,
|
||||
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeState,
|
||||
};
|
||||
use super::page_runtime::{
|
||||
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent,
|
||||
@@ -81,6 +81,7 @@ pub enum TreeShellDomPatch {
|
||||
drop_feedback: Option<PageTreeDropFeedback>,
|
||||
},
|
||||
FileTreeState {
|
||||
active_row_id: Option<String>,
|
||||
selected_row_ids: BTreeSet<String>,
|
||||
anchor_row_id: Option<String>,
|
||||
focused_row_id: Option<String>,
|
||||
@@ -125,6 +126,11 @@ pub enum TreeShellHostEvent {
|
||||
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
|
||||
file_count: u32,
|
||||
},
|
||||
FileTreeKeyboardCommand {
|
||||
command: FileTreeKeyboardCommand,
|
||||
row_ids: Vec<String>,
|
||||
target_row_id: Option<String>,
|
||||
},
|
||||
PickerPickRoot,
|
||||
PickerPickDocument {
|
||||
document_id: String,
|
||||
@@ -279,6 +285,7 @@ pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellR
|
||||
.iter()
|
||||
.filter_map(|output| match output {
|
||||
FileTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::FileTreeState {
|
||||
active_row_id: transition.state.active_row_id.clone(),
|
||||
selected_row_ids: transition.state.selection.selected_row_ids.clone(),
|
||||
anchor_row_id: transition.state.selection.anchor_row_id.clone(),
|
||||
focused_row_id: transition.state.selection.focused_row_id.clone(),
|
||||
@@ -325,6 +332,15 @@ pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellR
|
||||
target: target.clone(),
|
||||
file_count: *file_count,
|
||||
}),
|
||||
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::KeyboardCommand {
|
||||
command,
|
||||
row_ids,
|
||||
target_row_id,
|
||||
}) => Some(TreeShellHostEvent::FileTreeKeyboardCommand {
|
||||
command: *command,
|
||||
row_ids: row_ids.clone(),
|
||||
target_row_id: target_row_id.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -596,6 +612,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
filetree.dom_patches,
|
||||
vec![TreeShellDomPatch::FileTreeState {
|
||||
active_row_id: None,
|
||||
selected_row_ids: set(&["asset:image"]),
|
||||
anchor_row_id: Some("asset:image".into()),
|
||||
focused_row_id: Some("asset:image".into()),
|
||||
@@ -638,6 +655,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
drag_result.dom_patches,
|
||||
vec![TreeShellDomPatch::FileTreeState {
|
||||
active_row_id: None,
|
||||
selected_row_ids: set(&["asset:image"]),
|
||||
anchor_row_id: Some("asset:image".into()),
|
||||
focused_row_id: Some("asset:image".into()),
|
||||
@@ -675,6 +693,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
drop_target_result.dom_patches,
|
||||
vec![TreeShellDomPatch::FileTreeState {
|
||||
active_row_id: None,
|
||||
selected_row_ids: BTreeSet::new(),
|
||||
anchor_row_id: None,
|
||||
focused_row_id: None,
|
||||
@@ -730,6 +749,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
internal_drop.dom_patches,
|
||||
vec![TreeShellDomPatch::FileTreeState {
|
||||
active_row_id: None,
|
||||
selected_row_ids: BTreeSet::new(),
|
||||
anchor_row_id: None,
|
||||
focused_row_id: None,
|
||||
|
||||
Reference in New Issue
Block a user