fix local markdown attachment regressions

This commit is contained in:
lix-2026
2026-05-29 11:13:05 +08:00
parent 1109e3c0d8
commit cbe789e034
63 changed files with 3249 additions and 1502 deletions
+146 -146
View File
@@ -6,42 +6,42 @@ use crate::routes::command_support::{
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
LocalAccessMode, ensure_local_workspace_access_with_state,
ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_workspace_id_from_root_uri, LocalAccessMode,
};
use crate::routes::query_support::{
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_retired_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
};
use crate::tree_shell::picker_renderer::{
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
};
use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput,
};
use crate::tree_shell::runtime_api::{
TreeShellRuntimeRequest, TreeShellRuntimeResult,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
TreeShellRuntimeResult,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -850,6 +850,18 @@ fn build_tree_shell_html(
exclude_ids: &[String],
dataset: &Value,
) -> String {
let source_kind = projection
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or("convex_workspace");
let root_uri = projection
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or("");
let watch_revision = projection
.get("watchRevision")
.cloned()
.unwrap_or(Value::Null);
let renderer_input = build_tree_shell_renderer_input(
projection,
mode,
@@ -869,15 +881,9 @@ fn build_tree_shell_html(
"channel": channel,
"host": host,
"mode": mode,
"sourceKind": projection
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or("convex_workspace"),
"rootUri": projection
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or(""),
"localWatchRevision": projection.get("watchRevision").cloned().unwrap_or(Value::Null),
"sourceKind": source_kind,
"rootUri": root_uri,
"localWatchRevision": watch_revision.clone(),
"allowRootPick": allow_root_pick,
"excludeIds": exclude_ids,
"rendererInput": renderer_input,
@@ -892,6 +898,22 @@ fn build_tree_shell_html(
});
let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());
let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into());
let tree_live_bootstrap = serde_json::json!({
"schema": "mnote.tree_live_bootstrap.v1",
"disabled": false,
"transport": if source_kind == "local_folder" { "local-folder-events" } else { "tree-live-ws" },
"endpoint": "/api/tree/events",
"wsEndpoint": "/api/realtime/ws",
"workspaceId": workspace_id,
"rootIds": root_node_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| vec![value])
.unwrap_or_default(),
"initialRevision": watch_revision,
});
let tree_live_bootstrap_json =
serde_json::to_string(&tree_live_bootstrap).unwrap_or_else(|_| "{}".into());
let initial_tree_html = match mode {
"page" => render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows: collect_page_tree_render_rows(projection),
@@ -1589,7 +1611,7 @@ fn build_tree_shell_html(
}
</style>
</head>
<body>
<body data-mnote-root-uri="__ROOT_URI__">
<main>
<section class="tree-card">
<div class="tree-card-header">
@@ -1606,7 +1628,9 @@ fn build_tree_shell_html(
</main>
<script id="tree-shell-state" type="application/json">__APP_STATE__</script>
<script type="module" src="/api/mnote-browser-runtime/tree-shell-runtime.js"></script>
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json">__TREE_LIVE_BOOTSTRAP__</script>
<script type="module" src="__TREE_SHELL_RUNTIME_SRC__"></script>
<script type="module" src="__TREE_LIVE_CONTROLLER_SRC__"></script>
</body>
</html>
"##;
@@ -1615,9 +1639,22 @@ fn build_tree_shell_html(
.replace("__WORKSPACE_ID__", &escape_html(workspace_id))
.replace("__ROOT_LABEL__", &escape_html(root_label))
.replace("__ACTIVE_LABEL__", &escape_html(active_label))
.replace("__ROOT_URI__", &escape_html(root_uri))
.replace("__PROJECTION_JSON__", &escape_html(&projection_json))
.replace("__INITIAL_TREE_HTML__", &initial_tree_html)
.replace("__APP_STATE__", &escape_inline_json(&app_state_json))
.replace(
"__TREE_SHELL_RUNTIME_SRC__",
&crate::routes::web_shell::mnote_browser_runtime_src("tree-shell-runtime.js"),
)
.replace(
"__TREE_LIVE_CONTROLLER_SRC__",
&crate::routes::web_shell::mnote_browser_runtime_src("tree-live-controller.js"),
)
.replace(
"__TREE_LIVE_BOOTSTRAP__",
&escape_inline_json(&tree_live_bootstrap_json),
)
}
fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
@@ -2584,10 +2621,10 @@ mod tests {
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
use super::{
TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
collect_filetree_render_rows, create_command_wire,
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG,
};
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
@@ -2752,20 +2789,14 @@ mod tests {
assert!(html.contains("data-rust-action=\"toggle\""));
assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
);
assert!(
TREE_SHELL_RUNTIME_JS
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
@@ -2790,11 +2821,8 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("id=\"tree-shell-state\""));
assert!(
html.contains(
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
)
);
assert!(html
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""));
assert!(
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
@@ -2829,23 +2857,17 @@ mod tests {
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
);
assert!(
TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
assert!(html.contains("tabindex=\""));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
}
@@ -2885,18 +2907,12 @@ mod tests {
assert!(
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
);
assert!(
TREE_SHELL_FILETREE_MENU_RUNTIME_JS
.contains("function buildFileTreeMenuTarget(context")
);
assert!(
TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)")
);
assert!(
TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
);
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
.contains("function buildFileTreeMenuTarget(context"));
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
assert!(
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
);
@@ -2904,10 +2920,8 @@ mod tests {
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
}
#[tokio::test]
@@ -3065,8 +3079,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert!(TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("/api/mnote-browser-runtime/tree-live-controller.js"));
assert!(html.contains("data-mnote-root-uri="));
assert!(TREE_SHELL_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot"));
assert!(!TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload"));
}
@@ -3120,8 +3142,8 @@ mod tests {
}
#[tokio::test]
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint()
{
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
) {
let root =
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
@@ -3236,12 +3258,11 @@ mod tests {
String::from_utf8_lossy(&move_body)
);
assert!(!root.join("重命名页面").exists());
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root
.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
let moved_document_id = move_payload["result"]["documentId"]
.as_str()
@@ -3281,12 +3302,11 @@ mod tests {
.as_str()
.expect("copied document id")
.to_string();
assert!(
root.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists()
);
assert!(root
.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists());
let folder_response = app()
.oneshot(
@@ -3319,13 +3339,12 @@ mod tests {
.expect("response");
assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("重命名页面").exists());
assert!(
root.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root
.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists());
assert!(root.join(".mnote").join("trash-index.json").exists());
assert!(!root.join(".mnote").join("page-ids.json").exists());
@@ -3352,12 +3371,11 @@ mod tests {
"{}",
String::from_utf8_lossy(&restore_body)
);
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root
.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
assert_eq!(
restore_payload["result"]["documentId"].as_str(),
@@ -3573,12 +3591,10 @@ mod tests {
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "local_folder_root_escape");
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("root")
);
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("root"));
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
assert_eq!(
headers
@@ -3747,10 +3763,8 @@ mod tests {
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(
filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
assert!(filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
@@ -3778,10 +3792,8 @@ mod tests {
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(
picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
assert!(picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
}
#[tokio::test]
@@ -4197,13 +4209,11 @@ mod tests {
Some("convex://workspace/ws_demo")
);
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
assert!(
create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command")
);
assert!(create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command"));
let rename_wire = create_command_wire(
&context,
@@ -4395,31 +4405,21 @@ mod tests {
#[test]
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
assert!(
aliases
.iter()
.all(|entry| entry.compat_command.starts_with("documents."))
);
assert!(
aliases
.iter()
.all(|entry| entry.preferred_command.starts_with("tree."))
);
assert!(
aliases
.iter()
.all(|entry| entry.source_kind == "convex_workspace")
);
assert!(
aliases
.iter()
.all(|entry| entry.retained_for.contains("legacy cloud"))
);
assert!(
aliases
.iter()
.all(|entry| entry.retirement_condition.contains("emit tree."))
);
assert!(aliases
.iter()
.all(|entry| entry.compat_command.starts_with("documents.")));
assert!(aliases
.iter()
.all(|entry| entry.preferred_command.starts_with("tree.")));
assert!(aliases
.iter()
.all(|entry| entry.source_kind == "convex_workspace"));
assert!(aliases
.iter()
.all(|entry| entry.retained_for.contains("legacy cloud")));
assert!(aliases
.iter()
.all(|entry| entry.retirement_condition.contains("emit tree.")));
assert!(aliases.iter().any(|entry| {
entry.compat_command == "documents.delete"
&& entry.preferred_command == "tree.node.archive"