fix local office resource editing
- add local-folder OnlyOffice sign/callback writeback and edit-tab handling - align main resource tabs, attachment edit menu, slash isolation, and filetree context behavior - record Sidex/Hermes gap reviews and Reasonix task checklists
This commit is contained in:
@@ -196,6 +196,7 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
kind,
|
||||
status,
|
||||
raw_input,
|
||||
locations,
|
||||
} => Some(SseEvent {
|
||||
event: "tool.started".into(),
|
||||
data: json!({
|
||||
@@ -204,6 +205,7 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
"input": raw_input,
|
||||
"locations": locations,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::ToolCallUpdate {
|
||||
@@ -238,25 +240,30 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
tool_name,
|
||||
params,
|
||||
decision,
|
||||
} => Some(SseEvent {
|
||||
event: if decision == "allowed" {
|
||||
"permission.allowed".into()
|
||||
} else {
|
||||
"permission.denied".into()
|
||||
},
|
||||
data: json!({
|
||||
"permissionId": permission_id,
|
||||
"toolName": tool_name,
|
||||
"params": params,
|
||||
"decision": decision,
|
||||
}),
|
||||
} => {
|
||||
let event = match decision.as_str() {
|
||||
"allowed" => "permission.allowed",
|
||||
"requested" => "permission.requested",
|
||||
_ => "permission.denied",
|
||||
};
|
||||
Some(SseEvent {
|
||||
event: event.into(),
|
||||
data: json!({
|
||||
"permissionId": permission_id,
|
||||
"toolName": tool_name,
|
||||
"params": params,
|
||||
"decision": decision,
|
||||
}),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent {
|
||||
event: "session.info.updated".into(),
|
||||
data: json!({ "title": title }),
|
||||
}),
|
||||
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
|
||||
event: "plan.updated".into(),
|
||||
data: json!({ "entries": entries }),
|
||||
}),
|
||||
AcpSessionEvent::SessionInfoUpdate { .. } => {
|
||||
None // Not forwarded to frontend
|
||||
}
|
||||
AcpSessionEvent::PlanUpdate { .. } => {
|
||||
None // Not forwarded (Phase C)
|
||||
}
|
||||
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
|
||||
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
|
||||
event: "run.failed".into(),
|
||||
@@ -327,6 +334,37 @@ mod tests {
|
||||
assert_eq!(sse.data["decision"], "denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_requested_emits_permission_requested_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_2".into(),
|
||||
tool_name: "mnote.page.get".into(),
|
||||
params: json!({"documentId": "doc_2"}),
|
||||
decision: "requested".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission requested should be forwarded");
|
||||
assert_eq!(sse.event, "permission.requested");
|
||||
assert_eq!(sse.data["permissionId"], "perm_2");
|
||||
assert_eq!(sse.data["toolName"], "mnote.page.get");
|
||||
assert_eq!(sse.data["decision"], "requested");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_allowed_emits_permission_allowed_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_3".into(),
|
||||
tool_name: "mnote.page.save".into(),
|
||||
params: json!({"documentId": "doc_1"}),
|
||||
decision: "allowed".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded");
|
||||
assert_eq!(sse.event, "permission.allowed");
|
||||
assert_eq!(sse.data["permissionId"], "perm_3");
|
||||
assert_eq!(sse.data["decision"], "allowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_events_keep_detail_for_collapsible_ui() {
|
||||
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
|
||||
@@ -335,12 +373,18 @@ mod tests {
|
||||
kind: "read".into(),
|
||||
status: crate::acp_types::ToolCallStatus::InProgress,
|
||||
raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})),
|
||||
locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()],
|
||||
})
|
||||
.expect("tool start");
|
||||
assert_eq!(started.event, "tool.started");
|
||||
assert_eq!(started.data["tool"], "mnote.page.get");
|
||||
assert_eq!(started.data["status"], "in_progress");
|
||||
assert_eq!(started.data["input"]["documentId"], "doc_1");
|
||||
assert_eq!(
|
||||
started.data["locations"][0],
|
||||
"/mnt/Data1T/mnote/src/main.rs"
|
||||
);
|
||||
assert_eq!(started.data["locations"].as_array().unwrap().len(), 1);
|
||||
|
||||
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id: "tool_1".into(),
|
||||
@@ -367,4 +411,34 @@ mod tests {
|
||||
assert_eq!(running.event, "tool.started");
|
||||
assert_eq!(running.data["status"], "in_progress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_session_info_update_emits_session_info_updated_sse() {
|
||||
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
|
||||
title: "我的新会话标题".into(),
|
||||
})
|
||||
.expect("session info update should be forwarded");
|
||||
assert_eq!(sse.event, "session.info.updated");
|
||||
assert_eq!(sse.data["title"], "我的新会话标题");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_plan_update_emits_plan_updated_sse() {
|
||||
let entries = vec![
|
||||
"步骤 1:读取文件".into(),
|
||||
"步骤 2:修改配置".into(),
|
||||
"步骤 3:验证更改".into(),
|
||||
];
|
||||
let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate {
|
||||
entries: entries.clone(),
|
||||
})
|
||||
.expect("plan update should be forwarded");
|
||||
assert_eq!(sse.event, "plan.updated");
|
||||
let sse_entries: Vec<String> =
|
||||
serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default();
|
||||
assert_eq!(sse_entries.len(), 3);
|
||||
assert_eq!(sse_entries[0], "步骤 1:读取文件");
|
||||
assert_eq!(sse_entries[1], "步骤 2:修改配置");
|
||||
assert_eq!(sse_entries[2], "步骤 3:验证更改");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@ type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
|
||||
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
|
||||
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
|
||||
|
||||
// ── Incoming request handler type ────────────────────
|
||||
///
|
||||
/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。
|
||||
/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message
|
||||
/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`]
|
||||
/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。
|
||||
type IncomingRequestHandler = Box<dyn Fn(Value, String, Value) -> bool + Send + 'static>;
|
||||
|
||||
/// incoming request handler 的线程安全容器。
|
||||
type IncomingRequestHandlerMutex = std::sync::Mutex<Option<IncomingRequestHandler>>;
|
||||
|
||||
// ── Pending request entry ────────────────────────────
|
||||
|
||||
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
|
||||
@@ -108,6 +119,9 @@ pub struct AcpClient {
|
||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
next_id: AtomicU64,
|
||||
notification_handler: Arc<NotificationHandlerMutex>,
|
||||
/// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。
|
||||
/// 未设置时会直接回复 method-not-found。
|
||||
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
|
||||
}
|
||||
|
||||
impl AcpClient {
|
||||
@@ -162,14 +176,24 @@ impl AcpClient {
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let notification_handler: Arc<NotificationHandlerMutex> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let incoming_request_handler: Arc<IncomingRequestHandlerMutex> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
// Start background reader task
|
||||
let pending_clone = pending.clone();
|
||||
let handler_clone = notification_handler.clone();
|
||||
let incoming_clone = incoming_request_handler.clone();
|
||||
let writer_clone = writer.clone();
|
||||
let child_pid = child.id().unwrap_or(0);
|
||||
tokio::spawn(async move {
|
||||
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
|
||||
Self::reader_loop(
|
||||
reader,
|
||||
writer_clone,
|
||||
pending_clone,
|
||||
handler_clone,
|
||||
incoming_clone,
|
||||
)
|
||||
.await;
|
||||
info!("ACP reader loop ended (pid={})", child_pid);
|
||||
});
|
||||
|
||||
@@ -179,6 +203,7 @@ impl AcpClient {
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
notification_handler,
|
||||
incoming_request_handler,
|
||||
};
|
||||
|
||||
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
|
||||
@@ -272,6 +297,53 @@ impl AcpClient {
|
||||
*guard = Some(Box::new(handler));
|
||||
}
|
||||
|
||||
/// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。
|
||||
/// handler 接收原始 request id、method 和 params,并应稍后通过
|
||||
/// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。
|
||||
/// 同一时间只保留一个 handler,后续注册会覆盖前一个。
|
||||
pub fn on_incoming_request<F>(&self, handler: F)
|
||||
where
|
||||
F: Fn(Value, String, Value) -> bool + Send + 'static,
|
||||
{
|
||||
let mut guard = self.incoming_request_handler.lock().unwrap();
|
||||
*guard = Some(Box::new(handler));
|
||||
}
|
||||
|
||||
/// 用 result 响应 agent 发来的 incoming JSON-RPC request。
|
||||
///
|
||||
/// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。
|
||||
pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.clone(),
|
||||
"result": result,
|
||||
});
|
||||
debug!("ACP <-- respond to incoming #{}", id);
|
||||
Self::write_jsonrpc_message(&self.writer, &msg).await
|
||||
}
|
||||
|
||||
/// 用 error 响应 incoming JSON-RPC request。
|
||||
pub async fn respond_to_incoming_error(
|
||||
&self,
|
||||
id: Value,
|
||||
code: i64,
|
||||
message: &str,
|
||||
) -> Result<(), AcpError> {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.clone(),
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
});
|
||||
debug!(
|
||||
"ACP <-- respond error to incoming #{}: [{}] {}",
|
||||
id, code, message
|
||||
);
|
||||
Self::write_jsonrpc_message(&self.writer, &msg).await
|
||||
}
|
||||
|
||||
/// Gracefully close the ACP connection and kill the subprocess.
|
||||
pub async fn close(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
@@ -296,6 +368,7 @@ impl AcpClient {
|
||||
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: Arc<NotificationHandlerMutex>,
|
||||
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
|
||||
) {
|
||||
let mut line_buf = String::new();
|
||||
loop {
|
||||
@@ -328,7 +401,14 @@ impl AcpClient {
|
||||
}
|
||||
};
|
||||
|
||||
Self::dispatch_message(msg, &writer, &pending, ¬ification_handler).await;
|
||||
Self::dispatch_message(
|
||||
msg,
|
||||
&writer,
|
||||
&pending,
|
||||
¬ification_handler,
|
||||
&incoming_request_handler,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Process died or EOF — resolve all pending
|
||||
@@ -346,6 +426,7 @@ impl AcpClient {
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: &Arc<NotificationHandlerMutex>,
|
||||
incoming_request_handler: &Arc<IncomingRequestHandlerMutex>,
|
||||
) {
|
||||
let has_id = msg.get("id").is_some();
|
||||
let has_method = msg
|
||||
@@ -355,33 +436,36 @@ impl AcpClient {
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_id && has_method {
|
||||
// Incoming request from agent (e.g. session/request_permission)
|
||||
// agent 发来的 incoming request,例如 session/request_permission。
|
||||
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
|
||||
warn!("ACP incoming request not handled: {method} (params={params:?})");
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("ACP incoming request not supported: {method}")
|
||||
}
|
||||
});
|
||||
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
|
||||
warn!("ACP incoming request response write failed: {error}");
|
||||
}
|
||||
if method == "session/request_permission" {
|
||||
let mut event_params = params;
|
||||
if let Some(object) = event_params.as_object_mut() {
|
||||
object.insert("decision".into(), Value::String("denied".into()));
|
||||
object.insert("method".into(), Value::String(method.clone()));
|
||||
object.insert("jsonrpcId".into(), id);
|
||||
}
|
||||
let handler_guard = notification_handler.lock().unwrap();
|
||||
let id_val = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
|
||||
// 若已注册 handler,则由 handler 决定是否负责稍后响应。
|
||||
let handled = {
|
||||
let handler_guard = incoming_request_handler.lock().unwrap();
|
||||
if let Some(ref handler) = *handler_guard {
|
||||
handler(method, event_params);
|
||||
handler(id_val.clone(), method.clone(), params.clone())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if handled {
|
||||
debug!("ACP incoming request dispatched: {method} #{}", id_val);
|
||||
} else {
|
||||
// 没有 handler 时必须立即响应,避免 agent 一直等待。
|
||||
warn!("ACP incoming request not handled (no handler registered): {method}");
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id_val,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("ACP incoming request not supported: {method}")
|
||||
}
|
||||
});
|
||||
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
|
||||
warn!("ACP incoming request response write failed: {error}");
|
||||
}
|
||||
}
|
||||
} else if has_id {
|
||||
@@ -528,6 +612,48 @@ rl.on('line', (line) => {
|
||||
.expect("spawn permission mock ACP")
|
||||
}
|
||||
|
||||
async fn spawn_string_id_permission_request_mock_server() -> AcpClient {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
let permissionResponse = null;
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id !== undefined && msg.method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
});
|
||||
setTimeout(() => send({
|
||||
jsonrpc: '2.0',
|
||||
id: 'perm-string-id',
|
||||
method: 'session/request_permission',
|
||||
params: { reason: 'test permission' }
|
||||
}), 10);
|
||||
} else if (msg.id === 'perm-string-id' && msg.method === undefined) {
|
||||
permissionResponse = msg;
|
||||
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { permissionResponse }
|
||||
});
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_permission_request_string_id_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write permission string id mock script");
|
||||
|
||||
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn permission string id mock ACP")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_response() {
|
||||
let client = spawn_mock_acp_server().await;
|
||||
@@ -603,4 +729,18 @@ rl.on('line', (line) => {
|
||||
assert_eq!(response["id"], 77);
|
||||
assert!(response.get("result").is_some() || response.get("error").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incoming_permission_request_preserves_string_id() {
|
||||
let client = spawn_string_id_permission_request_mock_server().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let result: Value = client
|
||||
.request("get_permission_response", json!({}))
|
||||
.await
|
||||
.expect("permission response probe");
|
||||
let response = &result["permissionResponse"];
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert_eq!(response["id"], "perm-string-id");
|
||||
assert!(response.get("result").is_some() || response.get("error").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
/// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic)
|
||||
use crate::acp_client::AcpClient;
|
||||
use crate::acp_types::{
|
||||
ContentBlock, ContentBlockWrapper, SessionNewParams, SessionNewResult, SessionPromptParams,
|
||||
SessionPromptResult, SessionUpdate, ToolCallStatus,
|
||||
ContentBlock, ContentBlockWrapper, SessionLoadParams, SessionNewParams, SessionNewResult,
|
||||
SessionPromptParams, SessionPromptResult, SessionUpdate, ToolCallStatus,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// ── Events ───────────────────────────────────────────
|
||||
|
||||
/// Strongly-typed event emitted by the session manager when a `session/update` arrives.
|
||||
@@ -27,34 +27,36 @@ pub enum AcpSessionEvent {
|
||||
TextDelta { text: String },
|
||||
/// Streaming reasoning/thinking text.
|
||||
ThoughtDelta { text: String },
|
||||
/// Tool call started.
|
||||
/// 工具调用开始。
|
||||
ToolCall {
|
||||
tool_call_id: String,
|
||||
title: String,
|
||||
kind: String,
|
||||
status: ToolCallStatus,
|
||||
raw_input: Option<Value>,
|
||||
/// 工具涉及的文件路径。
|
||||
locations: Vec<String>,
|
||||
},
|
||||
/// Tool call status update (with optional result content).
|
||||
/// 工具调用状态更新,可能携带结果内容。
|
||||
ToolCallUpdate {
|
||||
tool_call_id: String,
|
||||
status: ToolCallStatus,
|
||||
content: Option<Vec<ContentBlockWrapper>>,
|
||||
},
|
||||
/// Context usage update.
|
||||
/// 上下文用量更新。
|
||||
UsageUpdate { used: u64, size: u64 },
|
||||
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。
|
||||
/// Agent 发起权限请求。
|
||||
PermissionRequest {
|
||||
permission_id: String,
|
||||
tool_name: String,
|
||||
params: Value,
|
||||
decision: String,
|
||||
},
|
||||
/// Session metadata update (e.g. auto-title).
|
||||
/// 会话元数据更新,例如自动标题。
|
||||
SessionInfoUpdate { title: String },
|
||||
/// Plan entries update.
|
||||
/// 计划条目更新。
|
||||
PlanUpdate { entries: Vec<String> },
|
||||
/// Connection closed/error.
|
||||
/// 连接关闭或异常。
|
||||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
@@ -81,6 +83,19 @@ pub enum SessionState {
|
||||
Closed,
|
||||
}
|
||||
|
||||
// ── Pending permission ───────────────────────────────
|
||||
|
||||
/// 等待前端决策的 `session/request_permission`。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingPermission {
|
||||
/// incoming ACP request 的原始 JSON-RPC id,可能是数字或字符串。
|
||||
pub jsonrpc_id: Value,
|
||||
/// `session/request_permission` 的原始参数。
|
||||
pub params: Value,
|
||||
/// 请求创建时间,用于后续超时判断。
|
||||
pub created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
// ── AcpSessionManager ────────────────────────────────
|
||||
|
||||
/// Manages ACP sessions — create, prompt, cancel, and event dispatch.
|
||||
@@ -97,6 +112,8 @@ pub struct AcpSessionManager {
|
||||
accumulated: Arc<Mutex<String>>,
|
||||
/// Whether we're currently inside a prompt (for dedup gating).
|
||||
in_prompt: Arc<Mutex<bool>>,
|
||||
/// Pending permission requests keyed by permission_id.
|
||||
pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
|
||||
}
|
||||
|
||||
impl AcpSessionManager {
|
||||
@@ -110,42 +127,125 @@ impl AcpSessionManager {
|
||||
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> = Arc::new(Mutex::new(None));
|
||||
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
|
||||
let in_prompt: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
|
||||
let pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Wire up the ACP notification handler
|
||||
// ── Incoming request handler ──────────────────
|
||||
// 处理 `session/request_permission`:先进入 pending,再由 HTTP
|
||||
// resolve-permission 端点决定最终响应。
|
||||
let event_handler_for_incoming = event_handler.clone();
|
||||
let pending_for_incoming = pending_permissions.clone();
|
||||
let client_for_incoming = client.clone();
|
||||
client.on_incoming_request(move |id, method, params| {
|
||||
if method != "session/request_permission" {
|
||||
// 未知方法交回 dispatch_message 回复 method-not-found。
|
||||
return false;
|
||||
}
|
||||
|
||||
// 优先使用 agent 传来的 permission id;缺失时用 JSON-RPC id 派生稳定 id。
|
||||
let permission_id = params
|
||||
.get("permissionId")
|
||||
.or_else(|| params.get("permission_id"))
|
||||
.or_else(|| params.get("requestId"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("acp_perm_{}", id));
|
||||
|
||||
let tool_name = params
|
||||
.get("toolName")
|
||||
.or_else(|| params.get("tool"))
|
||||
.or_else(|| params.get("name"))
|
||||
.or_else(|| params.get("method"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
|
||||
// 记录到 pending map,等待前端 allow / deny。
|
||||
{
|
||||
let mut pending = pending_for_incoming.lock().unwrap();
|
||||
pending.insert(
|
||||
permission_id.clone(),
|
||||
PendingPermission {
|
||||
jsonrpc_id: id.clone(),
|
||||
params: params.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
debug!(
|
||||
"ACP permission pending: {} (jsonrpc_id={}, tool={})",
|
||||
permission_id, id, tool_name
|
||||
);
|
||||
}
|
||||
|
||||
// 向前端发出 `permission.requested`。
|
||||
{
|
||||
let handler = event_handler_for_incoming.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id: permission_id.clone(),
|
||||
tool_name,
|
||||
params,
|
||||
decision: "requested".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 60 秒没有决策时自动 deny,避免 agent 永久等待。
|
||||
let pending_for_timeout = pending_for_incoming.clone();
|
||||
let event_handler_for_timeout = event_handler_for_incoming.clone();
|
||||
let client_for_timeout = client_for_incoming.clone();
|
||||
let timeout_permission_id = permission_id.clone();
|
||||
let timeout_jsonrpc_id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
let should_deny = {
|
||||
let mut pending = pending_for_timeout.lock().unwrap();
|
||||
if pending.remove(&timeout_permission_id).is_some() {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_deny {
|
||||
warn!(
|
||||
"ACP permission timeout: {} (jsonrpc_id={}), auto-denying",
|
||||
timeout_permission_id, timeout_jsonrpc_id
|
||||
);
|
||||
// 向 ACP 回复 permission denied。
|
||||
let _ = client_for_timeout
|
||||
.respond_to_incoming_error(
|
||||
timeout_jsonrpc_id.clone(),
|
||||
-32000,
|
||||
"permission denied by timeout",
|
||||
)
|
||||
.await;
|
||||
// 同步 denied 事件给前端。
|
||||
let handler = event_handler_for_timeout.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id: timeout_permission_id,
|
||||
tool_name: "session/request_permission".into(),
|
||||
params: Value::Null,
|
||||
decision: "denied".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
true // handler took responsibility for responding
|
||||
});
|
||||
|
||||
// ── Notification handler ──────────────────────
|
||||
// Only handles `session/update` (streaming events from agent).
|
||||
// Incoming request methods like `session/request_permission` are handled
|
||||
// above via `on_incoming_request`.
|
||||
let session_id_clone = session_id.clone();
|
||||
let event_handler_clone = event_handler.clone();
|
||||
let accumulated_clone = accumulated.clone();
|
||||
let in_prompt_clone = in_prompt.clone();
|
||||
|
||||
client.on_notification(move |method, params| {
|
||||
if method == "session/request_permission" {
|
||||
let permission_id = params
|
||||
.get("permissionId")
|
||||
.or_else(|| params.get("permission_id"))
|
||||
.or_else(|| params.get("requestId"))
|
||||
.or_else(|| params.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("permission_auto_denied")
|
||||
.to_string();
|
||||
let tool_name = params
|
||||
.get("toolName")
|
||||
.or_else(|| params.get("tool"))
|
||||
.or_else(|| params.get("name"))
|
||||
.or_else(|| params.get("method"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
let handler = event_handler_clone.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision: "denied".into(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method != "session/update" {
|
||||
return;
|
||||
}
|
||||
@@ -187,6 +287,7 @@ impl AcpSessionManager {
|
||||
event_handler,
|
||||
accumulated,
|
||||
in_prompt,
|
||||
pending_permissions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +301,92 @@ impl AcpSessionManager {
|
||||
*guard = Some(Arc::new(handler));
|
||||
}
|
||||
|
||||
/// 按用户决策解析 pending permission。
|
||||
///
|
||||
/// 根据 `permission_id` 找到 pending request,向 ACP 子进程回写 result/error,
|
||||
/// 发出 `permission.allowed` / `permission.denied` 事件,并从 pending map 移除。
|
||||
///
|
||||
/// permission 存在且完成响应时返回 `Ok(())`,否则返回错误原因。
|
||||
pub async fn resolve_permission(
|
||||
&self,
|
||||
permission_id: &str,
|
||||
decision: &str,
|
||||
) -> Result<(), String> {
|
||||
let pending = {
|
||||
let mut map = self.pending_permissions.lock().unwrap();
|
||||
map.remove(permission_id)
|
||||
};
|
||||
let pending = pending.ok_or_else(|| {
|
||||
format!("pending permission not found: {permission_id} (already timed out or invalid)")
|
||||
})?;
|
||||
|
||||
let tool_name = pending
|
||||
.params
|
||||
.get("toolName")
|
||||
.or_else(|| pending.params.get("tool"))
|
||||
.or_else(|| pending.params.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
|
||||
let normalized_decision = match decision {
|
||||
"allow" | "allowed" => "allow",
|
||||
"deny" | "denied" => "deny",
|
||||
other => return Err(format!("unknown decision: {other} (expected allow/deny)")),
|
||||
};
|
||||
|
||||
let response = permission_response_for_decision(&pending.params, normalized_decision)
|
||||
.unwrap_or_else(|| {
|
||||
if normalized_decision == "allow" {
|
||||
Err((-32000, "permission allow option not available".into()))
|
||||
} else {
|
||||
Err((-32000, "permission denied by user".into()))
|
||||
}
|
||||
});
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
self.client
|
||||
.respond_to_incoming(pending.jsonrpc_id.clone(), result)
|
||||
.await
|
||||
.map_err(|e| format!("ACP respond failed: {e}"))?;
|
||||
info!(
|
||||
"ACP permission resolved: {} decision={} (jsonrpc_id={})",
|
||||
permission_id, normalized_decision, pending.jsonrpc_id
|
||||
);
|
||||
}
|
||||
Err((code, message)) => {
|
||||
self.client
|
||||
.respond_to_incoming_error(pending.jsonrpc_id.clone(), code, &message)
|
||||
.await
|
||||
.map_err(|e| format!("ACP respond failed: {e}"))?;
|
||||
info!(
|
||||
"ACP permission rejected: {} decision={} (jsonrpc_id={})",
|
||||
permission_id, normalized_decision, pending.jsonrpc_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 发出最终 decision 事件。
|
||||
{
|
||||
let handler = self.event_handler.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id: permission_id.to_string(),
|
||||
tool_name,
|
||||
params: pending.params,
|
||||
decision: if normalized_decision == "allow" {
|
||||
"allowed".into()
|
||||
} else {
|
||||
"denied".into()
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new ACP session.
|
||||
///
|
||||
/// Sends `session/new` to the agent and stores the returned `sessionId`.
|
||||
@@ -228,6 +415,108 @@ impl AcpSessionManager {
|
||||
Ok(result.session_id)
|
||||
}
|
||||
|
||||
/// Load/resume an existing ACP session.
|
||||
///
|
||||
/// Sends `session/load { sessionId, cwd, mcpServers: [] }` to the agent.
|
||||
/// Returns `true` if the session was loaded and the internal session_id updated,
|
||||
/// `false` if the adapter returned null (session not found) or doesn't support the method.
|
||||
///
|
||||
/// On success the internal `session_id` is set to the loaded id.
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()`
|
||||
pub async fn load_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
cwd: Option<&str>,
|
||||
) -> Result<bool, crate::acp_client::AcpError> {
|
||||
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let params = SessionLoadParams {
|
||||
session_id: session_id.to_string(),
|
||||
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
|
||||
mcp_servers: Some(Vec::new()),
|
||||
};
|
||||
|
||||
match self
|
||||
.client
|
||||
.request::<_, serde_json::Value>("session/load", params)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
// Successful load: adapter returned { sessionId: "..." }
|
||||
if let Some(sid) = result.get("sessionId").and_then(|v| v.as_str()) {
|
||||
if !sid.is_empty() {
|
||||
let mut sid_guard = self.session_id.lock().unwrap();
|
||||
*sid_guard = Some(sid.to_string());
|
||||
info!("ACP session loaded: {}", sid);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
// Null or missing sessionId → session not found on adapter
|
||||
info!(
|
||||
"ACP session/load returned null for session_id={}",
|
||||
session_id
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(crate::acp_client::AcpError::JsonRpc { code, message }) => {
|
||||
// Adapter doesn't support session/load or session expired
|
||||
info!(
|
||||
"ACP session/load not supported (code={code}, message={message}), will fallback"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("ACP session/load transport error: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure an active ACP session exists.
|
||||
///
|
||||
/// 1. If there's already an active session, return it immediately.
|
||||
/// 2. If a `stored_session_id` is provided, attempt `session/load` first.
|
||||
/// 3. Fall back to `session/new`.
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()`
|
||||
pub async fn ensure_session(
|
||||
&self,
|
||||
cwd: Option<&str>,
|
||||
stored_session_id: Option<&str>,
|
||||
) -> Result<String, crate::acp_client::AcpError> {
|
||||
// 1. Reuse active session if one exists
|
||||
{
|
||||
let sid = self.session_id.lock().unwrap();
|
||||
if let Some(ref sid) = *sid {
|
||||
debug!("ACP ensure_session: reusing active session {}", sid);
|
||||
return Ok(sid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try to load a stored session
|
||||
if let Some(stored) = stored_session_id {
|
||||
if !stored.is_empty() {
|
||||
debug!("ACP ensure_session: attempting session/load for {}", stored);
|
||||
if self.load_session(stored, cwd).await.unwrap_or(false) {
|
||||
return Ok(stored.to_string());
|
||||
}
|
||||
info!(
|
||||
"ACP ensure_session: stored session {} not found, creating new",
|
||||
stored
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: create new session
|
||||
debug!("ACP ensure_session: creating new session");
|
||||
self.create_session(cwd, None).await
|
||||
}
|
||||
|
||||
/// Send a prompt to the agent and stream events.
|
||||
///
|
||||
/// The `prompt` is a list of content blocks (text + optional page context).
|
||||
@@ -424,6 +713,7 @@ impl AcpSessionManager {
|
||||
kind,
|
||||
status,
|
||||
raw_input,
|
||||
locations,
|
||||
..
|
||||
} => {
|
||||
let title = title.clone().unwrap_or_else(|| "tool".into());
|
||||
@@ -432,12 +722,14 @@ impl AcpSessionManager {
|
||||
None => "other".into(),
|
||||
};
|
||||
let status = status.clone().unwrap_or(ToolCallStatus::Pending);
|
||||
let locations: Vec<String> = locations.iter().map(|l| l.path.clone()).collect();
|
||||
Some(AcpSessionEvent::ToolCall {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
title,
|
||||
kind: kind_str,
|
||||
status,
|
||||
raw_input: raw_input.clone(),
|
||||
locations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,6 +771,76 @@ impl AcpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_response_for_decision(
|
||||
params: &Value,
|
||||
decision: &str,
|
||||
) -> Option<Result<Value, (i64, String)>> {
|
||||
let option_id = permission_option_id_by_decision(params, decision);
|
||||
match (decision, option_id) {
|
||||
("allow", Some(option_id)) | ("deny", Some(option_id)) => Some(Ok(json!({
|
||||
"outcome": "selected",
|
||||
"optionId": option_id
|
||||
}))),
|
||||
("deny", None) => Some(Err((-32000, "permission denied by user".into()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_option_id_by_decision(params: &Value, decision: &str) -> Option<String> {
|
||||
let options = params.get("options").and_then(Value::as_array)?;
|
||||
let preferred: &[&str] = if decision == "allow" {
|
||||
&["allow_once", "allow", "approve", "yes"]
|
||||
} else {
|
||||
&["deny_once", "reject_once", "deny", "reject", "no"]
|
||||
};
|
||||
|
||||
for keyword in preferred.iter().copied() {
|
||||
if let Some(option_id) = options.iter().find_map(|option| {
|
||||
let id = permission_option_id(option)?;
|
||||
let haystack = format!(
|
||||
"{} {}",
|
||||
id.to_ascii_lowercase(),
|
||||
option
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
);
|
||||
if haystack.contains(keyword) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
return Some(option_id);
|
||||
}
|
||||
}
|
||||
|
||||
if decision == "allow" {
|
||||
return options.iter().find_map(|option| {
|
||||
let id = permission_option_id(option)?;
|
||||
let lower = id.to_ascii_lowercase();
|
||||
if lower.contains("deny") || lower.contains("reject") || lower == "no" {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn permission_option_id(option: &Value) -> Option<String> {
|
||||
option
|
||||
.get("optionId")
|
||||
.or_else(|| option.get("option_id"))
|
||||
.or_else(|| option.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -605,6 +967,229 @@ rl.on('line', (line) => {
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a mock ACP that supports session/load (success case).
|
||||
async fn spawn_mock_acp_with_load() -> Arc<AcpClient> {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (!msg.id) return;
|
||||
if (msg.method === 'session/load') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { sessionId: msg.params?.sessionId || 'loaded_session_1' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/new') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { sessionId: 'test_session_1' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || 'test';
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello from mock ACP' }
|
||||
}
|
||||
}
|
||||
}) + '\n');
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { stopReason: 'end_turn' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/cancel') {
|
||||
// No response for notification
|
||||
} else if (msg.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
}) + '\n');
|
||||
}
|
||||
});
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_session_load_success_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write mock");
|
||||
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn");
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
/// Creates a mock ACP that returns null for session/load (session expired/not found)
|
||||
async fn spawn_mock_acp_with_load_null() -> Arc<AcpClient> {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (!msg.id) return;
|
||||
if (msg.method === 'session/load') {
|
||||
// Return null result → session not found on adapter
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: null
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/new') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { sessionId: 'fallback_session_1' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || 'fallback_session_1';
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello from fallback session' }
|
||||
}
|
||||
}
|
||||
}) + '\n');
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { stopReason: 'end_turn' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/cancel') {
|
||||
// No response for notification
|
||||
} else if (msg.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
}) + '\n');
|
||||
}
|
||||
});
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_session_load_null_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write mock");
|
||||
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn");
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_session_success() {
|
||||
let client = spawn_mock_acp_with_load().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
let loaded = mgr
|
||||
.load_session("stored_session_1", Some("/test"))
|
||||
.await
|
||||
.expect("load_session");
|
||||
assert!(loaded, "session should be loaded successfully");
|
||||
assert_eq!(mgr.session_id().await, Some("stored_session_1".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_session_null_fallback() {
|
||||
let client = spawn_mock_acp_with_load_null().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
|
||||
// load_session returns false for null result
|
||||
let loaded = mgr
|
||||
.load_session("expired_session", Some("/test"))
|
||||
.await
|
||||
.expect("load_session");
|
||||
assert!(!loaded, "session should not be loaded");
|
||||
|
||||
// No session_id set after failed load
|
||||
assert_eq!(mgr.session_id().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_session_reuses_active() {
|
||||
let client = spawn_mock_acp().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
|
||||
// First, create a session
|
||||
let created = mgr
|
||||
.create_session(Some("/test"), None)
|
||||
.await
|
||||
.expect("create_session");
|
||||
assert_eq!(created, "test_session_1");
|
||||
|
||||
// ensure_session should reuse the active session, not create a new one
|
||||
let ensured = mgr
|
||||
.ensure_session(Some("/test"), Some("stored_but_ignored"))
|
||||
.await
|
||||
.expect("ensure_session");
|
||||
assert_eq!(ensured, "test_session_1");
|
||||
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_session_loads_stored() {
|
||||
let client = spawn_mock_acp_with_load().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
|
||||
// No active session, stored id provided → should load
|
||||
let ensured = mgr
|
||||
.ensure_session(Some("/test"), Some("stored_session_1"))
|
||||
.await
|
||||
.expect("ensure_session");
|
||||
assert_eq!(ensured, "stored_session_1");
|
||||
assert_eq!(mgr.session_id().await, Some("stored_session_1".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_session_fallback_to_new() {
|
||||
let client = spawn_mock_acp_with_load_null().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
|
||||
// No active session, stored id provided but load returns null → fallback to new
|
||||
let ensured = mgr
|
||||
.ensure_session(Some("/test"), Some("expired_session"))
|
||||
.await
|
||||
.expect("ensure_session");
|
||||
assert_eq!(ensured, "fallback_session_1");
|
||||
assert_eq!(mgr.session_id().await, Some("fallback_session_1".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_session_no_stored_creates_new() {
|
||||
let client = spawn_mock_acp().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
|
||||
// No active session, no stored id → should create new
|
||||
let ensured = mgr
|
||||
.ensure_session(Some("/test"), None)
|
||||
.await
|
||||
.expect("ensure_session");
|
||||
assert_eq!(ensured, "test_session_1");
|
||||
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_session_fallback_then_prompt_works() {
|
||||
let client = spawn_mock_acp_with_load_null().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
|
||||
// ensure_session with expired stored id → should create fallback
|
||||
mgr.ensure_session(Some("/test"), Some("expired_session"))
|
||||
.await
|
||||
.expect("ensure_session");
|
||||
|
||||
// Verify prompt still works on the fallback session
|
||||
let prompt = vec![ContentBlock::Text {
|
||||
text: "Hello after fallback".into(),
|
||||
}];
|
||||
let result = mgr
|
||||
.run_prompt(prompt)
|
||||
.await
|
||||
.expect("run_prompt after fallback");
|
||||
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thought_chunk_maps_to_thought_delta() {
|
||||
let accumulated = Arc::new(Mutex::new(String::new()));
|
||||
@@ -624,4 +1209,26 @@ rl.on('line', (line) => {
|
||||
other => panic!("expected ThoughtDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_response_selects_allow_and_deny_options() {
|
||||
let params = json!({
|
||||
"options": [
|
||||
{"optionId": "allow_once", "name": "Allow Once", "kind": "allow_once"},
|
||||
{"optionId": "reject_once", "name": "Reject Once", "kind": "reject_once"}
|
||||
]
|
||||
});
|
||||
|
||||
let allow = permission_response_for_decision(¶ms, "allow")
|
||||
.expect("allow response")
|
||||
.expect("allow should select an option");
|
||||
assert_eq!(allow["outcome"], "selected");
|
||||
assert_eq!(allow["optionId"], "allow_once");
|
||||
|
||||
let deny = permission_response_for_decision(¶ms, "deny")
|
||||
.expect("deny response")
|
||||
.expect("deny should select an option");
|
||||
assert_eq!(deny["outcome"], "selected");
|
||||
assert_eq!(deny["optionId"], "reject_once");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,24 @@ pub struct SessionNewResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ── Session load ─────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionLoadParams {
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_servers: Option<Vec<McpServerSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionLoadResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ── Content blocks ───────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -236,6 +254,8 @@ pub enum SessionUpdate {
|
||||
status: Option<ToolCallStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_input: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
locations: Vec<ToolLocation>,
|
||||
},
|
||||
ToolCallUpdate {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
@@ -327,6 +347,8 @@ impl<'de> Deserialize<'de> for SessionUpdate {
|
||||
kind: Option<ToolCallKind>,
|
||||
status: Option<ToolCallStatus>,
|
||||
raw_input: Option<Value>,
|
||||
#[serde(default)]
|
||||
locations: Vec<ToolLocation>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
@@ -337,6 +359,7 @@ impl<'de> Deserialize<'de> for SessionUpdate {
|
||||
kind: raw.kind,
|
||||
status: raw.status,
|
||||
raw_input: raw.raw_input,
|
||||
locations: raw.locations,
|
||||
})
|
||||
}
|
||||
SessionUpdate::TOOL_CALL_UPDATE => {
|
||||
@@ -456,6 +479,13 @@ pub enum ToolCallStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A file path location referenced by a tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolLocation {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlanEntry {
|
||||
@@ -666,9 +696,47 @@ mod tests {
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
match &parsed.update {
|
||||
SessionUpdate::ToolCall { title, kind, .. } => {
|
||||
SessionUpdate::ToolCall {
|
||||
title,
|
||||
kind,
|
||||
locations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
|
||||
assert!(matches!(kind, Some(ToolCallKind::Read)));
|
||||
assert!(locations.is_empty(), "no locations in this fixture");
|
||||
}
|
||||
_ => panic!("expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_tool_call_with_locations() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "tc_2",
|
||||
"title": "mnote.doc.read",
|
||||
"kind": "read",
|
||||
"status": "in_progress",
|
||||
"locations": [
|
||||
{ "path": "/mnt/Data1T/mnote/src/main.rs" },
|
||||
{ "path": "/mnt/Data1T/mnote/src/lib.rs" }
|
||||
]
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
match &parsed.update {
|
||||
SessionUpdate::ToolCall {
|
||||
tool_call_id,
|
||||
locations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tool_call_id, "tc_2");
|
||||
assert_eq!(locations.len(), 2);
|
||||
assert_eq!(locations[0].path, "/mnt/Data1T/mnote/src/main.rs");
|
||||
assert_eq!(locations[1].path, "/mnt/Data1T/mnote/src/lib.rs");
|
||||
}
|
||||
_ => panic!("expected ToolCall"),
|
||||
}
|
||||
|
||||
@@ -2367,10 +2367,10 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("初始化的新页面"));
|
||||
assert!(!html.contains("初始化的新页面"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -2583,17 +2583,17 @@ mod tests {
|
||||
.join("user_real")
|
||||
.join("workspaces")
|
||||
.join("my-space");
|
||||
assert!(expected_root
|
||||
.join("初始化的新页面")
|
||||
.join("初始化的新页面.md")
|
||||
.exists());
|
||||
assert!(html.contains("初始化的新页面"));
|
||||
assert!(
|
||||
!expected_root.join("初始化的新页面").exists(),
|
||||
"默认工作区不应创建'初始化的新页面'目录"
|
||||
);
|
||||
assert!(!html.contains("初始化的新页面"));
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
||||
assert!(!html.contains("当前还没有可显示的本地工作区"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
||||
assert!(html.contains("当前还没有可显示的本地工作区"));
|
||||
assert!(html.contains(r#"data-testid="mnote-empty-create-page""#));
|
||||
assert!(html.contains(r#""transport":"disabled""#));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
|
||||
@@ -1461,13 +1461,22 @@ async fn acp_stream_events(
|
||||
}
|
||||
});
|
||||
|
||||
let acp_session_id = mgr.create_session(None, None).await.map_err(|e| {
|
||||
WebError::bad_gateway_code(
|
||||
"acp_session_create_failed",
|
||||
format!("ACP session creation failed: {e}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
// Try to resume stored ACP session, or create a new one.
|
||||
// Reference: hermes-vscode-main sessionManager.ts ensureSession()
|
||||
let stored_acp_session_id = payload
|
||||
.get("acpSessionId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
let acp_session_id = mgr
|
||||
.ensure_session(None, stored_acp_session_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WebError::bad_gateway_code(
|
||||
"acp_session_ensure_failed",
|
||||
format!("ACP session ensure failed: {e}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string());
|
||||
ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert(
|
||||
run_id.to_string(),
|
||||
@@ -1810,6 +1819,82 @@ pub async fn abort_run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a pending permission request from an ACP agent.
|
||||
///
|
||||
/// POST /api/hermes/client/runs/{run_id}/resolve-permission
|
||||
/// Body: { "permissionId": "...", "decision": "allow"|"deny" }
|
||||
///
|
||||
/// Looks up the active run, forwards the decision to the ACP subprocess,
|
||||
/// and emits `permission.allowed` / `permission.denied` SSE event.
|
||||
pub async fn resolve_permission(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(run_id): Path<String>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let permission_id = payload
|
||||
.get("permissionId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 permissionId")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let decision = payload
|
||||
.get("decision")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 decision (allow/deny)")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
|
||||
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
||||
if acp_runtime_for_run(&run_id, &profile).is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"hermes_client_not_acp_run",
|
||||
format!("run_id={run_id} 不是 ACP run"),
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
|
||||
let active = ACP_ACTIVE_RUNS
|
||||
.lock()
|
||||
.expect("acp active runs")
|
||||
.get(&run_id)
|
||||
.cloned();
|
||||
let Some(active) = active else {
|
||||
return Err(WebError::bad_request_code(
|
||||
"hermes_client_no_active_run",
|
||||
format!("run_id={run_id} 没有活跃 ACP run"),
|
||||
)
|
||||
.with_context(&context));
|
||||
};
|
||||
|
||||
active
|
||||
.manager
|
||||
.resolve_permission(permission_id, decision)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"hermes_client_permission_resolve_failed",
|
||||
format!("resolve permission 失败: {e}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"runId": run_id,
|
||||
"permissionId": permission_id,
|
||||
"decision": decision,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_models(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
|
||||
@@ -1554,7 +1554,6 @@ fn create_default_local_workspace_for_actor_at_base(
|
||||
|
||||
let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?;
|
||||
ensure_default_workspace_directories(&canonical_root)?;
|
||||
ensure_default_workspace_home_page(&canonical_root)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"workspace": {
|
||||
@@ -1782,36 +1781,6 @@ fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_default_workspace_home_page(root: &Path) -> Result<(), WebError> {
|
||||
let page_dir = root.join("初始化的新页面");
|
||||
let page = page_dir.join("初始化的新页面.md");
|
||||
if page.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !page.starts_with(root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_workspace_root_escape",
|
||||
"默认本地工作区首页不能越过 root",
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(&page_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_workspace_create_failed",
|
||||
format!(
|
||||
"无法创建默认本地工作区首页目录 {}: {error}",
|
||||
page_dir.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let content = "";
|
||||
fs::write(&page, content).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_workspace_create_failed",
|
||||
format!("无法创建默认本地工作区首页 {}: {error}", page.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn local_workspace_manifest_path(root: &Path) -> PathBuf {
|
||||
root.join(".mnote").join("workspace.json")
|
||||
}
|
||||
@@ -3781,6 +3750,12 @@ fn move_local_directory(
|
||||
|
||||
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
if let Some(directory) = resolve_local_directory_id(root, entry_id)? {
|
||||
// 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash
|
||||
if let Some(main_md) = nested_bundle_main_markdown(&directory) {
|
||||
let relative = normalize_relative_path(root, &main_md)?;
|
||||
let page_id = local_markdown_path_page_id(&relative);
|
||||
return trash_local_markdown_page(root, &page_id);
|
||||
}
|
||||
return trash_local_directory(root, entry_id, &directory);
|
||||
}
|
||||
if let Some(file) = resolve_local_raw_file_id(root, entry_id)? {
|
||||
@@ -6961,13 +6936,13 @@ mod tests {
|
||||
execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy,
|
||||
get_share_grants, initialize_local_page_id, initialize_local_workspace_for_actor,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id,
|
||||
open_local_file, record_shared_cache, record_sync_pending_change,
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report,
|
||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
||||
LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
|
||||
local_folder_watch_revision, local_markdown_path_page_id,
|
||||
local_resource_write_editor_blocks, local_workspace_id, open_local_file,
|
||||
record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate,
|
||||
save_local_markdown_page, update_local_markdown_title, validate_local_access_root,
|
||||
write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data,
|
||||
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||
LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
};
|
||||
use crate::context::RequestContext;
|
||||
@@ -8216,10 +8191,10 @@ fn main() {}
|
||||
|
||||
assert_eq!(std::path::Path::new(root_path), expected_root.as_path());
|
||||
assert!(expected_root.join(".mnote").join("workspace.json").exists());
|
||||
assert!(expected_root
|
||||
.join("初始化的新页面")
|
||||
.join("初始化的新页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
!expected_root.join("初始化的新页面").exists(),
|
||||
"默认工作区不应创建'初始化的新页面'目录"
|
||||
);
|
||||
assert!(!expected_root.join("pages").exists());
|
||||
assert!(!expected_root.join("assets").exists());
|
||||
assert!(!expected_root.join("mindmaps").exists());
|
||||
@@ -8516,6 +8491,39 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_delete_page_bundle_directory_uses_page_trash() {
|
||||
let root = temp_root("mnote-local-delete-page-bundle-dir");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page bundle");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write markdown");
|
||||
std::fs::write(root.join("Page").join("asset.txt"), "asset").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let folder_id = format!("local-dir:{}", encode_local_id_segment("Page"));
|
||||
let page_id = local_markdown_path_page_id("Page/Page.md");
|
||||
|
||||
let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
|
||||
.expect("delete page bundle");
|
||||
|
||||
assert!(
|
||||
!root.join("Page").exists(),
|
||||
"页面 bundle 目录应整体移入垃圾箱"
|
||||
);
|
||||
assert_eq!(result["id"].as_str(), Some(page_id.as_str()));
|
||||
assert_eq!(result["documentId"].as_str(), Some(page_id.as_str()));
|
||||
assert_eq!(result["resourceKind"].as_str(), Some("markdown_bundle"));
|
||||
assert_ne!(
|
||||
result["resourceKind"].as_str(),
|
||||
Some("local_directory"),
|
||||
"页面 bundle 目录不能作为普通资源目录进入垃圾箱"
|
||||
);
|
||||
let trash_path = result["trashPath"].as_str().expect("trashPath");
|
||||
assert!(root.join(trash_path).join("Page.md").is_file());
|
||||
assert!(root.join(trash_path).join("asset.txt").is_file());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_delete_local_file_id_uses_trash_index() {
|
||||
let root = temp_root("mnote-local-delete-local-file-id");
|
||||
|
||||
@@ -311,6 +311,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/client/runs/{run_id}/abort",
|
||||
post(hermes_client::abort_run),
|
||||
)
|
||||
.route(
|
||||
"/client/runs/{run_id}/resolve-permission",
|
||||
post(hermes_client::resolve_permission),
|
||||
)
|
||||
.route("/client/models", get(hermes_client::list_models))
|
||||
.route("/client/tools", get(hermes_client::list_tools)),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
|
||||
use adapter_onlyoffice::{
|
||||
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
|
||||
OnlyOfficeProxyPreparationInput,
|
||||
};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::upgrade::Upgraded;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
||||
use tokio_tungstenite::tungstenite::protocol::Role;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
|
||||
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
|
||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
|
||||
@@ -136,6 +147,9 @@ pub struct OnlyOfficeCallbackQuery {
|
||||
asset_id: Option<String>,
|
||||
#[serde(rename = "userId")]
|
||||
user_id: Option<String>,
|
||||
#[serde(rename = "rootUri")]
|
||||
root_uri: Option<String>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -429,6 +443,13 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
}}
|
||||
return String(Math.abs(hash));
|
||||
}}
|
||||
function safeOnlyOfficeDocKey(input) {{
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return "mnote_" + hashOnlyOfficeKey(initial.fileName || "document");
|
||||
const safe = raw.replace(/[^A-Za-z0-9_.=-]/g, "_").replace(/_+/g, "_");
|
||||
if (safe && safe === raw && safe.length <= 96) return safe;
|
||||
return "mnote_" + hashOnlyOfficeKey(raw) + "_" + hashOnlyOfficeKey(initial.fileName || "");
|
||||
}}
|
||||
function docTypeFromExt(ext) {{
|
||||
const value = String(ext || "").toLowerCase();
|
||||
if (["ppt", "pptx", "odp"].includes(value)) return "slide";
|
||||
@@ -465,19 +486,44 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
return value;
|
||||
}}
|
||||
}}
|
||||
function buildCallbackUrl(assetId, userId) {{
|
||||
function localFolderOpenParams(raw) {{
|
||||
try {{
|
||||
const url = new URL(String(raw || ""), location.origin);
|
||||
if (url.pathname !== "/api/local-folder/files/open") return null;
|
||||
const rootUri = String(url.searchParams.get("rootUri") || "").trim();
|
||||
const path = String(url.searchParams.get("path") || "").trim();
|
||||
if (!rootUri || !path) return null;
|
||||
return {{ rootUri, path }};
|
||||
}} catch {{
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
function buildCallbackUrl(assetId, userId, localFile) {{
|
||||
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
|
||||
if (assetId) callback.searchParams.set("assetId", assetId);
|
||||
if (userId) callback.searchParams.set("userId", userId);
|
||||
if (localFile && localFile.rootUri && localFile.path) {{
|
||||
callback.searchParams.set("rootUri", localFile.rootUri);
|
||||
callback.searchParams.set("path", localFile.path);
|
||||
}}
|
||||
return callback.toString();
|
||||
}}
|
||||
function loadScript(url) {{
|
||||
function loadScript(url, timeoutMs) {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
const script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error("无法加载 ONLYOFFICE API: " + url));
|
||||
const cleanup = () => {{ script.onload = null; script.onerror = null; }};
|
||||
script.onload = () => {{ cleanup(); resolve(); }};
|
||||
script.onerror = () => {{ cleanup(); reject(new Error("无法加载 ONLYOFFICE API: " + url)); }};
|
||||
document.head.appendChild(script);
|
||||
if (timeoutMs > 0) {{
|
||||
const timer = window.setTimeout(() => {{
|
||||
cleanup();
|
||||
reject(new Error("ONLYOFFICE API 加载超时 (" + (timeoutMs / 1000) + "秒)"));
|
||||
}}, timeoutMs);
|
||||
resolve = ((orig) => (value) => {{ window.clearTimeout(timer); return orig(value); }})(resolve);
|
||||
reject = ((orig) => (reason) => {{ window.clearTimeout(timer); return orig(reason); }})(reject);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
const MNOTE_ONLYOFFICE_FRAME_REV = "mnote-proxy-identity-20260511";
|
||||
@@ -539,10 +585,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
return "";
|
||||
}}
|
||||
}}
|
||||
function isLocalFolderAsset() {{
|
||||
if (initial.assetId && (initial.assetId.indexOf("local:") === 0 || initial.assetId.indexOf("local-file:") === 0)) return true;
|
||||
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
|
||||
return false;
|
||||
}}
|
||||
async function resolveAssetUrlAndKey() {{
|
||||
let effectiveFileUrl = initial.fileUrl;
|
||||
let storageId = "";
|
||||
if (initial.assetId) {{
|
||||
if (initial.assetId && !isLocalFolderAsset()) {{
|
||||
try {{
|
||||
const response = await fetch("/api/media/sign?assetId=" + encodeURIComponent(initial.assetId));
|
||||
const payload = await response.json().catch(() => null);
|
||||
@@ -554,91 +605,115 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
// ignore
|
||||
}}
|
||||
}}
|
||||
const docKey = initial.assetId
|
||||
const rawDocKey = initial.assetId
|
||||
? (storageId ? initial.assetId + "_" + hashOnlyOfficeKey(storageId) : initial.assetId)
|
||||
: hashOnlyOfficeKey(String(effectiveFileUrl || "") + "-" + initial.fileName);
|
||||
const docKey = safeOnlyOfficeDocKey(rawDocKey);
|
||||
return {{ effectiveFileUrl, storageId, docKey, resolvedFileUrl: resolveDocumentUrl(effectiveFileUrl) }};
|
||||
}}
|
||||
async function boot() {{
|
||||
const userId = await resolveWhoami();
|
||||
const fileState = await resolveAssetUrlAndKey();
|
||||
if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
|
||||
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js");
|
||||
await waitForDocEditorReady(120000);
|
||||
const bootTimeoutMs = 60000;
|
||||
const bootDeadline = Date.now() + bootTimeoutMs;
|
||||
const bootHeartbeat = window.setInterval(() => {{
|
||||
if (Date.now() > bootDeadline) {{
|
||||
window.clearInterval(bootHeartbeat);
|
||||
showError("ONLYOFFICE 页面初始化超时(" + (bootTimeoutMs / 1000) + "秒),请检查 DocumentServer 是否正常运行。");
|
||||
}}
|
||||
}}, 5000);
|
||||
try {{
|
||||
const userId = await resolveWhoami();
|
||||
const fileState = await resolveAssetUrlAndKey();
|
||||
if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
|
||||
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js", 15000);
|
||||
await waitForDocEditorReady(60000);
|
||||
|
||||
const resolvedMode = initial.mode === "view" ? "view" : "edit";
|
||||
const config = {{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
documentType: docTypeFromExt(initial.fileType),
|
||||
document: {{
|
||||
const resolvedMode = initial.mode === "view" ? "view" : "edit";
|
||||
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
|
||||
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
|
||||
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
|
||||
const config = {{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
documentType: docTypeFromExt(initial.fileType),
|
||||
document: {{
|
||||
fileType: initial.fileType,
|
||||
title: initial.fileName,
|
||||
url: fileState.resolvedFileUrl,
|
||||
key: fileState.docKey,
|
||||
permissions: {{
|
||||
edit: resolvedMode !== "view",
|
||||
download: true,
|
||||
print: true,
|
||||
copy: true
|
||||
}}
|
||||
}},
|
||||
editorConfig: {{
|
||||
mode: resolvedMode,
|
||||
lang: "zh-CN",
|
||||
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
|
||||
user: {{
|
||||
id: displayUserId,
|
||||
name: displayUserName
|
||||
}},
|
||||
customization: {{
|
||||
feedback: {{ visible: false }},
|
||||
anonymous: {{ request: false, label: "Guest" }},
|
||||
features: {{ featuresTips: false }},
|
||||
forcesave: resolvedMode !== "view"
|
||||
}}
|
||||
}},
|
||||
events: {{
|
||||
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
||||
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
||||
onError: (event) => showError(JSON.stringify(event))
|
||||
}}
|
||||
}};
|
||||
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
|
||||
pageOrigin: location.origin,
|
||||
baseUrl: "/onlyoffice-server",
|
||||
documentUrlBase,
|
||||
proxyOrigin,
|
||||
callbackOrigin,
|
||||
fileUrlInput: fileState.effectiveFileUrl,
|
||||
resolvedFileUrl: fileState.resolvedFileUrl,
|
||||
fileName: initial.fileName,
|
||||
fileType: initial.fileType,
|
||||
title: initial.fileName,
|
||||
url: fileState.resolvedFileUrl,
|
||||
key: fileState.docKey,
|
||||
permissions: {{
|
||||
edit: resolvedMode !== "view",
|
||||
download: true,
|
||||
print: true,
|
||||
copy: true
|
||||
mode: initial.mode,
|
||||
resolvedMode,
|
||||
assetId: initial.assetId,
|
||||
documentId: initial.documentId,
|
||||
docKey: fileState.docKey
|
||||
}};
|
||||
const readyDeadline = Date.now() + 120000;
|
||||
const timer = window.setInterval(() => {{
|
||||
const root = document.getElementById("onlyoffice-frame");
|
||||
const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length;
|
||||
if (count > 0) {{
|
||||
window.__MNOTE_ONLYOFFICE_READY__ = true;
|
||||
window.clearInterval(timer);
|
||||
}} else if (Date.now() > readyDeadline) {{
|
||||
window.clearInterval(timer);
|
||||
}}
|
||||
}},
|
||||
editorConfig: {{
|
||||
mode: resolvedMode,
|
||||
lang: "zh-CN",
|
||||
callbackUrl: buildCallbackUrl(initial.assetId, userId),
|
||||
customization: {{
|
||||
feedback: {{ visible: false }},
|
||||
forcesave: resolvedMode !== "view"
|
||||
}}
|
||||
}},
|
||||
events: {{
|
||||
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
||||
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
||||
onError: (event) => showError(JSON.stringify(event))
|
||||
}}
|
||||
}};
|
||||
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
|
||||
pageOrigin: location.origin,
|
||||
baseUrl: "/onlyoffice-server",
|
||||
documentUrlBase,
|
||||
proxyOrigin,
|
||||
callbackOrigin,
|
||||
fileUrlInput: fileState.effectiveFileUrl,
|
||||
resolvedFileUrl: fileState.resolvedFileUrl,
|
||||
fileName: initial.fileName,
|
||||
fileType: initial.fileType,
|
||||
mode: initial.mode,
|
||||
resolvedMode,
|
||||
assetId: initial.assetId,
|
||||
documentId: initial.documentId,
|
||||
docKey: fileState.docKey
|
||||
}};
|
||||
const readyDeadline = Date.now() + 120000;
|
||||
const timer = window.setInterval(() => {{
|
||||
const root = document.getElementById("onlyoffice-frame");
|
||||
const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length;
|
||||
if (count > 0) {{
|
||||
window.__MNOTE_ONLYOFFICE_READY__ = true;
|
||||
window.clearInterval(timer);
|
||||
}} else if (Date.now() > readyDeadline) {{
|
||||
window.clearInterval(timer);
|
||||
}}
|
||||
}}, 500);
|
||||
const signResponse = await fetch("/api/onlyoffice/sign", {{
|
||||
method: "POST",
|
||||
headers: {{ "content-type": "application/json" }},
|
||||
body: JSON.stringify({{ config }})
|
||||
}});
|
||||
const signPayload = await signResponse.json().catch(() => null);
|
||||
if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败");
|
||||
if (signPayload.token) config.token = signPayload.token;
|
||||
if (signPayload.documentToken) config.document.token = signPayload.documentToken;
|
||||
if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
|
||||
installOnlyOfficeFrameSrcPatch();
|
||||
window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
|
||||
}}, 500);
|
||||
const signResponse = await fetch("/api/onlyoffice/sign", {{
|
||||
method: "POST",
|
||||
headers: {{ "content-type": "application/json" }},
|
||||
body: JSON.stringify({{ config }})
|
||||
}});
|
||||
const signPayload = await signResponse.json().catch(() => null);
|
||||
if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败");
|
||||
if (signPayload.token) config.token = signPayload.token;
|
||||
if (signPayload.documentToken) config.document.token = signPayload.documentToken;
|
||||
if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
|
||||
installOnlyOfficeFrameSrcPatch();
|
||||
window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
|
||||
}} finally {{
|
||||
window.clearInterval(bootHeartbeat);
|
||||
}}
|
||||
}}
|
||||
boot().catch((error) => showError(error && error.message ? error.message : error));
|
||||
boot().catch((error) => {{
|
||||
showError(error && error.message ? error.message : error);
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
@@ -972,6 +1047,131 @@ fn proxy_local_folder_file_open(
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
fn is_onlyoffice_local_asset_id(asset_id: &str) -> bool {
|
||||
let value = asset_id.trim();
|
||||
value.starts_with("local:") || value.starts_with("local-file:")
|
||||
}
|
||||
|
||||
fn onlyoffice_callback_success(extra: Value) -> Response {
|
||||
let mut payload = json!({ "error": 0 });
|
||||
if let (Some(object), Some(extra_object)) = (payload.as_object_mut(), extra.as_object()) {
|
||||
for (key, value) in extra_object {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
Json(payload).into_response()
|
||||
}
|
||||
|
||||
fn onlyoffice_callback_failure(error: WebError) -> Response {
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"code": error.code(),
|
||||
"message": error.message(),
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("OnlyOffice callback HTTP 客户端创建失败: {error}"))
|
||||
})?;
|
||||
let response = client.get(download_url).send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_local_callback_download_failed",
|
||||
format!("OnlyOffice 保存文件下载失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"onlyoffice_local_callback_download_failed",
|
||||
format!("OnlyOffice 保存文件下载失败: HTTP {status}"),
|
||||
));
|
||||
}
|
||||
response.bytes().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_local_callback_body_failed",
|
||||
format!("OnlyOffice 保存文件读取失败: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn local_folder_onlyoffice_callback(
|
||||
query: &OnlyOfficeCallbackQuery,
|
||||
body: &Value,
|
||||
status: i64,
|
||||
) -> Result<Response, WebError> {
|
||||
let asset_id = query.asset_id.as_deref().unwrap_or("").trim();
|
||||
let root_uri = query
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"onlyoffice_local_callback_root_missing",
|
||||
"OnlyOffice 本地保存缺少 rootUri",
|
||||
)
|
||||
})?;
|
||||
let relative_path = query
|
||||
.path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"onlyoffice_local_callback_path_missing",
|
||||
"OnlyOffice 本地保存缺少 path",
|
||||
)
|
||||
})?;
|
||||
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
|
||||
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
|
||||
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
|
||||
asset_id: asset_id.to_string(),
|
||||
document_id: None,
|
||||
workspace_id: None,
|
||||
user_id: query.user_id.clone(),
|
||||
session_id: None,
|
||||
status,
|
||||
url: body
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
key: body
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
onlyoffice_internal_url,
|
||||
})
|
||||
.map_err(|error| WebError::bad_request_code("onlyoffice_local_callback_invalid", error))?;
|
||||
if !prepared.should_write {
|
||||
return Ok(onlyoffice_callback_success(json!({
|
||||
"localWrite": false,
|
||||
"status": status,
|
||||
})));
|
||||
}
|
||||
let download_url = prepared.download_url.as_deref().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"onlyoffice_local_callback_url_missing",
|
||||
"OnlyOffice 本地保存缺少下载地址",
|
||||
)
|
||||
})?;
|
||||
let bytes = download_onlyoffice_callback_body(download_url).await?;
|
||||
fs::write(&target, &bytes).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"onlyoffice_local_callback_write_failed",
|
||||
format!("写回本地 Office 文件失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
Ok(onlyoffice_callback_success(json!({
|
||||
"localWrite": true,
|
||||
"bytes": bytes.len(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
@@ -988,6 +1188,19 @@ pub async fn callback(
|
||||
status,
|
||||
"OnlyOffice callback received by mnote-web"
|
||||
);
|
||||
let is_local_callback = query
|
||||
.asset_id
|
||||
.as_deref()
|
||||
.map(is_onlyoffice_local_asset_id)
|
||||
.unwrap_or(false)
|
||||
|| query.root_uri.as_deref().is_some()
|
||||
|| query.path.as_deref().is_some();
|
||||
if is_local_callback {
|
||||
return match local_folder_onlyoffice_callback(&query, &body, status).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => onlyoffice_callback_failure(error),
|
||||
};
|
||||
}
|
||||
match proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/callback",
|
||||
@@ -1308,14 +1521,101 @@ async fn proxy_onlyoffice_path(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn onlyoffice_websocket_url(base: &str, upstream_path: &str, query: Option<&str>) -> String {
|
||||
let mut target = append_path_and_query(base, upstream_path, query);
|
||||
if let Some(rest) = target.strip_prefix("http://") {
|
||||
target = format!("ws://{rest}");
|
||||
} else if let Some(rest) = target.strip_prefix("https://") {
|
||||
target = format!("wss://{rest}");
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
async fn bridge_onlyoffice_websocket(upgraded: Upgraded, target: String) {
|
||||
let client_io = TokioIo::new(upgraded);
|
||||
let mut client_socket = WebSocketStream::from_raw_socket(client_io, Role::Server, None).await;
|
||||
let Ok((mut upstream_socket, _response)) = connect_async(&target).await else {
|
||||
let _ = client_socket.close(None).await;
|
||||
return;
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
client_message = client_socket.next() => {
|
||||
let Some(Ok(message)) = client_message else {
|
||||
let _ = upstream_socket.send(TungsteniteMessage::Close(None)).await;
|
||||
break;
|
||||
};
|
||||
let is_close = matches!(message, TungsteniteMessage::Close(_));
|
||||
if upstream_socket.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
upstream_message = upstream_socket.next() => {
|
||||
let Some(Ok(message)) = upstream_message else {
|
||||
let _ = client_socket.send(TungsteniteMessage::Close(None)).await;
|
||||
break;
|
||||
};
|
||||
let is_close = matches!(message, TungsteniteMessage::Close(_));
|
||||
if client_socket.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn server_proxy(
|
||||
State(_state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
uri: Uri,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
request: Request<Body>,
|
||||
mut request: Request<Body>,
|
||||
) -> Result<Response, WebError> {
|
||||
let upgrade = headers
|
||||
.get(header::UPGRADE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.eq_ignore_ascii_case("websocket"))
|
||||
.unwrap_or(false);
|
||||
if upgrade {
|
||||
let key = headers
|
||||
.get(header::SEC_WEBSOCKET_KEY)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"onlyoffice_websocket_key_missing",
|
||||
"缺少 Sec-WebSocket-Key",
|
||||
)
|
||||
})?;
|
||||
let target =
|
||||
onlyoffice_websocket_url(&resolve_onlyoffice_internal_url().await, &path, uri.query());
|
||||
let upgraded = hyper::upgrade::on(&mut request);
|
||||
tokio::spawn(async move {
|
||||
if let Ok(upgraded) = upgraded.await {
|
||||
bridge_onlyoffice_websocket(upgraded, target).await;
|
||||
}
|
||||
});
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::SWITCHING_PROTOCOLS)
|
||||
.header(header::CONNECTION, "Upgrade")
|
||||
.header(header::UPGRADE, "websocket")
|
||||
.header(
|
||||
header::SEC_WEBSOCKET_ACCEPT,
|
||||
derive_accept_key(key.as_bytes()),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("构造 OnlyOffice WebSocket 响应失败: {error}"))
|
||||
})?;
|
||||
return Ok(response);
|
||||
}
|
||||
proxy_onlyoffice_path("", &path, uri, method, headers, request).await
|
||||
}
|
||||
|
||||
@@ -1332,13 +1632,43 @@ pub async fn cache_proxy(
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String {
|
||||
if !asset_id.trim().is_empty() {
|
||||
let raw = if !asset_id.trim().is_empty() {
|
||||
if storage_id.trim().is_empty() {
|
||||
return asset_id.trim().to_string();
|
||||
asset_id.trim().to_string()
|
||||
} else {
|
||||
format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id))
|
||||
}
|
||||
return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id));
|
||||
} else {
|
||||
js_hash_abs(&format!("{file_url}-{file_name}"))
|
||||
};
|
||||
safe_onlyoffice_doc_key(&raw, file_name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn safe_onlyoffice_doc_key(input: &str, file_name: &str) -> String {
|
||||
let raw = input.trim();
|
||||
if raw.is_empty() {
|
||||
return format!("mnote_{}", js_hash_abs(file_name));
|
||||
}
|
||||
js_hash_abs(&format!("{file_url}-{file_name}"))
|
||||
let mut safe = String::new();
|
||||
let mut previous_underscore = false;
|
||||
for ch in raw.chars() {
|
||||
let allowed = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-');
|
||||
let next = if allowed { ch } else { '_' };
|
||||
if next == '_' {
|
||||
if previous_underscore {
|
||||
continue;
|
||||
}
|
||||
previous_underscore = true;
|
||||
} else {
|
||||
previous_underscore = false;
|
||||
}
|
||||
safe.push(next);
|
||||
}
|
||||
if !safe.is_empty() && safe == raw && safe.len() <= 96 {
|
||||
return safe;
|
||||
}
|
||||
format!("mnote_{}_{}", js_hash_abs(raw), js_hash_abs(file_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1374,6 +1704,24 @@ mod tests {
|
||||
assert!(key.starts_with("asset_1_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_doc_key_hashes_local_unicode_asset_ids() {
|
||||
let key = stable_doc_key(
|
||||
"local:asset:Alpha/重庆发展特殊化妆品可行性报告_政府汇报版.docx",
|
||||
"",
|
||||
"",
|
||||
"重庆发展特殊化妆品可行性报告_政府汇报版.docx",
|
||||
);
|
||||
assert!(key.len() <= 128);
|
||||
assert!(key.starts_with("mnote_"));
|
||||
assert!(key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||
assert!(!key.contains('/'));
|
||||
assert!(!key.contains(':'));
|
||||
assert!(!key.contains('重'));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_object_shell_exposes_resource_identity() {
|
||||
let response = object_shell(
|
||||
@@ -1533,6 +1881,8 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: None,
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
@@ -1550,6 +1900,85 @@ mod tests {
|
||||
assert_eq!(payload["degraded"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
root.display()
|
||||
)
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some("Page/report.docx".into()),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": download_url
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert_eq!(fs::read(&target).expect("read target"), b"new docx bytes");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_ignores_non_write_status() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-ignore-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
root.display()
|
||||
)
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some("Page/report.docx".into()),
|
||||
}),
|
||||
Json(json!({ "status": 1 })),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert_eq!(fs::read(&target).expect("read target"), b"old");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
|
||||
@@ -1561,6 +1990,8 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
@@ -1635,4 +2066,50 @@ mod tests {
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() {
|
||||
let response = page(Query(OnlyOfficePageQuery {
|
||||
file_url: Some(
|
||||
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
|
||||
.into(),
|
||||
),
|
||||
file_name: Some("report.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
asset_id: Some("local-file:Page/report.docx".into()),
|
||||
document_id: Some("local-md:Page".into()),
|
||||
user_id: None,
|
||||
mode: Some("view".into()),
|
||||
}))
|
||||
.await
|
||||
.expect("onlyoffice page");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
|
||||
// 1) Guard function is present
|
||||
assert!(html.contains("function isLocalFolderAsset()"));
|
||||
// 2) Condition uses guard to skip /api/media/sign
|
||||
assert!(html.contains("if (initial.assetId && !isLocalFolderAsset())"));
|
||||
// 3) local: prefix detection
|
||||
assert!(html.contains("initial.assetId.indexOf(\"local:\") === 0"));
|
||||
// 4) local-file: prefix detection
|
||||
assert!(html.contains("initial.assetId.indexOf(\"local-file:\") === 0"));
|
||||
// 5) fileUrl path detection for /api/local-folder/files/open
|
||||
assert!(html.contains(
|
||||
"initial.fileUrl && initial.fileUrl.indexOf(\"/api/local-folder/files/open\") !== -1"
|
||||
));
|
||||
// 6) Non-local asset still fetches /api/media/sign (general code path preserved)
|
||||
assert!(html.contains("/api/media/sign?assetId="));
|
||||
// 7) local rootUri/path are propagated into callback for save writeback
|
||||
assert!(html.contains("function localFolderOpenParams(raw)"));
|
||||
assert!(html.contains("callback.searchParams.set(\"rootUri\", localFile.rootUri);"));
|
||||
assert!(html.contains("callback.searchParams.set(\"path\", localFile.path);"));
|
||||
// 8) user name is explicit so OnlyOffice does not ask for collaboration name
|
||||
assert!(html.contains("user: {"));
|
||||
assert!(html.contains("name: displayUserName"));
|
||||
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
|
||||
assert!(html.contains("features: { featuresTips: false }"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +465,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
document.title = title;
|
||||
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
||||
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
||||
const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
|
||||
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
|
||||
}
|
||||
if (documentId) {
|
||||
const escapedId = cssEscape(documentId);
|
||||
@@ -2616,6 +2618,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
document.title = title;
|
||||
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
||||
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
||||
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
|
||||
if (pageTab instanceof HTMLElement) {
|
||||
pageTab.setAttribute('data-document-id', documentId);
|
||||
pageTab.setAttribute('data-workspace-id', workspaceId);
|
||||
const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title');
|
||||
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', {
|
||||
detail: { documentId, workspaceId, title }
|
||||
}));
|
||||
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
|
||||
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
|
||||
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
|
||||
@@ -2986,12 +2998,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE
|
||||
? anchorNode
|
||||
: anchorNode?.parentElement || null;
|
||||
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => node instanceof HTMLElement);
|
||||
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => (
|
||||
node instanceof HTMLElement
|
||||
&& node.offsetParent !== null
|
||||
&& node.getAttribute('data-mnote-side-target-unsupported') !== 'true'
|
||||
));
|
||||
if (anchorElement instanceof Element) {
|
||||
const activeRoot = roots.find((root) => root.contains(anchorElement));
|
||||
if (activeRoot) return activeRoot;
|
||||
}
|
||||
return roots.find((root) => root.offsetParent !== null) || roots[0] || null;
|
||||
const focused = document.activeElement instanceof Element
|
||||
? roots.find((root) => root.contains(document.activeElement))
|
||||
: null;
|
||||
if (focused) return focused;
|
||||
return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null;
|
||||
};
|
||||
|
||||
const slashMenuAnchorFromSelection = (root) => {
|
||||
@@ -3042,12 +3062,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return fallback();
|
||||
};
|
||||
|
||||
const positionSlashMenuForRoot = (root) => {
|
||||
if (!(root instanceof HTMLElement)) root = activeEditorRootForSlashMenu();
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')
|
||||
|| document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
|
||||
const setSlashMenuInactive = (menu, inactive) => {
|
||||
if (!(menu instanceof HTMLElement)) return;
|
||||
if (inactive) {
|
||||
if (menu.getAttribute('data-mnote-slash-inactive') !== 'true') {
|
||||
menu.setAttribute('data-mnote-slash-inactive', 'true');
|
||||
}
|
||||
if (menu.style.display !== 'none') menu.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (menu.getAttribute('data-mnote-slash-inactive') === 'true') {
|
||||
menu.removeAttribute('data-mnote-slash-inactive');
|
||||
}
|
||||
if (menu.style.display === 'none') menu.style.display = '';
|
||||
};
|
||||
|
||||
const hideSlashMenusOutsideRoot = (activeRoot) => {
|
||||
document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => {
|
||||
const root = menu.closest(ROOT_SELECTOR);
|
||||
if (root !== activeRoot) setSlashMenuInactive(menu, true);
|
||||
});
|
||||
};
|
||||
|
||||
const positionSlashMenuForRoot = (root) => {
|
||||
const activeRoot = activeEditorRootForSlashMenu();
|
||||
if (!(root instanceof HTMLElement)) root = activeRoot;
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
if (activeRoot instanceof HTMLElement && root !== activeRoot) {
|
||||
const inactiveMenu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
|
||||
setSlashMenuInactive(inactiveMenu, true);
|
||||
hideSlashMenusOutsideRoot(activeRoot);
|
||||
return;
|
||||
}
|
||||
hideSlashMenusOutsideRoot(root);
|
||||
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
|
||||
if (!(menu instanceof HTMLElement)) return;
|
||||
setSlashMenuInactive(menu, false);
|
||||
const anchor = slashMenuAnchorFromSelection(root);
|
||||
const gap = 8;
|
||||
const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16));
|
||||
@@ -3611,6 +3661,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshExistingOfficeResourceTab = (entry, input) => {
|
||||
if (!entry || entry.kind !== 'office') return false;
|
||||
const nextHref = String(input.officeUrl || input.href || '').trim();
|
||||
if (!nextHref) return false;
|
||||
const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
|
||||
const currentHref = frame instanceof HTMLIFrameElement
|
||||
? String(frame.getAttribute('src') || frame.src || '').trim()
|
||||
: '';
|
||||
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
|
||||
return true;
|
||||
};
|
||||
|
||||
const openMindmapResourceTab = async (entry, input) => {
|
||||
const documentId = String(input.documentId || currentDocumentId() || '').trim();
|
||||
const mindmapId = String(input.mindmapId || input.assetId || '').trim();
|
||||
@@ -3668,6 +3730,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源';
|
||||
if (root instanceof HTMLElement) {
|
||||
root.replaceChildren();
|
||||
document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => {
|
||||
if (node instanceof HTMLElement) node.remove();
|
||||
});
|
||||
root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
|
||||
root.setAttribute('data-mnote-side-target-unsupported', 'true');
|
||||
root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
|
||||
@@ -3691,6 +3756,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (!objectIdentity) return false;
|
||||
const existing = resourceTabRegistry.get(objectIdentity);
|
||||
if (existing) {
|
||||
refreshExistingOfficeResourceTab(existing, input);
|
||||
activateMainEditorTab(objectIdentity);
|
||||
return true;
|
||||
}
|
||||
@@ -3754,6 +3820,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
|
||||
await replacePaneDocument('primary', descriptor);
|
||||
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
|
||||
activateMainEditorTab('');
|
||||
return true;
|
||||
},
|
||||
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
|
||||
@@ -3778,6 +3845,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
},
|
||||
resolveResourceOpen: (input) => resolveResourceOpen(input),
|
||||
openResourceInActiveTab: openResourceInActiveTab,
|
||||
activatePageTab: () => {
|
||||
bindMainEditorPageTab();
|
||||
activateMainEditorTab('');
|
||||
return true;
|
||||
},
|
||||
openResourceAsSideTarget: async (input = {}) => {
|
||||
const resolved = resolveResourceOpen({ ...input, openTarget: 'side' });
|
||||
if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') {
|
||||
@@ -4466,12 +4538,25 @@ mod tests {
|
||||
assert!(html.contains("data-mnote-main-tab-strip"));
|
||||
assert!(html.contains("class=\"mnote-main-tab-badge\""));
|
||||
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
|
||||
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
|
||||
assert!(html.contains("pageTab.setAttribute('data-document-id', documentId);"));
|
||||
assert!(html.contains("data-mnote-tab-badge-kind"));
|
||||
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
|
||||
assert!(html.contains("positionSlashMenuForRoot"));
|
||||
assert!(html.contains("menu.style.position = 'fixed';"));
|
||||
assert!(html.contains("installGlobalSlashMenuPositioning();"));
|
||||
assert!(html.contains("data-mnote-slash-positioned', 'host'"));
|
||||
assert!(html.contains(
|
||||
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
|
||||
));
|
||||
assert!(html.contains("const setSlashMenuInactive = (menu, inactive) =>"));
|
||||
assert!(html.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
|
||||
assert!(html.contains("data-mnote-slash-inactive"));
|
||||
assert!(html.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)"));
|
||||
assert!(!html.contains(
|
||||
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
|
||||
));
|
||||
assert!(html.contains("data-mnote-side-target-unsupported') !== 'true'"));
|
||||
assert!(
|
||||
html.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
|
||||
);
|
||||
@@ -4509,6 +4594,12 @@ mod tests {
|
||||
assert!(html.contains("resourceTabRegistry.delete(key)"));
|
||||
assert!(html.contains("activateMainEditorTab(lastActiveResourceTabKey())"));
|
||||
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
|
||||
assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>"));
|
||||
assert!(html.contains("if (!entry || entry.kind !== 'office') return false;"));
|
||||
assert!(
|
||||
html.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);")
|
||||
);
|
||||
assert!(html.contains("refreshExistingOfficeResourceTab(existing, input);"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4769,7 +4860,7 @@ mod tests {
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
assert!(html.contains("data-page-openable=\"false\""));
|
||||
assert!(html.contains("fileAction === 'open' && rowKind === 'folder'"));
|
||||
assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'"));
|
||||
assert!(html.contains("openTrigger.getAttribute('data-page-openable') === 'false'"));
|
||||
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
||||
assert!(html.contains("refreshSessionFromExternalFileChange"));
|
||||
assert!(html.contains("refreshSessionFromExternalChange"));
|
||||
|
||||
@@ -1253,6 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode());
|
||||
persistSidebarTreeMode(treeView);
|
||||
if (currentDocumentId() === nodeId && window.location.pathname.indexOf('/documents/') === 0) {
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === 'function') {
|
||||
window.__mnoteDocumentPaneRuntime.activatePageTab();
|
||||
}
|
||||
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach(function(row) {
|
||||
if (row instanceof HTMLElement) {
|
||||
row.setAttribute('data-active', 'false');
|
||||
@@ -1268,6 +1271,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
});
|
||||
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
|
||||
return;
|
||||
}
|
||||
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
|
||||
@@ -1289,6 +1293,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}).then(function(){
|
||||
if (mnoteNavigationInFlight === url) mnoteNavigationInFlight = '';
|
||||
document.documentElement.removeAttribute('data-mnote-navigation-pending');
|
||||
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
|
||||
}).catch(function(error){
|
||||
console.warn('mnote pane 内导航失败,将回退整页导航', error);
|
||||
if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') {
|
||||
@@ -1323,9 +1328,32 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map();
|
||||
window.__mnoteLocalFolderSelfChangeSuppressions.set(nextDocumentId, Date.now() + 5000);
|
||||
}
|
||||
if (nextDocumentId && currentSourceKind() === 'local_folder') {
|
||||
await refreshLocalFolderSidebarSnapshot();
|
||||
selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true });
|
||||
document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId);
|
||||
}
|
||||
navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() });
|
||||
}
|
||||
|
||||
async function createFileTreeFolder(trigger, parentId) {
|
||||
if (currentSourceKind() !== 'local_folder') return false;
|
||||
var workspaceId = resolveWorkspaceId(trigger || document.body);
|
||||
var effectiveParentId = String(parentId || '').trim();
|
||||
var title = window.prompt('新建文件夹', '新建文件夹');
|
||||
if (!title || !title.trim()) return false;
|
||||
var result = await dispatchTreeCommand(trigger || document.body, {
|
||||
action: 'create_folder',
|
||||
workspaceId: workspaceId,
|
||||
parentId: effectiveParentId || null,
|
||||
title: title.trim()
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-filetree-folder-created', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-filetree-folder-created-id', commandDocumentId(result, result.id || ''));
|
||||
void refreshLocalFolderSidebarSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
function applySidebarTreeTab(mode, shell) {
|
||||
mode = persistSidebarTreeMode(mode);
|
||||
shell = shell || document.querySelector('[data-testid="wolai-sidebar-page-tree-shell"]');
|
||||
@@ -2063,7 +2091,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (input.assetId) target.searchParams.set('assetId', input.assetId);
|
||||
if (input.documentId) target.searchParams.set('documentId', input.documentId);
|
||||
if (input.userId) target.searchParams.set('userId', input.userId);
|
||||
target.searchParams.set('mode', input.mode || 'edit');
|
||||
target.searchParams.set('mode', input.mode || 'view');
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
@@ -2075,7 +2103,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (input.assetId) params.set('assetId', input.assetId);
|
||||
if (input.documentId) params.set('documentId', input.documentId);
|
||||
if (input.userId) params.set('userId', input.userId);
|
||||
params.set('mode', input.mode || 'edit');
|
||||
params.set('mode', input.mode || 'view');
|
||||
if (input.documentId && input.assetId) {
|
||||
return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId) + '?' + params.toString();
|
||||
}
|
||||
@@ -2094,7 +2122,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId || ('local-file:' + relativePath),
|
||||
documentId: documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: mode || 'edit'
|
||||
mode: mode || 'view'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2224,6 +2252,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!assetId) return;
|
||||
var openTarget = String(detail && detail.openTarget || '').trim().toLowerCase();
|
||||
var forceNewWindow = openTarget === 'new-window';
|
||||
var forceEditMode = openTarget === 'edit-mode';
|
||||
if (openTarget === 'side') {
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') {
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({
|
||||
@@ -2261,7 +2290,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
||||
return;
|
||||
}
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceNewWindow ? 'edit' : 'view');
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceEditMode ? 'edit' : 'view');
|
||||
if (localOfficeUrl) {
|
||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
@@ -2328,15 +2357,29 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type);
|
||||
if (fileType) {
|
||||
var userId = await fetchCurrentOnlyOfficeUserId();
|
||||
window.open(buildOnlyOfficeOpenUrl({
|
||||
var officeUrl = buildOnlyOfficeOpenUrl({
|
||||
fileUrl: fileUrl,
|
||||
fileName: fileName,
|
||||
fileType: fileType,
|
||||
assetId: assetId,
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
userId: userId,
|
||||
mode: 'edit'
|
||||
}), '_blank', 'noopener,noreferrer');
|
||||
mode: forceEditMode ? 'edit' : 'view'
|
||||
});
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
|
||||
var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:onlyoffice:' + String(asset.document_id || detail.documentId || '').trim() + ':' + assetId,
|
||||
assetId: assetId,
|
||||
title: fileName,
|
||||
fileName: fileName,
|
||||
kind: 'office',
|
||||
officeUrl: officeUrl,
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim()
|
||||
});
|
||||
if (didOpen) return;
|
||||
}
|
||||
window.open(officeUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
|
||||
@@ -2695,7 +2738,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId,
|
||||
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||
userId: userId || '',
|
||||
mode: 'edit'
|
||||
mode: 'view'
|
||||
});
|
||||
}
|
||||
return buildOnlyOfficeOpenUrl({
|
||||
@@ -2705,7 +2748,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId,
|
||||
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||
userId: userId || '',
|
||||
mode: 'edit'
|
||||
mode: 'view'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2803,7 +2846,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: detail.assetId,
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: 'edit'
|
||||
mode: 'view'
|
||||
}));
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
link.setAttribute('data-asset-id', detail.assetId);
|
||||
@@ -3172,7 +3215,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId,
|
||||
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
|
||||
userId: userId || '',
|
||||
mode: 'edit'
|
||||
mode: 'view'
|
||||
}))
|
||||
: href;
|
||||
var inserted = editor.chain().focus().insertContent({
|
||||
@@ -3557,6 +3600,57 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
}
|
||||
|
||||
function fileTreeCopyPath(detail, trigger) {
|
||||
// 对 local_folder 复制真实相对路径,不复制标题
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
if (detail.assetId) {
|
||||
var path = localFilePathFromAssetId(detail.assetId);
|
||||
if (path) {
|
||||
try {
|
||||
return decodeURIComponent(path.replace(/~2F/g, '/'));
|
||||
} catch (_) {
|
||||
return path.replace(/~2F/g, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (detail.documentId) {
|
||||
var docPath = String(detail.documentId || '')
|
||||
.replace(/^local-md:/, '')
|
||||
.replace(/^local-dir:/, '')
|
||||
.replace(/~2F/g, '/');
|
||||
if (docPath) {
|
||||
try {
|
||||
return decodeURIComponent(docPath);
|
||||
} catch (_) {
|
||||
return docPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return detail.title || '';
|
||||
}
|
||||
|
||||
function fileTreeMenuTargetParentId(detail, trigger) {
|
||||
var rowKind = String(detail && detail.rowKind || '').trim();
|
||||
var rowId = String(detail && detail.rowId || '').trim();
|
||||
var documentId = String(detail && detail.documentId || '').trim();
|
||||
if (rowKind === 'folder' && rowId) return rowId;
|
||||
if (rowKind === 'directory' && rowId) return rowId;
|
||||
if (documentId) return documentId;
|
||||
if (trigger && typeof trigger.getAttribute === 'function') {
|
||||
var triggerKind = String(trigger.getAttribute('data-row-kind') || '').trim();
|
||||
var triggerRowId = String(trigger.getAttribute('data-row-id') || '').trim();
|
||||
if ((triggerKind === 'folder' || triggerKind === 'directory') && triggerRowId) return triggerRowId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function withOfficeEditModeGuard(callback) {
|
||||
document.documentElement.setAttribute('data-mnote-last-office-edit-mode-requested', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-last-office-edit-mode-guard', 'silent');
|
||||
callback();
|
||||
}
|
||||
|
||||
function handleTreeContextMenuAction(action, detail, trigger) {
|
||||
closeTreeContextMenu();
|
||||
detail = detail || {};
|
||||
@@ -3577,6 +3671,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
openEditorAttachmentNewWindow(detail);
|
||||
return;
|
||||
}
|
||||
if (action === 'new-window-edit') {
|
||||
withOfficeEditModeGuard(function() {
|
||||
openEditorAttachmentNewWindow(detail, 'edit');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'open-edit-mode') {
|
||||
withOfficeEditModeGuard(function() {
|
||||
void openEditorAttachmentEditTab(detail);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'right-preview') {
|
||||
dispatchSidebarEvent('tree.attachment.open-right', detail);
|
||||
return;
|
||||
@@ -3597,6 +3703,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
||||
return;
|
||||
}
|
||||
if (isAsset && action === 'open-edit-mode') {
|
||||
withOfficeEditModeGuard(function() {
|
||||
recordFileTreeAction('open-edit-mode', detail);
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isAsset && action === 'open-right') {
|
||||
recordFileTreeAction('open-right', detail);
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' });
|
||||
@@ -3645,7 +3758,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
if (action === 'new-file') {
|
||||
void createPage(trigger || document.body, documentId || null);
|
||||
void createPage(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null);
|
||||
return;
|
||||
}
|
||||
if (action === 'new-folder') {
|
||||
recordFileTreeAction('new-folder', detail);
|
||||
void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) {
|
||||
recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail);
|
||||
}).catch(function(error) {
|
||||
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
|
||||
window.alert(error && error.message ? error.message : '新建文件夹失败');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'paste-into') {
|
||||
@@ -3660,7 +3783,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
if (action === 'copy-path') {
|
||||
void copyTreeContextValue(title, 'copy-path');
|
||||
void copyTreeContextValue(fileTreeCopyPath(detail, trigger), 'copy-path');
|
||||
return;
|
||||
}
|
||||
if (action === 'refresh') {
|
||||
@@ -3790,6 +3913,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ separator: true },
|
||||
{ action: 'popup-preview', icon: 'preview', label: '弹窗预览' },
|
||||
{ action: 'right-preview', icon: 'right_panel_open', label: '右侧预览' },
|
||||
{ action: 'open-edit-mode', icon: 'edit_note', label: '弹窗编辑' },
|
||||
{ action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'download', icon: 'download', label: '下载' },
|
||||
{ action: 'replace-file', icon: 'sync', label: '更换文件' },
|
||||
@@ -3799,7 +3924,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ separator: true },
|
||||
{ action: 'color', icon: 'format_paint', label: '颜色' }
|
||||
] : isAsset ? [
|
||||
{ action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' },
|
||||
{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
@@ -3817,14 +3942,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ separator: true },
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: true, title: 'Convex 文件夹对象尚未进入正式 tree command' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
||||
{ separator: true },
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
@@ -3835,8 +3958,6 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ separator: true },
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
@@ -3929,6 +4050,33 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return Array.from(selected);
|
||||
}
|
||||
|
||||
function selectSidebarFileTreeDocument(documentId, options) {
|
||||
var id = String(documentId || '').trim();
|
||||
if (!id) return false;
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]')
|
||||
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]')
|
||||
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]');
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var rowId = row.getAttribute('data-row-id') || '';
|
||||
if (!rowId) return false;
|
||||
sidebarFileTreeSelection.selectedRowIds = new Set([rowId]);
|
||||
sidebarFileTreeSelection.anchorRowId = rowId;
|
||||
sidebarFileTreeSelection.focusedRowId = rowId;
|
||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
|
||||
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
|
||||
});
|
||||
syncSidebarFileTreeSelection();
|
||||
row.setAttribute('data-active', 'true');
|
||||
if (options && options.scrollIntoView !== false) {
|
||||
try {
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
} catch (_) {
|
||||
row.scrollIntoView();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectedSidebarFileTreeRowIdsForDrag(row) {
|
||||
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
|
||||
if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
|
||||
@@ -4773,6 +4921,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function pageAiResolvePermission(permissionId, decision) {
|
||||
permissionId = String(permissionId || '').trim();
|
||||
if (!permissionId) return;
|
||||
// 调用后端 resolve-permission 端点,让 ACP agent 得到真实响应
|
||||
var runId = pageUiState.pageAiCurrentRunId;
|
||||
if (runId) {
|
||||
fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ permissionId: permissionId, decision: decision })
|
||||
}).then(function(response) {
|
||||
if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status);
|
||||
}).catch(function(err) {
|
||||
console.warn('resolve-permission 请求失败', err);
|
||||
});
|
||||
} else {
|
||||
console.warn('resolve-permission: 无活跃 runId,只能本地更新');
|
||||
}
|
||||
// 本地乐观更新 UI
|
||||
pageUiState.pageAiMessages.forEach(function(item) {
|
||||
if (item.kind === 'permission' && item.permissionId === permissionId) {
|
||||
item.resolved = true;
|
||||
@@ -4986,6 +5150,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
|
||||
var rawLocations = payload.locations;
|
||||
return {
|
||||
role: 'tool',
|
||||
content: toolName,
|
||||
@@ -4995,6 +5160,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
|
||||
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
|
||||
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
|
||||
locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [],
|
||||
traceId: String(payload.traceId || payload.trace_id || ''),
|
||||
auditId: String(payload.auditId || payload.audit_id || '')
|
||||
};
|
||||
@@ -5610,6 +5776,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
traceId: traceId,
|
||||
auditId: auditId
|
||||
};
|
||||
var rawLocations = toolEvent && toolEvent.locations;
|
||||
var locations = Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [];
|
||||
var existing = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.role === 'tool' && item.toolCallId === toolCallId;
|
||||
});
|
||||
@@ -5624,6 +5792,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
status: status,
|
||||
argsSummary: '',
|
||||
resultSummary: '',
|
||||
locations: locations,
|
||||
traceId: traceId,
|
||||
auditId: auditId
|
||||
};
|
||||
@@ -5637,6 +5806,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
existing.auditId = auditId || existing.auditId || '';
|
||||
if (argsSummary) existing.argsSummary = argsSummary;
|
||||
if (resultSummary) existing.resultSummary = resultSummary;
|
||||
if (locations.length) existing.locations = locations;
|
||||
if (status === 'completed') {
|
||||
pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId);
|
||||
}
|
||||
@@ -6585,7 +6755,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
|
||||
if (item.role === 'tool') {
|
||||
var statusLabel = item.status === 'completed' ? '完成' : (item.status === 'failed' ? '失败' : '运行中');
|
||||
var locationRows = Array.isArray(item.locations) && item.locations.length
|
||||
? '<div class="wolai-page-ai-tool-meta">位置:' + item.locations.map(function(loc, idx) {
|
||||
return '<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px">' +
|
||||
'<span>' + escapeHtml(loc) + '</span>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" style="font-size:0.85em;padding:0 4px;min-width:unset" data-page-ai-open-location="' + escapeHtml(loc) + '" data-page-ai-tool-call-id="' + escapeHtml(item.toolCallId || '') + '" title="打开文件">打开</button>' +
|
||||
'</span>';
|
||||
}).join('') + '</div>'
|
||||
: '';
|
||||
var detailRows = [
|
||||
locationRows,
|
||||
item.argsSummary ? '<div class="wolai-page-ai-tool-meta">参数 ' + escapeHtml(item.argsSummary) + '</div>' : '',
|
||||
item.resultSummary ? '<div class="wolai-page-ai-tool-meta">结果 ' + escapeHtml(item.resultSummary) + '</div>' : '',
|
||||
item.changedFiles && item.changedFiles.length ? '<pre class="wolai-page-ai-tool-meta wolai-page-ai-changed-files">' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '</pre>' : '',
|
||||
@@ -6629,6 +6808,21 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
if (item.kind === 'plan') {
|
||||
var planEntries = Array.isArray(item.entries) ? item.entries : [];
|
||||
var listHtml = planEntries.map(function(entry, idx) {
|
||||
return '<li style="margin:2px 0">' + escapeHtml(String(entry || '')) + '</li>';
|
||||
}).join('');
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-plan="true">' +
|
||||
'<details class="wolai-page-ai-plan-details" open>' +
|
||||
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.8;font-size:0.85em">执行计划 · ' + planEntries.length + ' 步</summary>' +
|
||||
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.75;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' +
|
||||
'<ol style="margin:4px 0;padding-left:20px">' + listHtml + '</ol>' +
|
||||
'</div>' +
|
||||
'</details>' +
|
||||
'</div>';
|
||||
}
|
||||
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
||||
@@ -7046,6 +7240,40 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiStoppedRunIds[runId] = true;
|
||||
pageAiSetRunStatus('aborted', runId);
|
||||
}
|
||||
if (eventName === 'session.info.updated') {
|
||||
try {
|
||||
var infoPayload = JSON.parse(payloadText || 'null') || {};
|
||||
var newTitle = String(infoPayload.title || '').trim();
|
||||
if (newTitle) {
|
||||
var sessionForTitle = pageAiCurrentSession();
|
||||
if (sessionForTitle) {
|
||||
sessionForTitle.title = newTitle;
|
||||
sessionForTitle.updatedAt = Date.now();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'plan.updated') {
|
||||
try {
|
||||
var planPayload = JSON.parse(payloadText || 'null') || {};
|
||||
var planEntries = Array.isArray(planPayload.entries) ? planPayload.entries : [];
|
||||
if (planEntries.length) {
|
||||
var planMsgs = pageUiState.pageAiMessages;
|
||||
var existingPlan = planMsgs.length > 0 && planMsgs[planMsgs.length - 1].kind === 'plan' ? planMsgs[planMsgs.length - 1] : null;
|
||||
if (existingPlan) {
|
||||
existingPlan.entries = planEntries;
|
||||
existingPlan.updatedAt = Date.now();
|
||||
} else {
|
||||
planMsgs.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() });
|
||||
}
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||
pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId);
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
@@ -7424,7 +7652,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: url.searchParams.get('assetId') || '',
|
||||
documentId: url.searchParams.get('documentId') || currentDocumentId() || '',
|
||||
userId: url.searchParams.get('userId') || '',
|
||||
mode: url.searchParams.get('mode') || 'edit'
|
||||
mode: url.searchParams.get('mode') || 'view'
|
||||
});
|
||||
} catch (_) {
|
||||
return String(href || '');
|
||||
@@ -7453,7 +7681,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId,
|
||||
documentId: currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: 'edit'
|
||||
mode: 'view'
|
||||
});
|
||||
} else if (isOnlyOfficeAttachmentHref(rawHref)) {
|
||||
href = normalizeOnlyOfficeAttachmentHref(rawHref);
|
||||
@@ -7494,7 +7722,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
assetId: assetId,
|
||||
documentId: params.get('documentId') || currentDocumentId() || '',
|
||||
userId: params.get('userId') || '',
|
||||
mode: params.get('mode') || 'edit'
|
||||
mode: params.get('mode') || 'view'
|
||||
}));
|
||||
}
|
||||
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
||||
@@ -7575,20 +7803,110 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
void openCodeEditorAttachment(detail);
|
||||
return;
|
||||
}
|
||||
var fileType = String(detail.fileType || '').trim();
|
||||
if (fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
|
||||
assetId: detail.assetId || '',
|
||||
title: detail.fileName || '附件',
|
||||
fileName: detail.fileName || '附件',
|
||||
kind: 'office',
|
||||
officeUrl: detail.href,
|
||||
documentId: detail.documentId || '',
|
||||
workspaceId: detail.workspaceId || ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(detail.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
function openEditorAttachmentNewWindow(detail) {
|
||||
function openEditorAttachmentNewWindow(detail, mode) {
|
||||
if (!detail) return;
|
||||
var requestedMode = mode === 'edit' ? 'edit' : 'view';
|
||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit');
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode);
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
window.open(detail.href || detail.fileUrl, '_blank', 'noopener,noreferrer');
|
||||
var href = detail.href || detail.fileUrl;
|
||||
if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
|
||||
try {
|
||||
var url = new URL(href, window.location.origin);
|
||||
url.searchParams.set('mode', requestedMode);
|
||||
href = url.toString();
|
||||
} catch (_) {}
|
||||
} else if (detail.fileType) {
|
||||
href = buildOnlyOfficeOpenUrl({
|
||||
fileUrl: detail.fileUrl || href || '',
|
||||
fileName: detail.fileName || '未命名附件',
|
||||
fileType: detail.fileType,
|
||||
assetId: detail.assetId || '',
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: requestedMode
|
||||
});
|
||||
}
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function openEditorAttachmentEditTab(detail) {
|
||||
if (!detail) return false;
|
||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit');
|
||||
if (localOfficeUrl) {
|
||||
var didOpenLocalEditTab = await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
title: detail.fileName || localFileName,
|
||||
kind: 'office',
|
||||
assetId: detail.assetId,
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||
href: buildLocalFileOpenUrl(localFilePath, false),
|
||||
officeUrl: localOfficeUrl
|
||||
});
|
||||
if (!didOpenLocalEditTab) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
|
||||
return didOpenLocalEditTab;
|
||||
}
|
||||
}
|
||||
var href = detail.href || detail.fileUrl;
|
||||
if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
|
||||
try {
|
||||
var url = new URL(href, window.location.origin);
|
||||
url.searchParams.set('mode', 'edit');
|
||||
href = url.toString();
|
||||
} catch (_) {}
|
||||
} else if (detail.fileType) {
|
||||
href = buildOnlyOfficeOpenUrl({
|
||||
fileUrl: detail.fileUrl || href || '',
|
||||
fileName: detail.fileName || '未命名附件',
|
||||
fileType: detail.fileType,
|
||||
assetId: detail.assetId || '',
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: 'edit'
|
||||
});
|
||||
}
|
||||
if (detail.fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
|
||||
var didOpenEditTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
|
||||
assetId: detail.assetId || '',
|
||||
title: detail.fileName || '附件',
|
||||
fileName: detail.fileName || '附件',
|
||||
kind: 'office',
|
||||
officeUrl: href,
|
||||
documentId: detail.documentId || '',
|
||||
workspaceId: detail.workspaceId || ''
|
||||
});
|
||||
if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return didOpenEditTab;
|
||||
}
|
||||
if (href) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveEditorAttachmentUrl(detail) {
|
||||
@@ -7999,6 +8317,21 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiOpenLocation = closestAction(e.target, '[data-page-ai-open-location]');
|
||||
if (pageAiOpenLocation) {
|
||||
e.preventDefault();
|
||||
var loc = String(pageAiOpenLocation.getAttribute('data-page-ai-open-location') || '').trim();
|
||||
if (loc) {
|
||||
openLocalResourceInActiveTab({ path: loc }).then(function(opened) {
|
||||
if (!opened) {
|
||||
var href = buildLocalFileOpenUrl(loc, false);
|
||||
if (href) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
@@ -8181,10 +8514,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
var tree = document.getElementById('sidebar-tree-root');
|
||||
if (!tree || !tree.contains(e.target)) return;
|
||||
var pageRow = closestAction(e.target, '.tree-row[data-shell-mode="page"]');
|
||||
var btn = closestAction(e.target, '[data-rust-action]');
|
||||
if (!btn) return;
|
||||
var nodeId = btn.getAttribute('data-node-id');
|
||||
var action = btn.getAttribute('data-rust-action');
|
||||
if (!btn && !pageRow) return;
|
||||
var nodeId = (btn && btn.getAttribute('data-node-id')) || (pageRow && pageRow.getAttribute('data-node-id')) || '';
|
||||
var action = btn ? btn.getAttribute('data-rust-action') : 'open';
|
||||
|
||||
if (action === 'toggle') {
|
||||
var row = btn.closest('.tree-row');
|
||||
@@ -8192,11 +8526,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
toggleChildren(row, btn);
|
||||
e.preventDefault();
|
||||
} else if (action === 'open') {
|
||||
if (btn.getAttribute('data-page-openable') === 'false') {
|
||||
var openTrigger = btn || pageRow;
|
||||
if (openTrigger && openTrigger.getAttribute('data-page-openable') === 'false') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
var workspaceId = resolveWorkspaceId(btn);
|
||||
var workspaceId = resolveWorkspaceId(openTrigger);
|
||||
navigateToDocument(nodeId, workspaceId, { treeView: 'page' });
|
||||
e.preventDefault();
|
||||
} else if (action === 'create') {
|
||||
@@ -8714,6 +9049,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
sidebarFileTreeSelection.focusedRowId = rowId;
|
||||
});
|
||||
syncSidebarFileTreeSelection();
|
||||
window.addEventListener('mnote:primary-document-activated', function(event) {
|
||||
var detail = event && event.detail ? event.detail : {};
|
||||
selectSidebarFileTreeDocument(detail.documentId, { scrollIntoView: false });
|
||||
});
|
||||
restoreSidebarTreeTab();
|
||||
startLocalFolderSidebarWatch();
|
||||
})();
|
||||
@@ -9162,7 +9501,7 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('userId'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("window.open(buildOnlyOfficeOpenUrl"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("window.open(officeUrl,"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("beginFileTreeInlineRename"));
|
||||
@@ -9240,6 +9579,22 @@ mod tests {
|
||||
!SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"),
|
||||
"已决 ACP permission 事件不能继续展示假审批按钮"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("session.info.updated"),
|
||||
"ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("plan.updated"),
|
||||
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("data-page-ai-plan"),
|
||||
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("执行计划 · "),
|
||||
"plan 面板标题应显示执行计划和步数"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9358,6 +9713,18 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("复制访问链接"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function createFileTreeFolder"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("action: 'create_folder'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function fileTreeMenuTargetParentId"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (action === 'new-folder')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("fileTreeCopyPath(detail, trigger)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"var pageRow = closestAction(e.target, '.tree-row[data-shell-mode=\"page\"]');"
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("var action = btn ? btn.getAttribute('data-rust-action') : 'open';"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("var openTrigger = btn || pageRow;"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("var workspaceId = resolveWorkspaceId(openTrigger);"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
@@ -9491,6 +9858,24 @@ mod tests {
|
||||
"return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId)"
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains("return '/onlyoffice?' + params.toString();"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('mode', input.mode || 'view');"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("params.set('mode', input.mode || 'view');"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }"
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("{ action: 'open-edit-mode', icon: 'edit_note', label: '弹窗编辑' }"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("{ action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' }"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function withOfficeEditModeGuard(callback)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-office-edit-mode-requested"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function openEditorAttachmentEditTab(detail)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("void openEditorAttachmentEditTab(detail);"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("forceEditMode ? 'edit' : 'view'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('mode', requestedMode);"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mode: requestedMode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user