fix(tree): stabilize filetree mindmap switching

Open filetree mindmap assets inside the primary document pane so the sidebar root is not rebuilt during rapid mindmap/page switching.

Keep filetree active rows on doc:<documentId> and asset:<mindmapId>, shorten generated mindmap filenames, and preserve legacy index rows only as compatibility input.

Add task438-task445 browser smokes and close the 4-27/4-38/4-39/4-40 tree-domain bug records.
This commit is contained in:
lix-2026
2026-05-16 07:11:06 +08:00
parent 01fdd2a8c3
commit 274779c0d8
15 changed files with 2128 additions and 155 deletions
+530 -130
View File
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order,
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
@@ -443,18 +443,81 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
rows
}
fn short_mindmap_file_name(raw: &str, asset_id: Option<&str>) -> String {
let source = asset_id
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(raw.trim());
let digits = source
.chars()
.rev()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect::<String>();
let suffix = if digits.len() >= 4 {
digits[digits.len().saturating_sub(4)..].to_string()
} else {
source
.trim_start_matches("mindmap")
.trim_start_matches(|ch| ch == '-' || ch == '_')
.chars()
.take(6)
.collect::<String>()
};
if suffix.trim().is_empty() {
"思维导图.json".to_string()
} else {
format!("思维导图-{suffix}.json")
}
}
fn normalize_filetree_mindmap_title(
raw_title: &str,
asset_id: Option<&str>,
icon_kind: &str,
resource_kind: Option<&str>,
) -> String {
let title = raw_title.trim();
let is_mindmap = icon_kind == "mindmap" || resource_kind == Some("mindmap");
let generated = title.starts_with("mindmap-") || title.starts_with("mindmap_");
if is_mindmap && (generated || title.chars().count() > 24) {
return short_mindmap_file_name(title, asset_id);
}
if title.is_empty() {
"无标题".to_string()
} else {
title.to_string()
}
}
pub(crate) fn collect_filetree_render_rows(
projection: &Value,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Vec<FileTreeRenderRow> {
let active_document_id = active_document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let selected_ids = active_document_id
let active_row_id = active_row_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let selected_ids = active_row_id
.as_deref()
.map(|document_id| BTreeSet::from([format!("index:{document_id}")]))
.map(|row_id| BTreeSet::from([row_id.to_string()]))
.or_else(|| {
active_document_id
.as_deref()
.map(|document_id| BTreeSet::from([format!("doc:{document_id}")]))
})
.unwrap_or_default();
let allow_document_fallback = active_row_id.is_none();
let active_document_id_for_fallback = active_document_id
.as_deref()
.filter(|_| allow_document_fallback);
projection
.get("items")
@@ -481,18 +544,47 @@ pub(crate) fn collect_filetree_render_rows(
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let selected = selected_ids.contains(row_id)
|| active_document_id
.as_deref()
.is_some_and(|active_id| document_id.as_deref() == Some(active_id));
|| active_document_id_for_fallback.is_some_and(|active_id| {
document_id.as_deref() == Some(active_id)
&& item
.get("rowKind")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "document")
});
let row_kind = item
.get("rowKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("document")
.to_string();
let asset_id = resource_meta
.and_then(|meta| meta.get("assetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let icon_kind = item
.get("iconHint")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("iconHint"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file")
.to_string();
let raw_title = item
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题");
Some(FileTreeRenderRow {
row_id: row_id.to_string(),
row_kind: item
.get("rowKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("document")
.to_string(),
row_kind: row_kind.clone(),
node_id: node_id.to_string(),
parent_node_id: item
.get("parentNodeId")
@@ -500,13 +592,14 @@ pub(crate) fn collect_filetree_render_rows(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
title: item
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题")
.to_string(),
title: normalize_filetree_mindmap_title(
raw_title,
asset_id.as_deref(),
&icon_kind,
resource_meta
.and_then(|meta| meta.get("resourceKind"))
.and_then(Value::as_str),
),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
@@ -521,25 +614,9 @@ pub(crate) fn collect_filetree_render_rows(
.get("expandedByDefault")
.and_then(Value::as_bool)
.unwrap_or(false),
icon_kind: item
.get("iconHint")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("iconHint"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file")
.to_string(),
icon_kind,
document_id,
asset_id: resource_meta
.and_then(|meta| meta.get("assetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
asset_id,
object_identity: resource_meta
.and_then(|meta| meta.get("objectIdentity"))
.and_then(|value| serde_json::to_string(value).ok()),
@@ -634,6 +711,10 @@ fn build_tree_shell_command_dispatcher(channel: &str) -> TreeShellCommandDispatc
"tree.resource.copy",
"tree.resource.move",
"tree.resource.upload",
"tree.resource.archive",
"tree.resource.restore",
"tree.resource.purge",
"tree.resource.rename",
]
.into_iter()
.map(ToOwned::to_owned)
@@ -659,7 +740,7 @@ fn build_tree_shell_renderer_input(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|document_id| {
FileTreeSelectionState::from_selected(&[format!("index:{document_id}")])
FileTreeSelectionState::from_selected(&[format!("doc:{document_id}")])
})
.unwrap_or_default();
TreeShellRendererInput::filetree(FileTreeRendererInput {
@@ -785,7 +866,7 @@ fn build_tree_shell_html(
.map(ToOwned::to_owned),
}),
"filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput {
rows: collect_filetree_render_rows(projection, active_document_id),
rows: collect_filetree_render_rows(projection, active_document_id, None),
}),
"picker" => render_initial_picker_html(&PickerInitialRenderInput {
rows: collect_picker_render_rows(
@@ -1620,9 +1701,9 @@ fn build_tree_shell_html(
typeof state.commandPath === "string" && state.commandPath.trim()
? state.commandPath.trim()
: "/api/tree/commands";
const mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
const mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
const tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : [];
let mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
let mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
let tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : [];
const mindmapAssetChildren =
state.mindmapAssetChildren && typeof state.mindmapAssetChildren === "object"
? state.mindmapAssetChildren
@@ -1711,78 +1792,87 @@ fn build_tree_shell_html(
};
};
const rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
const normalizedItems = Array.isArray(rawItems)
? rawItems
.map((item) => {
const nodeId = normalizeText(item?.nodeId);
const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
const rowKind = normalizeRowKind(item?.rowKind);
const fallbackRowId =
rowKind === "index"
? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
: rowKind === "asset"
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
: rowKind === "asset_folder"
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
: `doc:${resourceMeta.documentId || nodeId}`;
return {
rowId: normalizeText(item?.rowId, fallbackRowId),
rowKind,
nodeId,
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
iconHint: normalizeText(item?.iconHint),
capabilities: normalizeCapabilities(item?.capabilities),
resourceMeta,
};
})
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
: [];
const itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
const fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
const childrenByParentId = new Map();
const roots = [];
const assetsByDocId = new Map();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
const documentId = normalizeText(asset?.document_id);
const assetId = normalizeText(asset?.id);
if (!documentId || !assetId) return;
const bucket = assetsByDocId.get(documentId) || [];
bucket.push({
id: assetId,
documentId,
assetType: normalizeText(asset?.asset_type, "file"),
fileName: normalizeText(asset?.file_name, "附件"),
storagePath: normalizeText(asset?.storage_path),
});
assetsByDocId.set(documentId, bucket);
});
const compareItems = (left, right) => {
const byPosition = left.position - right.position;
if (byPosition !== 0) return byPosition;
return left.title.localeCompare(right.title, "zh-CN");
};
normalizedItems.forEach((item) => {
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
if (!parentId) {
roots.push(item);
return;
}
const bucket = childrenByParentId.get(parentId) || [];
bucket.push(item);
childrenByParentId.set(parentId, bucket);
});
const normalizeTreeItems = (items) =>
Array.isArray(items)
? items
.map((item) => {
const nodeId = normalizeText(item?.nodeId);
const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
const rowKind = normalizeRowKind(item?.rowKind);
const fallbackRowId =
rowKind === "index"
? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
: rowKind === "asset"
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
: rowKind === "asset_folder"
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
: `doc:${resourceMeta.documentId || nodeId}`;
return {
rowId: normalizeText(item?.rowId, fallbackRowId),
rowKind,
nodeId,
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
iconHint: normalizeText(item?.iconHint),
capabilities: normalizeCapabilities(item?.capabilities),
resourceMeta,
};
})
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
: [];
roots.sort(compareItems);
childrenByParentId.forEach((bucket) => bucket.sort(compareItems));
let rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
let normalizedItems = normalizeTreeItems(rawItems);
let itemById = new Map();
let fileTreeRowById = new Map();
let childrenByParentId = new Map();
let roots = [];
let assetsByDocId = new Map();
const rebuildTreeIndexes = () => {
itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
childrenByParentId = new Map();
roots = [];
assetsByDocId = new Map();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
const documentId = normalizeText(asset?.document_id);
const assetId = normalizeText(asset?.id);
if (!documentId || !assetId) return;
const bucket = assetsByDocId.get(documentId) || [];
bucket.push({
id: assetId,
documentId,
assetType: normalizeText(asset?.asset_type, "file"),
fileName: normalizeText(asset?.file_name, "附件"),
storagePath: normalizeText(asset?.storage_path),
});
assetsByDocId.set(documentId, bucket);
});
normalizedItems.forEach((item) => {
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
if (!parentId) {
roots.push(item);
return;
}
const bucket = childrenByParentId.get(parentId) || [];
bucket.push(item);
childrenByParentId.set(parentId, bucket);
});
roots.sort(compareItems);
childrenByParentId.forEach((bucket) => bucket.sort(compareItems));
};
rebuildTreeIndexes();
const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);
const expanded = new Set(
@@ -1840,10 +1930,10 @@ fn build_tree_shell_html(
let selectedFileTreeRowIds = new Set(
rendererSelectedFileTreeRowIds.length > 0
? rendererSelectedFileTreeRowIds
: currentActiveDocumentId ? [`index:${currentActiveDocumentId}`] : []
: currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`] : []
);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `index:${currentActiveDocumentId}` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `index:${currentActiveDocumentId}` : null);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let visibleFileTreeRowIds = [];
let draggingPageNodeId = "";
let activePageDropNodeId = null;
@@ -2792,7 +2882,7 @@ fn build_tree_shell_html(
const rowIds = resolveFileTreeActionRowIds(rowId);
const deletableItems = rowIds
.map((id) => fileTreeRowById.get(id))
.filter((item) => Boolean(getFileTreeRowDocumentId(item)));
.filter((item) => Boolean(getFileTreeRowDocumentId(item)) || (sourceKind === "local_folder" && item?.rowKind === "asset"));
if (deletableItems.length === 0) {
setLastAction("当前选择没有可删除的页面或 Markdown 文件", "error");
return false;
@@ -2806,7 +2896,7 @@ fn build_tree_shell_html(
await sendCommand({
action: "delete",
workspaceId,
documentId: getFileTreeRowDocumentId(item),
documentId: getFileTreeRowDocumentId(item) || item.rowId,
});
}
scheduleRefresh();
@@ -2977,16 +3067,62 @@ fn build_tree_shell_html(
}
};
const parseTreeShellStateFromHtml = (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const nextStateElement = doc.getElementById("tree-shell-state");
if (!nextStateElement) return null;
try {
return JSON.parse(nextStateElement.textContent || "{}");
} catch {
return null;
}
};
const applyTreeShellStateSnapshot = (nextState, options = {}) => {
if (!nextState || typeof nextState !== "object") return false;
mediaAssets = Array.isArray(nextState.mediaAssets) ? nextState.mediaAssets : [];
mindmapAssets = Array.isArray(nextState.mindmapAssets) ? nextState.mindmapAssets : [];
tableAssets = Array.isArray(nextState.tableAssets) ? nextState.tableAssets : [];
rawItems = Array.isArray(nextState.items) ? nextState.items : [];
normalizedItems = normalizeTreeItems(rawItems);
rebuildTreeIndexes();
if (currentActiveDocumentId && !itemById.has(currentActiveDocumentId)) {
currentActiveDocumentId = roots[0]?.nodeId || "";
}
if (currentFocusedDocumentId && !itemById.has(currentFocusedDocumentId)) {
currentFocusedDocumentId = currentActiveDocumentId;
}
normalizedItems
.filter((item) => item.childCount > 0 && item.expandedByDefault)
.forEach((item) => expanded.add(item.nodeId));
focusedNodeId = resolveFocusedNodeIdFromHostState();
renderTree();
if (mode === "filetree") {
emitFileTreeSelectionChange();
}
const renameRowId = normalizeText(options.renameRowId);
if (renameRowId) {
window.setTimeout(() => {
if (fileTreeRowById.has(renameRowId)) {
beginInlineRename("filetree", renameRowId);
}
}, 80);
}
return true;
};
const refreshLocalFolderSnapshot = async (options = {}) => {
const response = await fetch(window.location.href, {
headers: { "accept": "text/html" },
});
if (!response.ok) return false;
const nextState = parseTreeShellStateFromHtml(await response.text());
return applyTreeShellStateSnapshot(nextState, options);
};
const scheduleRefresh = (options = {}) => {
window.setTimeout(() => {
const renameRowId = normalizeText(options.renameRowId);
if (renameRowId) {
const url = new URL(window.location.href);
url.searchParams.set("renameRowId", renameRowId);
window.location.assign(url.toString());
return;
}
window.location.reload();
void refreshLocalFolderSnapshot(options);
}, 80);
};
@@ -3103,7 +3239,8 @@ fn build_tree_shell_html(
const refreshFromLocalWatch = () => {
if (localWatchRefreshTimer) return;
localWatchRefreshTimer = window.setTimeout(() => {
window.location.reload();
localWatchRefreshTimer = 0;
void refreshLocalFolderSnapshot();
}, 180);
};
const pollLocalFolderRevision = async () => {
@@ -4021,7 +4158,8 @@ fn build_tree_shell_html(
const canDelete =
rowKind === "markdown" ||
rowKind === "document" ||
rowKind === "index";
rowKind === "index" ||
(localSource && rowKind === "asset");
if (rowKind === "root") {
return [
@@ -4193,7 +4331,7 @@ fn build_tree_shell_html(
return result;
}
if (kind === "refresh") {
window.location.reload();
await refreshLocalFolderSnapshot();
return;
}
if (kind === "collapseAll") {
@@ -5965,6 +6103,66 @@ pub async fn local_folder_watch(
))
}
pub async fn filetree_drop_preflight(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let workspace_id = payload
.get("workspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone())
.ok_or_else(|| {
WebError::bad_request_code(
"filetree_drop_preflight_workspace_missing",
"缺少 workspaceId",
)
.with_context(&context)
})?;
let target_document_id = payload
.get("targetDocumentId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let envelope_context = TreeCommandEnvelopeContext::default();
let command = RuntimeCommandEnvelopeWire {
name: "tree.filetree.drop.preflight".into(),
command_id: format!("filetree_drop_preflight_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: build_workspace_source_wire(&context, &workspace_id, &envelope_context),
target: Some(build_tree_target(&workspace_id, target_document_id, None)),
payload,
preflight_data: None,
reason: Some("filetree-drop-preflight tree.filetree.drop.preflight".into()),
refs: vec!["file-tree-shell".into()],
dry_run: true,
validate_only: true,
};
let plan = build_runtime_command_plan(&context, Some(&workspace_id), command)?;
let file_tree_drop_plan = plan
.args_json
.get("fileTreeDropPlan")
.cloned()
.ok_or_else(|| {
WebError::internal("filetree drop preflight 未返回计划").with_context(&context)
})?;
Ok((
StatusCode::OK,
Json(json!({
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"plan": file_tree_drop_plan,
})),
))
}
pub async fn tree_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -6721,7 +6919,10 @@ pub async fn reduce_tree_shell_runtime(
#[cfg(test)]
mod tests {
use super::{create_command_wire, TreeCommandEnvelopeContext, TreeCommandRequest};
use super::{
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
TreeCommandRequest,
};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
@@ -6752,6 +6953,61 @@ mod tests {
}))
}
#[test]
fn filetree_rows_select_active_mindmap_asset_in_object_shell() {
let projection = serde_json::json!({
"items": [
{
"rowId": "doc:doc_1",
"rowKind": "document",
"nodeId": "doc_1",
"title": "页面.md",
"resourceMeta": {
"documentId": "doc_1",
"objectIdentity": {
"objectKind": "page",
"documentId": "doc_1",
"assetId": null
}
}
},
{
"rowId": "asset:mind_1",
"rowKind": "asset",
"nodeId": "asset:mind_1",
"parentNodeId": "doc_1",
"title": "思维导图.json",
"iconHint": "mindmap",
"resourceMeta": {
"documentId": "doc_1",
"assetId": "mind_1",
"objectIdentity": {
"objectKind": "mindmap",
"documentId": "doc_1",
"assetId": "mind_1"
}
}
}
]
});
let rows = collect_filetree_render_rows(&projection, Some("doc_1"), Some("asset:mind_1"));
let doc_row = rows
.iter()
.find(|row| row.row_id == "doc:doc_1")
.expect("doc row");
let mindmap_row = rows
.iter()
.find(|row| row.row_id == "asset:mind_1")
.expect("mindmap row");
assert!(
!doc_row.selected,
"对象页应避免把父页面行重新选中,防止 mindmap 文件行点击后闪回父页面"
);
assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中");
}
#[tokio::test]
async fn tree_shell_returns_interactive_html_document() {
let response = app()
@@ -6930,6 +7186,42 @@ mod tests {
assert!(html.contains("data-document-id=\"local-md:README.md\""));
}
#[tokio::test]
async fn local_folder_tree_shell_does_not_reload_page_for_refresh() {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-no-reload-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local folder root");
std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md");
std::fs::write(root.join("asset.txt"), "asset").expect("write local asset");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("/api/tree/local-folder-watch"));
assert!(html.contains("refreshLocalFolderSnapshot"));
assert!(!html.contains("window.location.reload"));
}
#[tokio::test]
async fn tree_shell_page_mode_can_open_local_folder_md_only_snapshot() {
let root = std::env::temp_dir().join(format!(
@@ -7160,6 +7452,115 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index() {
let root =
std::env::temp_dir().join(format!("mnote-local-asset-trash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
let asset_id = "local:asset:docs/photo.png";
let delete_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
let delete_status = delete_response.status();
let delete_body = axum::body::to_bytes(delete_response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(
delete_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&delete_body)
);
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json");
assert_eq!(
delete_payload["result"]["execution"]["canonicalCommand"],
"tree.resource.archive"
);
assert_eq!(
delete_payload["result"]["execution"]["resourceKind"],
"local_file"
);
assert_eq!(
delete_payload["result"]["execution"]["originalFilePath"],
"docs/photo.png"
);
assert!(!root.join("docs").join("photo.png").exists());
assert!(root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(trash_index.contains("local-file:docs/photo.png"));
assert!(trash_index.contains("resourceKind"));
let restore_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(restore_response.status(), StatusCode::OK);
assert!(root.join("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
let delete_again_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(delete_again_response.status(), StatusCode::OK);
let purge_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(purge_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index_after =
std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index after purge");
assert!(!trash_index_after.contains("local-file:docs/photo.png"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() {
let root = std::env::temp_dir().join(format!(
@@ -7233,12 +7634,11 @@ mod tests {
assert!(filetree_html.contains("\"rendererInput\""));
assert!(filetree_html.contains("\"mode\":\"fileTree\""));
assert!(filetree_html.contains("\"filetreeSelection\""));
assert!(filetree_html.contains("\"selectedRowIds\":[\"index:page_root\"]"));
assert!(!filetree_html.contains("\"selectedRowIds\":[\"doc:page_root\""));
assert!(filetree_html.contains("\"focusedRowId\":\"index:page_root\""));
assert!(filetree_html.contains("\"selectedRowIds\":[\"doc:page_root\"]"));
assert!(!filetree_html.contains("\"selectedRowIds\":[\"index:page_root\""));
assert!(filetree_html.contains("\"focusedRowId\":\"doc:page_root\""));
assert!(filetree_html.contains("data-row-id=\"doc:page_root\""));
assert!(filetree_html.contains("data-row-id=\"index:page_root\""));
assert!(filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\""));
assert!(!filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\""));
assert!(filetree_html.contains("\"commandDispatcher\""));
assert!(filetree_html.contains("\"runtimeArtifact\""));
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));