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:
@@ -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\""));
|
||||
|
||||
@@ -131,9 +131,15 @@ pub async fn document_page_shell(
|
||||
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
load_file_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
Some(&document_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
@@ -268,9 +274,8 @@ fn render_hermes_settings_config_script() -> String {
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(env_or_dotenv)
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()) else {
|
||||
return String::new();
|
||||
};
|
||||
let settings_url = format!("{base_url}/hermes/settings");
|
||||
@@ -438,6 +443,11 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
});
|
||||
};
|
||||
|
||||
const fileTreePageTitle = (value) => {
|
||||
const normalized = String(value || '无标题').trim() || '无标题';
|
||||
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
|
||||
};
|
||||
|
||||
const updateVisibleTitle = (input, title, documentId) => {
|
||||
const pane = input.closest('[data-document-pane="true"]');
|
||||
const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId;
|
||||
@@ -476,7 +486,7 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
const escapedId = cssEscape(documentId);
|
||||
const escapedDocRowId = cssEscape(`doc:${documentId}`);
|
||||
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
||||
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, title);
|
||||
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title));
|
||||
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
|
||||
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
|
||||
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
|
||||
@@ -1204,11 +1214,32 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const documentSessionRegistry = new Map();
|
||||
const localFolderEventRegistry = new Map();
|
||||
const paneViewRegistry = new Map();
|
||||
const mindmapPaneViewRegistry = new Map();
|
||||
let nextViewId = 1;
|
||||
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
|
||||
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
|
||||
const SESSION_RELEASE_DELAY_MS = 1200;
|
||||
|
||||
const unmountMindmapPane = (paneRole) => {
|
||||
const view = mindmapPaneViewRegistry.get(paneRole);
|
||||
if (!view) return;
|
||||
mindmapPaneViewRegistry.delete(paneRole);
|
||||
if (view.mountId != null && view.runtime && typeof view.runtime.unmount === 'function') {
|
||||
try {
|
||||
view.runtime.unmount(view.mountId);
|
||||
} catch (error) {
|
||||
console.warn('mnote mindmap pane unmount failed', error);
|
||||
}
|
||||
}
|
||||
if (view.root instanceof HTMLElement) {
|
||||
view.root.removeAttribute('data-runtime-mount-id');
|
||||
view.root.removeAttribute('data-mnote-object-editor');
|
||||
view.root.removeAttribute('data-mnote-object-identity');
|
||||
view.root.removeAttribute('data-mnote-mindmap-id');
|
||||
view.root.replaceChildren();
|
||||
}
|
||||
};
|
||||
|
||||
const parseLocalFolderEventPayload = (event) => {
|
||||
try {
|
||||
return JSON.parse(String(event?.data || '{}'));
|
||||
@@ -1565,7 +1596,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
if (!response.ok) {
|
||||
if (session.sourceKind === 'local_folder') {
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const nextAggregate = payload?.result;
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
@@ -1627,10 +1663,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (!payload) return;
|
||||
Array.from(channel.sessions.values()).forEach((targetSession) => {
|
||||
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
|
||||
if (documentId && documentId !== targetSession.documentId) return;
|
||||
const eventKind = String(payload.eventKind || '');
|
||||
const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name');
|
||||
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
|
||||
if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return;
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) {
|
||||
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
|
||||
markSessionExternalConflict(targetSession, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
@@ -1897,6 +1936,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
});
|
||||
if (runtimeDescriptor.paneRole === 'primary') {
|
||||
document.body.dataset.documentId = documentId;
|
||||
document.body.dataset.mnoteShell = 'document';
|
||||
delete document.body.dataset.mindmapId;
|
||||
document.title = title;
|
||||
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
||||
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
||||
@@ -1910,12 +1951,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
if (row.getAttribute('data-shell-mode') === 'filetree') {
|
||||
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `index:${documentId}`));
|
||||
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`));
|
||||
} else {
|
||||
row.setAttribute('data-active', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
runtimeDescriptor.root.removeAttribute('data-mnote-object-editor');
|
||||
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
|
||||
runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id');
|
||||
};
|
||||
|
||||
const fetchPageAggregateForPane = async (descriptor) => {
|
||||
@@ -1930,6 +1974,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
|
||||
unmountMindmapPane(paneRole);
|
||||
const runtime = await loadRuntime();
|
||||
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
|
||||
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
|
||||
@@ -2028,6 +2073,123 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
replaceUrlState(url);
|
||||
};
|
||||
|
||||
const parseJsonScriptFromDocument = (doc, id) => {
|
||||
const node = doc?.getElementById?.(id);
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || 'null');
|
||||
} catch (error) {
|
||||
console.warn(`mnote mindmap shell JSON 解析失败: ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMindmapShellBootstrap = async (url) => {
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
headers: { accept: 'text/html' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`mindmap_shell_failed_${response.status}`);
|
||||
const html = await response.text();
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
||||
const bootstrap = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__');
|
||||
if (!bootstrap || typeof bootstrap !== 'object') throw new Error('mindmap_shell_missing_bootstrap');
|
||||
const contract = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_SHELL__') || {};
|
||||
const title = String(bootstrap.title || parsed.querySelector('title')?.textContent || '思维导图').trim() || '思维导图';
|
||||
return { bootstrap, contract, title };
|
||||
};
|
||||
|
||||
const setPrimaryMindmapSelection = (documentId, mindmapId) => {
|
||||
const cssEscape = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape : (value) => String(value).replace(/["\\]/g, '\\$&');
|
||||
const escapedDocId = cssEscape(documentId);
|
||||
const escapedMindmapId = cssEscape(mindmapId);
|
||||
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
row.setAttribute('data-active', 'false');
|
||||
row.setAttribute('data-selected', 'false');
|
||||
});
|
||||
document.querySelectorAll(`.tree-row[data-shell-mode="page"][data-node-id="${escapedDocId}"]`).forEach((row) => {
|
||||
if (row instanceof HTMLElement) row.setAttribute('data-active', 'true');
|
||||
});
|
||||
document.querySelectorAll(`.tree-row[data-shell-mode="filetree"][data-asset-id="${escapedMindmapId}"]`).forEach((row) => {
|
||||
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'true');
|
||||
});
|
||||
};
|
||||
|
||||
const updatePrimaryMindmapChrome = ({ documentId, mindmapId, workspaceId, title, root }) => {
|
||||
const pane = root.closest('[data-document-pane="true"]');
|
||||
if (pane instanceof HTMLElement) {
|
||||
pane.setAttribute('data-pane-document-id', `__mindmap_object__:${documentId}:${mindmapId}`);
|
||||
pane.setAttribute('data-pane-workspace-id', workspaceId || '');
|
||||
pane.setAttribute('data-pane-visible', 'true');
|
||||
pane.hidden = false;
|
||||
}
|
||||
const shell = root.closest('.document-shell');
|
||||
if (shell instanceof HTMLElement) {
|
||||
shell.setAttribute('data-editor-host', 'mindmap_object');
|
||||
shell.setAttribute('data-document-id', documentId);
|
||||
shell.setAttribute('data-workspace-id', workspaceId || '');
|
||||
shell.setAttribute('data-mindmap-id', mindmapId);
|
||||
}
|
||||
document.body.dataset.documentId = documentId;
|
||||
document.body.dataset.mindmapId = mindmapId;
|
||||
document.body.dataset.mnoteShell = 'mindmap';
|
||||
document.title = title;
|
||||
document.querySelectorAll('[data-page-title-input="true"][data-pane-role="primary"]').forEach((node) => {
|
||||
if (!(node instanceof HTMLTextAreaElement)) return;
|
||||
node.value = title;
|
||||
node.setAttribute('data-document-id', documentId);
|
||||
node.setAttribute('data-workspace-id', workspaceId || '');
|
||||
node.setAttribute('data-title-last-saved', title);
|
||||
node.setAttribute('data-title-save-status', 'saved');
|
||||
node.style.height = 'auto';
|
||||
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
|
||||
});
|
||||
document.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
|
||||
if (node instanceof HTMLElement) node.textContent = title;
|
||||
});
|
||||
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
||||
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
||||
root.setAttribute('data-mnote-object-editor', 'mindmap');
|
||||
root.setAttribute('data-mnote-object-identity', `resource:mindmap:${documentId}:${mindmapId}`);
|
||||
root.setAttribute('data-mnote-mindmap-id', mindmapId);
|
||||
setPrimaryMindmapSelection(documentId, mindmapId);
|
||||
};
|
||||
|
||||
const replacePrimaryPaneMindmap = async ({ documentId, mindmapId, workspaceId, url }) => {
|
||||
const targetUrl = url instanceof URL
|
||||
? url
|
||||
: new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
|
||||
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`);
|
||||
const observability = document.querySelector('[data-editor-host-observability][data-pane-role="primary"]');
|
||||
if (!(root instanceof HTMLElement)) throw new Error('primary_pane_root_missing');
|
||||
const runtime = await loadRuntime();
|
||||
const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl);
|
||||
const previousView = paneViewRegistry.get('primary');
|
||||
if (previousView) {
|
||||
unmountEditorViewBinding(previousView);
|
||||
paneViewRegistry.delete('primary');
|
||||
}
|
||||
unmountMindmapPane('primary');
|
||||
if (observability instanceof HTMLElement) {
|
||||
observability.setAttribute('data-editor-host-active', 'mindmap_object');
|
||||
observability.setAttribute('data-editor-host-status', 'mounting');
|
||||
}
|
||||
root.replaceChildren();
|
||||
root.setAttribute('data-runtime-editor-status', 'booting');
|
||||
updatePrimaryMindmapChrome({ documentId, mindmapId, workspaceId, title, root });
|
||||
const mountId = runtime.mount(root, bootstrap);
|
||||
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
root.setAttribute('data-editor-host-kind', 'mindmap_object');
|
||||
if (observability instanceof HTMLElement) {
|
||||
observability.setAttribute('data-editor-host-status', 'mounted');
|
||||
}
|
||||
mindmapPaneViewRegistry.set('primary', { paneRole: 'primary', root, runtime, mountId });
|
||||
pushUrlState(targetUrl);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSessionChange = (session, view, event) => {
|
||||
const payload = normalizeEnvelopePayload(event);
|
||||
if (!payload) return;
|
||||
@@ -2221,6 +2383,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
|
||||
return true;
|
||||
},
|
||||
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
|
||||
const docId = typeof documentId === 'string' ? documentId.trim() : '';
|
||||
const mapId = typeof mindmapId === 'string' ? mindmapId.trim() : '';
|
||||
if (!docId || !mapId) return false;
|
||||
await replacePrimaryPaneMindmap({
|
||||
documentId: docId,
|
||||
mindmapId: mapId,
|
||||
workspaceId: typeof workspaceId === 'string' ? workspaceId.trim() : '',
|
||||
url,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
|
||||
const id = typeof documentId === 'string' ? documentId.trim() : '';
|
||||
if (!id) return false;
|
||||
@@ -2610,6 +2784,7 @@ pub(crate) async fn load_file_tree_html(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
active_document_id: Option<&str>,
|
||||
active_row_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let spec = ProjectionSnapshotSpec {
|
||||
workspace_id,
|
||||
@@ -2654,7 +2829,7 @@ pub(crate) async fn load_file_tree_html(
|
||||
Err(_) => None,
|
||||
};
|
||||
result.map(|(projection, dev_fixture)| {
|
||||
let rows = collect_filetree_render_rows(&projection, active_document_id);
|
||||
let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
|
||||
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
|
||||
mark_dev_fixture_html(html, dev_fixture, "file-tree")
|
||||
})
|
||||
@@ -2687,7 +2862,7 @@ pub(crate) fn render_local_file_tree_html(
|
||||
active_document_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
|
||||
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
|
||||
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, None);
|
||||
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows,
|
||||
}))
|
||||
@@ -2806,6 +2981,15 @@ mod tests {
|
||||
assert!(html.contains(
|
||||
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(html.contains("const fileTreePageTitle = (value) => {"));
|
||||
assert!(html.contains(
|
||||
"return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;"
|
||||
));
|
||||
assert!(html.contains(
|
||||
r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"#
|
||||
));
|
||||
assert!(html.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
|
||||
assert!(!html.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
|
||||
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
|
||||
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
|
||||
assert!(html.contains("data-mnote-action=\"open-page-settings\""));
|
||||
@@ -2815,6 +2999,10 @@ mod tests {
|
||||
assert!(html.contains("data-document-pane=\"true\""));
|
||||
assert!(html.contains("data-pane-role=\"primary\""));
|
||||
assert!(html.contains("data-document-pane-resizer=\"true\""));
|
||||
assert!(html.contains("openPrimaryMindmap"));
|
||||
assert!(html.contains("replacePrimaryPaneMindmap"));
|
||||
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
|
||||
assert!(html.contains("data-mnote-object-identity"));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
@@ -2972,6 +3160,10 @@ mod tests {
|
||||
assert!(html.contains("/api/local-folder/events"));
|
||||
assert!(html.contains("new EventSource(url.toString())"));
|
||||
assert!(html.contains("localFolderEventRegistry"));
|
||||
assert!(html
|
||||
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
|
||||
assert!(html.contains("mayAffectMissingDocument"));
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
assert!(!html.contains(
|
||||
@@ -3012,7 +3204,7 @@ mod tests {
|
||||
let sidebar_html =
|
||||
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
|
||||
let filetree_html =
|
||||
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
|
||||
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
|
||||
let workspace_projection = super::load_workspace_shell_projection(
|
||||
&config,
|
||||
&context,
|
||||
|
||||
Reference in New Issue
Block a user