4-10 树域 Rust 家族化
This commit is contained in:
@@ -380,6 +380,16 @@ fn build_tree_shell_html(
|
|||||||
background: rgba(15, 108, 132, 0.1);
|
background: rgba(15, 108, 132, 0.1);
|
||||||
border-color: rgba(15, 108, 132, 0.24);
|
border-color: rgba(15, 108, 132, 0.24);
|
||||||
}
|
}
|
||||||
|
.tree-row[data-drop-feedback="true"] {
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
border-color: rgba(15, 108, 132, 0.28);
|
||||||
|
}
|
||||||
|
.tree-row[data-draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.tree-row[data-draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
.tree-toggle,
|
.tree-toggle,
|
||||||
.tree-action {
|
.tree-action {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -588,6 +598,13 @@ fn build_tree_shell_html(
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: rgba(37, 99, 235, 0.12);
|
background: rgba(37, 99, 235, 0.12);
|
||||||
}
|
}
|
||||||
|
.tree-row[data-drop-target="true"] {
|
||||||
|
background: rgba(15, 108, 132, 0.14);
|
||||||
|
outline: 1px solid rgba(15, 108, 132, 0.35);
|
||||||
|
}
|
||||||
|
.tree-root[data-drop-target="true"] {
|
||||||
|
background: rgba(15, 108, 132, 0.05);
|
||||||
|
}
|
||||||
.tree-toggle,
|
.tree-toggle,
|
||||||
.tree-action {
|
.tree-action {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -738,6 +755,11 @@ fn build_tree_shell_html(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const state = parseState();
|
const state = parseState();
|
||||||
|
const hostOverride =
|
||||||
|
window.__MNOTE_TREE_SHELL_OVERRIDE__ &&
|
||||||
|
typeof window.__MNOTE_TREE_SHELL_OVERRIDE__ === "object"
|
||||||
|
? window.__MNOTE_TREE_SHELL_OVERRIDE__
|
||||||
|
: {};
|
||||||
const channel =
|
const channel =
|
||||||
typeof state.channel === "string" && state.channel.trim()
|
typeof state.channel === "string" && state.channel.trim()
|
||||||
? state.channel.trim()
|
? state.channel.trim()
|
||||||
@@ -807,17 +829,68 @@ fn build_tree_shell_html(
|
|||||||
return Number.isFinite(value) ? Number(value) : fallback;
|
return Number.isFinite(value) ? Number(value) : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedItems = Array.isArray(state.items)
|
const normalizeRowKind = (value) => {
|
||||||
? state.items
|
const normalized = normalizeText(value).toLowerCase();
|
||||||
.map((item) => ({
|
if (normalized === "index") return "index";
|
||||||
nodeId: normalizeText(item?.nodeId),
|
if (normalized === "asset") return "asset";
|
||||||
parentNodeId: normalizeParent(item?.parentNodeId),
|
if (normalized === "asset_folder") return "asset_folder";
|
||||||
title: normalizeText(item?.title, "无标题"),
|
return "document";
|
||||||
depth: normalizeNumber(item?.depth, 0),
|
};
|
||||||
childCount: normalizeNumber(item?.childCount, 0),
|
|
||||||
position: normalizeNumber(item?.position),
|
const normalizeCapabilities = (value) =>
|
||||||
expandedByDefault: item?.expandedByDefault !== false,
|
Array.isArray(value)
|
||||||
}))
|
? value
|
||||||
|
.map((item) => normalizeText(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizeResourceMeta = (value) => {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return {
|
||||||
|
resourceKind: "",
|
||||||
|
documentId: "",
|
||||||
|
assetId: "",
|
||||||
|
assetKind: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resourceKind: normalizeText(value?.resourceKind),
|
||||||
|
documentId: normalizeText(value?.documentId),
|
||||||
|
assetId: normalizeText(value?.assetId),
|
||||||
|
assetKind: normalizeText(value?.assetKind),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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))
|
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -870,6 +943,12 @@ fn build_tree_shell_html(
|
|||||||
? activeDocumentId
|
? activeDocumentId
|
||||||
: roots[0]?.nodeId || "";
|
: roots[0]?.nodeId || "";
|
||||||
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
|
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
|
||||||
|
let fileTreeAnchorRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
|
||||||
|
let fileTreeFocusedRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
|
||||||
|
let visibleFileTreeRowIds = [];
|
||||||
|
let draggingFileTreeRowIds = [];
|
||||||
|
let activeFileTreeDropRowId = null;
|
||||||
|
let activeFileTreeRootDrop = false;
|
||||||
|
|
||||||
let activeCursor = itemById.get(activeDocumentId) || null;
|
let activeCursor = itemById.get(activeDocumentId) || null;
|
||||||
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
||||||
@@ -938,6 +1017,316 @@ fn build_tree_shell_html(
|
|||||||
window.parent.postMessage(payload, targetOrigin);
|
window.parent.postMessage(payload, targetOrigin);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids";
|
||||||
|
|
||||||
|
const getFileTreeRowDocumentId = (item) => {
|
||||||
|
if (!item) return "";
|
||||||
|
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||||
|
if (item.rowKind === "document") return item.nodeId;
|
||||||
|
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileTreeRowAssetId = (item) => {
|
||||||
|
if (!item) return "";
|
||||||
|
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
||||||
|
if (item.rowKind === "asset") return item.nodeId.replace(/^asset:/, "");
|
||||||
|
if (item.rowKind === "asset_folder") return item.nodeId.replace(/^asset-folder:/, "");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileTreeRowIconKind = (item) => {
|
||||||
|
if (!item) return "file";
|
||||||
|
const iconHint = normalizeText(item.iconHint).toLowerCase();
|
||||||
|
if (iconHint === "mindmap") return "mindmap";
|
||||||
|
if (iconHint === "table") return "table";
|
||||||
|
if (iconHint === "index") return "index";
|
||||||
|
if (iconHint === "page") return "page";
|
||||||
|
if (item.rowKind === "document") return "page";
|
||||||
|
if (item.rowKind === "index") return "index";
|
||||||
|
if (item.rowKind === "asset_folder") return "mindmap";
|
||||||
|
if (item.resourceMeta?.resourceKind === "table") return "table";
|
||||||
|
if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap";
|
||||||
|
return "file";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileTreeRowMetaLabel = (item) => {
|
||||||
|
if (!item) return "";
|
||||||
|
if (item.rowKind === "document") {
|
||||||
|
return `${getFileTreeRowDocumentId(item)} · 页面`;
|
||||||
|
}
|
||||||
|
if (item.rowKind === "index") {
|
||||||
|
return "页面正文";
|
||||||
|
}
|
||||||
|
if (item.rowKind === "asset_folder") {
|
||||||
|
return `${item.resourceMeta?.resourceKind || "mindmap"} · 资源目录`;
|
||||||
|
}
|
||||||
|
return `${item.resourceMeta?.resourceKind || item.resourceMeta?.assetKind || "asset"} · ${getFileTreeRowAssetId(item)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canExpandFileTreeRow = (item) => {
|
||||||
|
if (!item) return false;
|
||||||
|
return item.childCount > 0 || item.capabilities.includes("expand");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileTreeDropTargetFromElement = (element) => {
|
||||||
|
const row = element instanceof Element
|
||||||
|
? element.closest('.tree-row[data-shell-mode="filetree"]')
|
||||||
|
: null;
|
||||||
|
if (!(row instanceof HTMLElement)) {
|
||||||
|
return {
|
||||||
|
rowId: null,
|
||||||
|
rowKind: "root",
|
||||||
|
documentId: null,
|
||||||
|
assetId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rowId: normalizeText(row.dataset.rowId) || null,
|
||||||
|
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
||||||
|
documentId: normalizeText(row.dataset.documentId) || null,
|
||||||
|
assetId: normalizeText(row.dataset.assetId) || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFileTreeDropFeedback = () => {
|
||||||
|
if (activeFileTreeDropRowId) {
|
||||||
|
const previousRow = appElement.querySelector(
|
||||||
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
|
||||||
|
);
|
||||||
|
if (previousRow instanceof HTMLElement) {
|
||||||
|
previousRow.dataset.dropTarget = "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeFileTreeDropRowId = null;
|
||||||
|
activeFileTreeRootDrop = false;
|
||||||
|
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
element.dataset.dropTarget = "false";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFileTreeDropFeedback = (target) => {
|
||||||
|
const nextRowId = target?.rowId || null;
|
||||||
|
if (activeFileTreeDropRowId && activeFileTreeDropRowId !== nextRowId) {
|
||||||
|
const previousRow = appElement.querySelector(
|
||||||
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
|
||||||
|
);
|
||||||
|
if (previousRow instanceof HTMLElement) {
|
||||||
|
previousRow.dataset.dropTarget = "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextRowId) {
|
||||||
|
const nextRow = appElement.querySelector(
|
||||||
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${nextRowId}"]`,
|
||||||
|
);
|
||||||
|
if (nextRow instanceof HTMLElement) {
|
||||||
|
nextRow.dataset.dropTarget = "true";
|
||||||
|
}
|
||||||
|
activeFileTreeDropRowId = nextRowId;
|
||||||
|
activeFileTreeRootDrop = false;
|
||||||
|
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
element.dataset.dropTarget = "false";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeFileTreeDropRowId = null;
|
||||||
|
const root = appElement.querySelector(".tree-root");
|
||||||
|
if (root instanceof HTMLElement) {
|
||||||
|
root.dataset.dropTarget = "true";
|
||||||
|
}
|
||||||
|
activeFileTreeRootDrop = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFileTreeDropFeedback = (rowId) => {
|
||||||
|
if (rowId) {
|
||||||
|
setFileTreeDropFeedback({ rowId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferDefaultFileTreeDropDocumentId = () => {
|
||||||
|
const candidateRowIds = [
|
||||||
|
fileTreeFocusedRowId,
|
||||||
|
fileTreeAnchorRowId,
|
||||||
|
...Array.from(selectedFileTreeRowIds),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const rowId of candidateRowIds) {
|
||||||
|
if (!rowId) continue;
|
||||||
|
const item = fileTreeRowById.get(rowId);
|
||||||
|
const documentId = getFileTreeRowDocumentId(item);
|
||||||
|
if (documentId) {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstDocRowId = visibleFileTreeRowIds.find((rowId) => rowId.startsWith("doc:"));
|
||||||
|
const firstDocItem = firstDocRowId ? fileTreeRowById.get(firstDocRowId) : null;
|
||||||
|
return getFileTreeRowDocumentId(firstDocItem) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveFileTreeDraggedRowIds = (rowId) => {
|
||||||
|
if (!rowId) return [];
|
||||||
|
if (selectedFileTreeRowIds.has(rowId)) {
|
||||||
|
return Array.from(selectedFileTreeRowIds);
|
||||||
|
}
|
||||||
|
return [rowId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExternalFileDrag = (event) => {
|
||||||
|
const types = event.dataTransfer?.types;
|
||||||
|
return Array.isArray(types)
|
||||||
|
? types.includes("Files")
|
||||||
|
: types instanceof DOMStringList
|
||||||
|
? types.contains("Files")
|
||||||
|
: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInternalFileTreeDrag = (event) => {
|
||||||
|
const types = event.dataTransfer?.types;
|
||||||
|
return Array.isArray(types)
|
||||||
|
? types.includes(FILETREE_DRAG_MIME)
|
||||||
|
: types instanceof DOMStringList
|
||||||
|
? types.contains(FILETREE_DRAG_MIME)
|
||||||
|
: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitFileTreeSelectionChange = () => {
|
||||||
|
if (mode !== "filetree") return;
|
||||||
|
postToHost("tree.filetree.selection.changed", {
|
||||||
|
selectedRowIds: Array.from(selectedFileTreeRowIds),
|
||||||
|
anchorRowId: fileTreeAnchorRowId,
|
||||||
|
focusedRowId: fileTreeFocusedRowId,
|
||||||
|
payload: {
|
||||||
|
selectedRowIds: Array.from(selectedFileTreeRowIds),
|
||||||
|
anchorRowId: fileTreeAnchorRowId,
|
||||||
|
focusedRowId: fileTreeFocusedRowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileTreeRangeRowIds = (fromId, toId) => {
|
||||||
|
const fromIndex = visibleFileTreeRowIds.indexOf(fromId);
|
||||||
|
const toIndex = visibleFileTreeRowIds.indexOf(toId);
|
||||||
|
if (fromIndex < 0 || toIndex < 0) {
|
||||||
|
return [toId];
|
||||||
|
}
|
||||||
|
const lo = Math.min(fromIndex, toIndex);
|
||||||
|
const hi = Math.max(fromIndex, toIndex);
|
||||||
|
return visibleFileTreeRowIds.slice(lo, hi + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitFileTreeSelection = (nextSelection) => {
|
||||||
|
selectedFileTreeRowIds = nextSelection.selectedRowIds;
|
||||||
|
fileTreeAnchorRowId = nextSelection.anchorRowId;
|
||||||
|
fileTreeFocusedRowId = nextSelection.focusedRowId;
|
||||||
|
emitFileTreeSelectionChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFileTreeRow = (rowId, modifiers = {}) => {
|
||||||
|
const shiftKey = modifiers.shiftKey === true;
|
||||||
|
const metaKey = modifiers.metaKey === true;
|
||||||
|
const ctrlKey = modifiers.ctrlKey === true;
|
||||||
|
const toggleSelection = metaKey || ctrlKey;
|
||||||
|
|
||||||
|
if (shiftKey) {
|
||||||
|
const anchor = fileTreeAnchorRowId || fileTreeFocusedRowId || rowId;
|
||||||
|
const nextSelection = toggleSelection ? new Set(selectedFileTreeRowIds) : new Set();
|
||||||
|
getFileTreeRangeRowIds(anchor, rowId).forEach((id) => {
|
||||||
|
nextSelection.add(id);
|
||||||
|
});
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: nextSelection,
|
||||||
|
anchorRowId: fileTreeAnchorRowId || anchor,
|
||||||
|
focusedRowId: rowId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleSelection) {
|
||||||
|
const nextSelection = new Set(selectedFileTreeRowIds);
|
||||||
|
if (nextSelection.has(rowId)) {
|
||||||
|
nextSelection.delete(rowId);
|
||||||
|
} else {
|
||||||
|
nextSelection.add(rowId);
|
||||||
|
}
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: nextSelection,
|
||||||
|
anchorRowId: rowId,
|
||||||
|
focusedRowId: rowId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: new Set([rowId]),
|
||||||
|
anchorRowId: rowId,
|
||||||
|
focusedRowId: rowId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFileTreeContextRow = (rowId) => {
|
||||||
|
if (selectedFileTreeRowIds.has(rowId)) {
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: new Set(selectedFileTreeRowIds),
|
||||||
|
anchorRowId: fileTreeAnchorRowId,
|
||||||
|
focusedRowId: rowId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: new Set([rowId]),
|
||||||
|
anchorRowId: rowId,
|
||||||
|
focusedRowId: rowId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFileTreeSelection = () => {
|
||||||
|
if (mode !== "filetree") return;
|
||||||
|
if (
|
||||||
|
selectedFileTreeRowIds.size === 0 &&
|
||||||
|
!fileTreeAnchorRowId &&
|
||||||
|
!fileTreeFocusedRowId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: new Set(),
|
||||||
|
anchorRowId: null,
|
||||||
|
focusedRowId: null,
|
||||||
|
});
|
||||||
|
renderTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFileTreeSelectionForVisibleRows = () => {
|
||||||
|
if (mode !== "filetree") return;
|
||||||
|
const visibleSet = new Set(visibleFileTreeRowIds);
|
||||||
|
const nextSelectedRowIds = Array.from(selectedFileTreeRowIds).filter((rowId) => visibleSet.has(rowId));
|
||||||
|
const nextAnchorRowId =
|
||||||
|
fileTreeAnchorRowId && visibleSet.has(fileTreeAnchorRowId) ? fileTreeAnchorRowId : null;
|
||||||
|
const nextFocusedRowId =
|
||||||
|
fileTreeFocusedRowId && visibleSet.has(fileTreeFocusedRowId) ? fileTreeFocusedRowId : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextSelectedRowIds.length === selectedFileTreeRowIds.size &&
|
||||||
|
nextAnchorRowId === fileTreeAnchorRowId &&
|
||||||
|
nextFocusedRowId === fileTreeFocusedRowId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitFileTreeSelection({
|
||||||
|
selectedRowIds: new Set(nextSelectedRowIds),
|
||||||
|
anchorRowId: nextAnchorRowId,
|
||||||
|
focusedRowId: nextFocusedRowId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleRefresh = () => {
|
const scheduleRefresh = () => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -1142,6 +1531,30 @@ fn build_tree_shell_html(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readFileTreeInternalDropPayload = (event) => {
|
||||||
|
const raw =
|
||||||
|
event.dataTransfer?.getData("application/x-mnote-file-tree") ||
|
||||||
|
event.dataTransfer?.getData("text/plain") ||
|
||||||
|
"";
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
payload?.type !== "mnote-file-tree-dnd" ||
|
||||||
|
payload.version !== 1 ||
|
||||||
|
!Array.isArray(payload.rowIds)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rowIds = payload.rowIds
|
||||||
|
.map((value) => normalizeText(value))
|
||||||
|
.filter(Boolean);
|
||||||
|
return rowIds.length > 0 ? rowIds : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRowKeyDown = (event, item) => {
|
const handleRowKeyDown = (event, item) => {
|
||||||
if (mode !== "page") return;
|
if (mode !== "page") return;
|
||||||
const visible = getVisiblePageItems();
|
const visible = getVisiblePageItems();
|
||||||
@@ -1506,163 +1919,148 @@ fn build_tree_shell_html(
|
|||||||
|
|
||||||
const renderFileTree = () => {
|
const renderFileTree = () => {
|
||||||
appElement.innerHTML = "";
|
appElement.innerHTML = "";
|
||||||
|
visibleFileTreeRowIds = [];
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
|
||||||
const fileRoot = document.createElement("div");
|
const fileRoot = document.createElement("div");
|
||||||
fileRoot.className = "tree-root";
|
fileRoot.className = "tree-root";
|
||||||
fileRoot.setAttribute("role", "tree");
|
fileRoot.setAttribute("role", "tree");
|
||||||
|
fileRoot.dataset.dropTarget = String(activeFileTreeRootDrop);
|
||||||
|
fileRoot.addEventListener("mousedown", (event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
clearFileTreeSelection();
|
||||||
|
});
|
||||||
|
|
||||||
const appendAssetRow = (container, asset, depth) => {
|
const openFileTreeItem = (item) => {
|
||||||
const row = document.createElement("div");
|
const documentId = getFileTreeRowDocumentId(item);
|
||||||
row.className = "tree-row";
|
const assetId = getFileTreeRowAssetId(item);
|
||||||
row.style.marginLeft = `${depth * 22}px`;
|
if (item.rowKind === "document" || item.rowKind === "index") {
|
||||||
row.setAttribute("data-testid", "filetree-asset-row");
|
handleNavigate(documentId || item.nodeId);
|
||||||
row.dataset.assetId = asset.id;
|
return;
|
||||||
row.dataset.rowId = `asset:${asset.id}`;
|
}
|
||||||
row.dataset.rowKind = "asset";
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||||
row.dataset.shellMode = "filetree";
|
postToHost("tree.asset.open", {
|
||||||
row.dataset.selected = String(selectedFileTreeRowIds.has(`asset:${asset.id}`));
|
documentId: documentId || null,
|
||||||
row.tabIndex = 0;
|
assetId: assetId || null,
|
||||||
row.setAttribute("role", "treeitem");
|
target: { documentId: documentId || null },
|
||||||
row.setAttribute("aria-level", String(depth + 1));
|
payload: {
|
||||||
row.addEventListener("click", () => {
|
documentId: documentId || null,
|
||||||
selectedFileTreeRowIds = new Set([`asset:${asset.id}`]);
|
assetId: assetId || null,
|
||||||
renderTree();
|
rowId: item.rowId,
|
||||||
});
|
rowKind: item.rowKind,
|
||||||
row.addEventListener("contextmenu", (event) => {
|
},
|
||||||
event.preventDefault();
|
|
||||||
selectedFileTreeRowIds = new Set([`asset:${asset.id}`]);
|
|
||||||
renderTree();
|
|
||||||
openFileTreeContextMenu({
|
|
||||||
documentId: asset.documentId,
|
|
||||||
assetId: asset.id,
|
|
||||||
rowId: `asset:${asset.id}`,
|
|
||||||
rowKind: "asset",
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const spacer = document.createElement("div");
|
|
||||||
spacer.className = "tree-spacer";
|
|
||||||
row.appendChild(spacer);
|
|
||||||
row.appendChild(
|
|
||||||
createKindBadge(
|
|
||||||
asset.assetType === "mindmap"
|
|
||||||
? "mindmap"
|
|
||||||
: asset.assetType === "luckysheet"
|
|
||||||
? "table"
|
|
||||||
: "file",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "tree-link";
|
|
||||||
button.setAttribute("data-testid", "filetree-asset-open");
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
setLastAction(`准备打开附件 ${asset.id}`);
|
|
||||||
postToHost("tree.asset.open", {
|
|
||||||
assetId: asset.id,
|
|
||||||
documentId: asset.documentId,
|
|
||||||
target: { documentId: asset.documentId },
|
|
||||||
payload: { documentId: asset.documentId, assetId: asset.id },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const title = document.createElement("span");
|
|
||||||
title.className = "tree-link-title";
|
|
||||||
title.textContent = asset.fileName;
|
|
||||||
const meta = document.createElement("span");
|
|
||||||
meta.className = "tree-link-meta";
|
|
||||||
meta.textContent = `${asset.assetType} · ${asset.id}`;
|
|
||||||
button.appendChild(title);
|
|
||||||
button.appendChild(meta);
|
|
||||||
row.appendChild(button);
|
|
||||||
const actions = document.createElement("div");
|
|
||||||
actions.className = "tree-actions";
|
|
||||||
actions.appendChild(
|
|
||||||
createActionButton(
|
|
||||||
ICONS.more,
|
|
||||||
"filetree-action-menu",
|
|
||||||
`打开 ${asset.fileName} 的更多操作`,
|
|
||||||
(button) => {
|
|
||||||
const center = getElementCenter(button);
|
|
||||||
openFileTreeContextMenu({
|
|
||||||
documentId: asset.documentId,
|
|
||||||
assetId: asset.id,
|
|
||||||
rowId: `asset:${asset.id}`,
|
|
||||||
rowKind: "asset",
|
|
||||||
clientX: center.x,
|
|
||||||
clientY: center.y,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
row.appendChild(actions);
|
|
||||||
container.appendChild(row);
|
|
||||||
|
|
||||||
const childIds = Array.isArray(mindmapAssetChildren[asset.id]) ? mindmapAssetChildren[asset.id] : [];
|
|
||||||
if (childIds.length === 0) return;
|
|
||||||
childIds.forEach((childId) => {
|
|
||||||
const child = mediaAssets.find((entry) => normalizeText(entry?.id) === childId);
|
|
||||||
if (!child) return;
|
|
||||||
appendAssetRow(
|
|
||||||
container,
|
|
||||||
{
|
|
||||||
id: normalizeText(child?.id),
|
|
||||||
documentId: normalizeText(child?.document_id),
|
|
||||||
assetType: normalizeText(child?.asset_type, "file"),
|
|
||||||
fileName: normalizeText(child?.file_name, "附件"),
|
|
||||||
storagePath: normalizeText(child?.storage_path),
|
|
||||||
},
|
|
||||||
depth + 1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendDoc = (item, depth) => {
|
const postFileTreeDropToHost = (type, target, extra = {}) => {
|
||||||
const wrapper = document.createElement("div");
|
postToHost(type, {
|
||||||
wrapper.className = "tree-node";
|
workspaceId,
|
||||||
wrapper.dataset.nodeId = item.nodeId;
|
rowId: target.rowId,
|
||||||
|
rowKind: target.rowKind,
|
||||||
|
targetRowId: target.rowId,
|
||||||
|
targetRowKind: target.rowKind,
|
||||||
|
documentId: target.documentId,
|
||||||
|
assetId: target.assetId,
|
||||||
|
...extra,
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
rowId: target.rowId,
|
||||||
|
rowKind: target.rowKind,
|
||||||
|
targetRowId: target.rowId,
|
||||||
|
targetRowKind: target.rowKind,
|
||||||
|
documentId: target.documentId,
|
||||||
|
assetId: target.assetId,
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachFileTreeDragSource = (row, item) => {
|
||||||
|
row.draggable = true;
|
||||||
|
row.addEventListener("dragstart", (event) => {
|
||||||
|
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
|
||||||
|
draggingFileTreeRowIds = rowIds;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: "mnote-file-tree-dnd",
|
||||||
|
version: 1,
|
||||||
|
rowIds,
|
||||||
|
});
|
||||||
|
event.dataTransfer.effectAllowed = "copyMove";
|
||||||
|
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
||||||
|
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
|
||||||
|
event.dataTransfer.setData("text/plain", payload);
|
||||||
|
}
|
||||||
|
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
|
||||||
|
});
|
||||||
|
row.addEventListener("dragend", () => {
|
||||||
|
draggingFileTreeRowIds = [];
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendFileTreeRow = (container, item) => {
|
||||||
|
const children = getSiblings(item.nodeId);
|
||||||
|
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
||||||
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||||
|
const assetId = getFileTreeRowAssetId(item) || null;
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "tree-row";
|
row.className = "tree-row";
|
||||||
row.style.marginLeft = `${depth * 22}px`;
|
row.style.marginLeft = `${item.depth * 22}px`;
|
||||||
row.dataset.active = String(item.nodeId === activeDocumentId);
|
row.dataset.active = String(item.rowKind === "document" && documentId === activeDocumentId);
|
||||||
row.setAttribute("data-testid", "filetree-doc-row");
|
row.dataset.nodeId = item.nodeId;
|
||||||
row.dataset.rowId = `doc:${item.nodeId}`;
|
row.dataset.rowId = item.rowId;
|
||||||
row.dataset.rowKind = "doc";
|
row.dataset.rowKind = item.rowKind;
|
||||||
|
row.dataset.documentId = documentId || "";
|
||||||
|
row.dataset.assetId = assetId || "";
|
||||||
row.dataset.shellMode = "filetree";
|
row.dataset.shellMode = "filetree";
|
||||||
row.dataset.selected = String(selectedFileTreeRowIds.has(`doc:${item.nodeId}`));
|
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||||
|
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
||||||
|
row.setAttribute(
|
||||||
|
"data-testid",
|
||||||
|
item.rowKind === "document"
|
||||||
|
? "filetree-doc-row"
|
||||||
|
: item.rowKind === "index"
|
||||||
|
? "filetree-index-row"
|
||||||
|
: "filetree-asset-row",
|
||||||
|
);
|
||||||
row.tabIndex = 0;
|
row.tabIndex = 0;
|
||||||
row.setAttribute("role", "treeitem");
|
row.setAttribute("role", "treeitem");
|
||||||
row.setAttribute("aria-level", String(depth + 1));
|
row.setAttribute("aria-level", String(item.depth + 1));
|
||||||
const children = getSiblings(item.nodeId);
|
|
||||||
const assets = assetsByDocId.get(item.nodeId) || [];
|
|
||||||
const hasBranches = children.length > 0 || assets.length > 0;
|
|
||||||
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
|
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
|
||||||
row.addEventListener("click", () => {
|
visibleFileTreeRowIds.push(item.rowId);
|
||||||
selectedFileTreeRowIds = new Set([`doc:${item.nodeId}`]);
|
|
||||||
|
row.addEventListener("click", (event) => {
|
||||||
|
selectFileTreeRow(item.rowId, event);
|
||||||
renderTree();
|
renderTree();
|
||||||
});
|
});
|
||||||
|
row.addEventListener("dblclick", () => {
|
||||||
|
openFileTreeItem(item);
|
||||||
|
});
|
||||||
row.addEventListener("contextmenu", (event) => {
|
row.addEventListener("contextmenu", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
selectedFileTreeRowIds = new Set([`doc:${item.nodeId}`]);
|
selectFileTreeContextRow(item.rowId);
|
||||||
renderTree();
|
renderTree();
|
||||||
openFileTreeContextMenu({
|
openFileTreeContextMenu({
|
||||||
documentId: item.nodeId,
|
documentId,
|
||||||
rowId: `doc:${item.nodeId}`,
|
assetId,
|
||||||
rowKind: "doc",
|
rowId: item.rowId,
|
||||||
|
rowKind: item.rowKind,
|
||||||
clientX: event.clientX,
|
clientX: event.clientX,
|
||||||
clientY: event.clientY,
|
clientY: event.clientY,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
attachFileTreeDragSource(row, item);
|
||||||
|
|
||||||
if (hasBranches) {
|
if (hasBranches) {
|
||||||
const toggleButton = document.createElement("button");
|
const toggleButton = document.createElement("button");
|
||||||
toggleButton.type = "button";
|
toggleButton.type = "button";
|
||||||
toggleButton.className = "tree-toggle";
|
toggleButton.className = "tree-toggle";
|
||||||
toggleButton.setAttribute("aria-label", `${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`);
|
toggleButton.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
|
||||||
|
);
|
||||||
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
||||||
toggleButton.addEventListener("click", (event) => {
|
toggleButton.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1675,21 +2073,27 @@ fn build_tree_shell_html(
|
|||||||
row.appendChild(spacer);
|
row.appendChild(spacer);
|
||||||
}
|
}
|
||||||
|
|
||||||
row.appendChild(createKindBadge("page"));
|
row.appendChild(createKindBadge(getFileTreeRowIconKind(item)));
|
||||||
|
|
||||||
const linkButton = document.createElement("button");
|
const linkButton = document.createElement("button");
|
||||||
linkButton.type = "button";
|
linkButton.type = "button";
|
||||||
linkButton.className = "tree-link";
|
linkButton.className = "tree-link";
|
||||||
linkButton.setAttribute("data-testid", "filetree-doc-open");
|
if (item.rowKind === "document") {
|
||||||
linkButton.addEventListener("click", () => handleNavigate(item.nodeId));
|
linkButton.setAttribute("data-testid", "filetree-doc-open");
|
||||||
|
} else if (item.rowKind === "asset" || item.rowKind === "asset_folder") {
|
||||||
|
linkButton.setAttribute("data-testid", "filetree-asset-open");
|
||||||
|
}
|
||||||
|
linkButton.addEventListener("click", () => openFileTreeItem(item));
|
||||||
const title = document.createElement("span");
|
const title = document.createElement("span");
|
||||||
title.className = "tree-link-title";
|
title.className = "tree-link-title";
|
||||||
title.textContent = item.title;
|
title.textContent = item.title;
|
||||||
const meta = document.createElement("span");
|
const meta = document.createElement("span");
|
||||||
meta.className = "tree-link-meta";
|
meta.className = "tree-link-meta";
|
||||||
meta.textContent = `${item.nodeId} · 页面`;
|
meta.textContent = getFileTreeRowMetaLabel(item);
|
||||||
linkButton.appendChild(title);
|
linkButton.appendChild(title);
|
||||||
linkButton.appendChild(meta);
|
linkButton.appendChild(meta);
|
||||||
row.appendChild(linkButton);
|
row.appendChild(linkButton);
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "tree-actions";
|
actions.className = "tree-actions";
|
||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
@@ -1700,9 +2104,10 @@ fn build_tree_shell_html(
|
|||||||
(button) => {
|
(button) => {
|
||||||
const center = getElementCenter(button);
|
const center = getElementCenter(button);
|
||||||
openFileTreeContextMenu({
|
openFileTreeContextMenu({
|
||||||
documentId: item.nodeId,
|
documentId,
|
||||||
rowId: `doc:${item.nodeId}`,
|
assetId,
|
||||||
rowKind: "doc",
|
rowId: item.rowId,
|
||||||
|
rowKind: item.rowKind,
|
||||||
clientX: center.x,
|
clientX: center.x,
|
||||||
clientY: center.y,
|
clientY: center.y,
|
||||||
});
|
});
|
||||||
@@ -1711,90 +2116,71 @@ fn build_tree_shell_html(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
row.appendChild(actions);
|
row.appendChild(actions);
|
||||||
wrapper.appendChild(row);
|
container.appendChild(row);
|
||||||
|
|
||||||
const indexRow = document.createElement("div");
|
if (!hasBranches || !expanded.has(item.nodeId)) return;
|
||||||
indexRow.className = "tree-row";
|
children.forEach((child) => appendFileTreeRow(container, child));
|
||||||
indexRow.style.marginLeft = `${(depth + 1) * 22}px`;
|
|
||||||
indexRow.setAttribute("data-testid", "filetree-index-row");
|
|
||||||
indexRow.dataset.rowId = `index:${item.nodeId}`;
|
|
||||||
indexRow.dataset.rowKind = "index";
|
|
||||||
indexRow.dataset.shellMode = "filetree";
|
|
||||||
indexRow.dataset.selected = String(selectedFileTreeRowIds.has(`index:${item.nodeId}`));
|
|
||||||
indexRow.tabIndex = 0;
|
|
||||||
indexRow.setAttribute("role", "treeitem");
|
|
||||||
indexRow.setAttribute("aria-level", String(depth + 2));
|
|
||||||
indexRow.addEventListener("click", () => {
|
|
||||||
selectedFileTreeRowIds = new Set([`index:${item.nodeId}`]);
|
|
||||||
renderTree();
|
|
||||||
});
|
|
||||||
indexRow.addEventListener("contextmenu", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
selectedFileTreeRowIds = new Set([`index:${item.nodeId}`]);
|
|
||||||
renderTree();
|
|
||||||
openFileTreeContextMenu({
|
|
||||||
documentId: item.nodeId,
|
|
||||||
rowId: `index:${item.nodeId}`,
|
|
||||||
rowKind: "index",
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const indexSpacer = document.createElement("div");
|
|
||||||
indexSpacer.className = "tree-spacer";
|
|
||||||
indexRow.appendChild(indexSpacer);
|
|
||||||
indexRow.appendChild(createKindBadge("index"));
|
|
||||||
const indexLink = document.createElement("button");
|
|
||||||
indexLink.type = "button";
|
|
||||||
indexLink.className = "tree-link";
|
|
||||||
indexLink.addEventListener("click", () => handleNavigate(item.nodeId));
|
|
||||||
const indexTitle = document.createElement("span");
|
|
||||||
indexTitle.className = "tree-link-title";
|
|
||||||
indexTitle.textContent = "index.md";
|
|
||||||
const indexMeta = document.createElement("span");
|
|
||||||
indexMeta.className = "tree-link-meta";
|
|
||||||
indexMeta.textContent = "页面正文";
|
|
||||||
indexLink.appendChild(indexTitle);
|
|
||||||
indexLink.appendChild(indexMeta);
|
|
||||||
indexRow.appendChild(indexLink);
|
|
||||||
const indexActions = document.createElement("div");
|
|
||||||
indexActions.className = "tree-actions";
|
|
||||||
indexActions.appendChild(
|
|
||||||
createActionButton(
|
|
||||||
ICONS.more,
|
|
||||||
"filetree-action-menu",
|
|
||||||
`打开 ${item.title} 正文的更多操作`,
|
|
||||||
(button) => {
|
|
||||||
const center = getElementCenter(button);
|
|
||||||
openFileTreeContextMenu({
|
|
||||||
documentId: item.nodeId,
|
|
||||||
rowId: `index:${item.nodeId}`,
|
|
||||||
rowKind: "index",
|
|
||||||
clientX: center.x,
|
|
||||||
clientY: center.y,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
indexRow.appendChild(indexActions);
|
|
||||||
if (expanded.has(item.nodeId) || !hasBranches) {
|
|
||||||
wrapper.appendChild(indexRow);
|
|
||||||
assets.forEach((asset) => appendAssetRow(wrapper, asset, depth + 1));
|
|
||||||
children.forEach((child) => appendDoc(child, depth + 1));
|
|
||||||
}
|
|
||||||
fileRoot.appendChild(wrapper);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fileRoot.addEventListener("dragover", (event) => {
|
||||||
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
||||||
|
const files = Array.from(event.dataTransfer?.files || []);
|
||||||
|
if (!internalRowIds && files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const target = getFileTreeDropTargetFromElement(event.target);
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect =
|
||||||
|
files.length > 0 || event.altKey ? "copy" : "move";
|
||||||
|
}
|
||||||
|
setFileTreeDropFeedback(target);
|
||||||
|
});
|
||||||
|
fileRoot.addEventListener("dragleave", (event) => {
|
||||||
|
const relatedTarget =
|
||||||
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
||||||
|
if (relatedTarget && fileRoot.contains(relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
});
|
||||||
|
fileRoot.addEventListener("drop", (event) => {
|
||||||
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
||||||
|
const files = Array.from(event.dataTransfer?.files || []);
|
||||||
|
if (!internalRowIds && files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const target = getFileTreeDropTargetFromElement(event.target);
|
||||||
|
if (files.length > 0) {
|
||||||
|
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
|
||||||
|
postFileTreeDropToHost("tree.filetree.external-drop", target, {
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLastAction(
|
||||||
|
event.altKey
|
||||||
|
? `已发送复制拖放到 ${target.rowId || "根目录"}`
|
||||||
|
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
|
||||||
|
);
|
||||||
|
postFileTreeDropToHost("tree.filetree.internal-drop", target, {
|
||||||
|
rowIds: internalRowIds,
|
||||||
|
copy: event.altKey === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
draggingFileTreeRowIds = [];
|
||||||
|
clearFileTreeDropFeedback();
|
||||||
|
});
|
||||||
|
|
||||||
if (roots.length === 0) {
|
if (roots.length === 0) {
|
||||||
const empty = document.createElement("div");
|
const empty = document.createElement("div");
|
||||||
empty.className = "tree-empty";
|
empty.className = "tree-empty";
|
||||||
empty.textContent = "当前 file tree 没有可渲染的页面。";
|
empty.textContent = "当前 file tree 没有可渲染的页面。";
|
||||||
appElement.appendChild(empty);
|
fileRoot.appendChild(empty);
|
||||||
return;
|
} else {
|
||||||
|
roots.forEach((item) => appendFileTreeRow(fileRoot, item));
|
||||||
}
|
}
|
||||||
|
normalizeFileTreeSelectionForVisibleRows();
|
||||||
roots.forEach((item) => appendDoc(item, 0));
|
|
||||||
appElement.appendChild(fileRoot);
|
appElement.appendChild(fileRoot);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1871,6 +2257,9 @@ fn build_tree_shell_html(
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderTree();
|
renderTree();
|
||||||
|
if (mode === "filetree") {
|
||||||
|
emitFileTreeSelectionChange();
|
||||||
|
}
|
||||||
setStatus(
|
setStatus(
|
||||||
mode === "picker"
|
mode === "picker"
|
||||||
? "Tree picker 已就绪,可以展开目录并选择目标页面。"
|
? "Tree picker 已就绪,可以展开目录并选择目标页面。"
|
||||||
@@ -2219,7 +2608,7 @@ mod tests {
|
|||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
allow_dev_fixtures: true,
|
allow_dev_fixtures: true,
|
||||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]}}"#.into()),
|
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
||||||
mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"}}"#.into()),
|
mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"}}"#.into()),
|
||||||
dev_user_id: "dev-user".into(),
|
dev_user_id: "dev-user".into(),
|
||||||
dev_user_name: "开发用户".into(),
|
dev_user_name: "开发用户".into(),
|
||||||
@@ -2281,6 +2670,7 @@ mod tests {
|
|||||||
assert!(html.contains("\"allowRootPick\":true"));
|
assert!(html.contains("\"allowRootPick\":true"));
|
||||||
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
|
||||||
assert!(html.contains("tree.pick.root"));
|
assert!(html.contains("tree.pick.root"));
|
||||||
|
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2302,6 +2692,12 @@ mod tests {
|
|||||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(html.contains("filetree-doc-row"));
|
assert!(html.contains("filetree-doc-row"));
|
||||||
assert!(html.contains("\"mediaAssets\""));
|
assert!(html.contains("\"mediaAssets\""));
|
||||||
|
assert!(html.contains("tree.filetree.selection.changed"));
|
||||||
|
assert!(html.contains("tree.filetree.internal-drop"));
|
||||||
|
assert!(html.contains("tree.filetree.external-drop"));
|
||||||
|
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
||||||
|
assert!(html.contains("\"resourceMeta\""));
|
||||||
|
assert!(html.contains("dragover"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Blocking waiting for file lock on package cache
|
||||||
|
Blocking waiting for file lock on package cache
|
||||||
|
Blocking waiting for file lock on package cache
|
||||||
|
Compiling mnote-web v0.1.0 (/mnt/Data1T/mnote/rust/crates/mnote-web)
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.69s
|
||||||
|
Running `target/debug/mnote-web`
|
||||||
|
2026-04-23T16:14:28.636856Z INFO mnote-web 最小骨架已启动 bind_addr=127.0.0.1:3104
|
||||||
Binary file not shown.
@@ -0,0 +1,17 @@
|
|||||||
|
INFO: Will watch for changes in these directories: ['/mnt/Data1T/mnote/wolai-backend']
|
||||||
|
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||||
|
INFO: Started reloader process [1431406] using WatchFiles
|
||||||
|
INFO: Started server process [1431452]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: 127.0.0.1:47792 - "HEAD / HTTP/1.1" 405 Method Not Allowed
|
||||||
|
INFO: 127.0.0.1:37784 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:36884 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:48348 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:53220 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:48870 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:49756 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:49756 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:49732 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:49732 - "GET /health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:49732 - "GET /health HTTP/1.1" 200 OK
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
|
||||||
|
> wolai-frontend@0.1.0 dev /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
> node scripts/dev-server.js -p 3000
|
||||||
|
|
||||||
|
[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
|
||||||
|
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
|
||||||
|
[dev-server] ready http://0.0.0.0:3000 (ONLYOFFICE ws via /onlyoffice-server -> http://127.0.0.1:8082; Convex ws via /convex -> http://127.0.0.1:3210)
|
||||||
|
GET / 307 in 1911ms (compile: 1670ms, proxy.ts: 30ms, render: 211ms)
|
||||||
|
○ Compiling /documents/[id] ...
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 4.1s (compile: 3.6s, proxy.ts: 9ms, render: 536ms)
|
||||||
|
⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this.
|
||||||
|
Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 366ms (compile: 319ms, proxy.ts: 8ms, render: 40ms)
|
||||||
|
GET /api/backend/health 200 in 98ms (compile: 81ms, proxy.ts: 11ms, render: 6ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 924ms (compile: 186ms, proxy.ts: 13ms, render: 725ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 146ms (compile: 122ms, proxy.ts: 8ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 719ms (compile: 695ms, proxy.ts: 14ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 724ms (compile: 698ms, proxy.ts: 15ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 97ms (compile: 86ms, proxy.ts: 6ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 47ms (compile: 21ms, proxy.ts: 13ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 50ms (compile: 19ms, proxy.ts: 14ms, render: 17ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 49ms (compile: 21ms, proxy.ts: 14ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 49ms (compile: 19ms, proxy.ts: 17ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 52ms (compile: 20ms, proxy.ts: 17ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 55ms (compile: 20ms, proxy.ts: 18ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 51ms (compile: 22ms, proxy.ts: 17ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 52ms (compile: 23ms, proxy.ts: 15ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 53ms (compile: 21ms, proxy.ts: 18ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 50ms (compile: 22ms, proxy.ts: 15ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 52ms (compile: 17ms, proxy.ts: 20ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 52ms (compile: 19ms, proxy.ts: 19ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 47ms (compile: 18ms, proxy.ts: 16ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 46ms (compile: 19ms, proxy.ts: 15ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 46ms (compile: 19ms, proxy.ts: 16ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 48ms (compile: 20ms, proxy.ts: 17ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 43ms (compile: 20ms, proxy.ts: 14ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 43ms (compile: 20ms, proxy.ts: 12ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 45ms (compile: 16ms, proxy.ts: 13ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 45ms (compile: 17ms, proxy.ts: 13ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 44ms (compile: 17ms, proxy.ts: 14ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 20ms, proxy.ts: 13ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 43ms (compile: 19ms, proxy.ts: 16ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 41ms (compile: 19ms, proxy.ts: 15ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 36ms (compile: 15ms, proxy.ts: 10ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 36ms (compile: 14ms, proxy.ts: 11ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 36ms (compile: 15ms, proxy.ts: 11ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 38ms (compile: 16ms, proxy.ts: 14ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 37ms (compile: 16ms, proxy.ts: 14ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 30ms (compile: 3ms, proxy.ts: 8ms, render: 19ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 60ms (compile: 4ms, proxy.ts: 10ms, render: 46ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 41ms (compile: 4ms, proxy.ts: 20ms, render: 18ms)
|
||||||
|
GET /auth 200 in 441ms (compile: 376ms, proxy.ts: 4ms, render: 60ms)
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 511ms (compile: 114ms, proxy.ts: 19ms, render: 378ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 29ms (compile: 3ms, proxy.ts: 11ms, render: 15ms)
|
||||||
|
GET /api/backend/health 200 in 12ms (compile: 1739µs, proxy.ts: 7ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 111ms (compile: 89ms, proxy.ts: 11ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 110ms (compile: 91ms, proxy.ts: 10ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 11ms (compile: 3ms, proxy.ts: 6ms, render: 2ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 39ms (compile: 15ms, proxy.ts: 12ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 41ms (compile: 13ms, proxy.ts: 12ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 40ms (compile: 15ms, proxy.ts: 13ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 38ms (compile: 18ms, proxy.ts: 13ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 38ms (compile: 19ms, proxy.ts: 14ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 14ms, proxy.ts: 10ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 35ms (compile: 15ms, proxy.ts: 9ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 34ms (compile: 15ms, proxy.ts: 10ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 35ms (compile: 15ms, proxy.ts: 12ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 35ms (compile: 16ms, proxy.ts: 13ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 57ms (compile: 17ms, proxy.ts: 13ms, render: 26ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 56ms (compile: 15ms, proxy.ts: 16ms, render: 25ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 56ms (compile: 16ms, proxy.ts: 16ms, render: 23ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 56ms (compile: 17ms, proxy.ts: 17ms, render: 22ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 56ms (compile: 30ms, proxy.ts: 20ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 33ms (compile: 12ms, proxy.ts: 11ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 33ms (compile: 13ms, proxy.ts: 11ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 35ms (compile: 14ms, proxy.ts: 11ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 37ms (compile: 13ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 39ms (compile: 17ms, proxy.ts: 13ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 34ms (compile: 14ms, proxy.ts: 10ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 34ms (compile: 14ms, proxy.ts: 11ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 35ms (compile: 15ms, proxy.ts: 12ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 39ms (compile: 15ms, proxy.ts: 12ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 37ms (compile: 17ms, proxy.ts: 10ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 28ms (compile: 10ms, proxy.ts: 10ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 31ms (compile: 11ms, proxy.ts: 10ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 30ms (compile: 11ms, proxy.ts: 12ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 26ms (compile: 12ms, proxy.ts: 9ms, render: 5ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 719ms (compile: 1610µs, proxy.ts: 6ms, render: 711ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 76ms (compile: 3ms, proxy.ts: 9ms, render: 64ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 51ms (compile: 3ms, proxy.ts: 9ms, render: 40ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 17ms (compile: 1686µs, proxy.ts: 7ms, render: 8ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 37ms (compile: 3ms, proxy.ts: 17ms, render: 17ms)
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 483ms (compile: 111ms, proxy.ts: 18ms, render: 354ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 28ms (compile: 2ms, proxy.ts: 6ms, render: 20ms)
|
||||||
|
GET /api/backend/health 200 in 14ms (compile: 2ms, proxy.ts: 8ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 113ms (compile: 90ms, proxy.ts: 8ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 112ms (compile: 98ms, proxy.ts: 7ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 18ms (compile: 3ms, proxy.ts: 12ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 41ms (compile: 19ms, proxy.ts: 12ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 44ms (compile: 18ms, proxy.ts: 12ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 41ms (compile: 18ms, proxy.ts: 13ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 41ms (compile: 15ms, proxy.ts: 18ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 41ms (compile: 16ms, proxy.ts: 19ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 43ms (compile: 20ms, proxy.ts: 10ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 42ms (compile: 19ms, proxy.ts: 12ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 42ms (compile: 21ms, proxy.ts: 12ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 43ms (compile: 20ms, proxy.ts: 14ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 48ms (compile: 21ms, proxy.ts: 15ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 34ms (compile: 13ms, proxy.ts: 10ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 34ms (compile: 13ms, proxy.ts: 11ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 40ms (compile: 13ms, proxy.ts: 13ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 42ms (compile: 14ms, proxy.ts: 14ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 39ms (compile: 16ms, proxy.ts: 11ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 38ms (compile: 13ms, proxy.ts: 13ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 38ms (compile: 13ms, proxy.ts: 15ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 34ms (compile: 13ms, proxy.ts: 13ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 33ms (compile: 13ms, proxy.ts: 12ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 31ms (compile: 13ms, proxy.ts: 11ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 42ms (compile: 14ms, proxy.ts: 16ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 15ms, proxy.ts: 15ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 42ms (compile: 16ms, proxy.ts: 15ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 62ms (compile: 15ms, proxy.ts: 19ms, render: 28ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 61ms (compile: 16ms, proxy.ts: 16ms, render: 29ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 28ms (compile: 9ms, proxy.ts: 11ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 28ms (compile: 10ms, proxy.ts: 11ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 29ms (compile: 10ms, proxy.ts: 11ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 25ms (compile: 10ms, proxy.ts: 9ms, render: 5ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 739ms (compile: 1795µs, proxy.ts: 7ms, render: 731ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 80ms (compile: 2ms, proxy.ts: 7ms, render: 71ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 63ms (compile: 2ms, proxy.ts: 7ms, render: 54ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 22ms (compile: 1535µs, proxy.ts: 6ms, render: 15ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 29ms (compile: 3ms, proxy.ts: 8ms, render: 18ms)
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 481ms (compile: 111ms, proxy.ts: 19ms, render: 352ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 27ms (compile: 2ms, proxy.ts: 7ms, render: 18ms)
|
||||||
|
GET /api/backend/health 200 in 14ms (compile: 2ms, proxy.ts: 8ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 115ms (compile: 93ms, proxy.ts: 7ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 115ms (compile: 102ms, proxy.ts: 7ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 42ms (compile: 16ms, proxy.ts: 14ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 37ms (compile: 16ms, proxy.ts: 11ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 43ms (compile: 17ms, proxy.ts: 17ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 39ms (compile: 17ms, proxy.ts: 13ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 45ms (compile: 17ms, proxy.ts: 13ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 14ms, proxy.ts: 11ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 35ms (compile: 14ms, proxy.ts: 12ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 39ms (compile: 15ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 40ms (compile: 16ms, proxy.ts: 14ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 43ms (compile: 17ms, proxy.ts: 13ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 45ms (compile: 21ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 45ms (compile: 20ms, proxy.ts: 16ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 43ms (compile: 21ms, proxy.ts: 14ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 50ms (compile: 16ms, proxy.ts: 20ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 45ms (compile: 17ms, proxy.ts: 15ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 40ms (compile: 14ms, proxy.ts: 14ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 40ms (compile: 15ms, proxy.ts: 13ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 42ms (compile: 15ms, proxy.ts: 14ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 43ms (compile: 19ms, proxy.ts: 11ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 40ms (compile: 21ms, proxy.ts: 8ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 40ms (compile: 17ms, proxy.ts: 13ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 17ms, proxy.ts: 16ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 45ms (compile: 17ms, proxy.ts: 17ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 56ms (compile: 17ms, proxy.ts: 10ms, render: 29ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 57ms (compile: 18ms, proxy.ts: 9ms, render: 30ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 50ms (compile: 9ms, proxy.ts: 33ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 49ms (compile: 10ms, proxy.ts: 31ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 32ms (compile: 11ms, proxy.ts: 14ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 25ms (compile: 11ms, proxy.ts: 10ms, render: 5ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 738ms (compile: 1573µs, proxy.ts: 7ms, render: 730ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 62ms (compile: 3ms, proxy.ts: 7ms, render: 53ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 49ms (compile: 2ms, proxy.ts: 6ms, render: 40ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 17ms (compile: 2ms, proxy.ts: 7ms, render: 8ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 40ms (compile: 3ms, proxy.ts: 16ms, render: 20ms)
|
||||||
|
✓ Compiled in 80ms
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 38ms (compile: 2ms, proxy.ts: 18ms, render: 18ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 24ms (compile: 1593µs, proxy.ts: 7ms, render: 16ms)
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 747ms (compile: 183ms, proxy.ts: 17ms, render: 547ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 26ms (compile: 2ms, proxy.ts: 8ms, render: 16ms)
|
||||||
|
GET /api/backend/health 200 in 14ms (compile: 2ms, proxy.ts: 8ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 114ms (compile: 95ms, proxy.ts: 9ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 112ms (compile: 98ms, proxy.ts: 10ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 12ms (compile: 3ms, proxy.ts: 6ms, render: 2ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 47ms (compile: 18ms, proxy.ts: 12ms, render: 17ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 49ms (compile: 16ms, proxy.ts: 11ms, render: 22ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 44ms (compile: 19ms, proxy.ts: 11ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 48ms (compile: 21ms, proxy.ts: 14ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 46ms (compile: 22ms, proxy.ts: 13ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 13ms, proxy.ts: 12ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 34ms (compile: 14ms, proxy.ts: 11ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 34ms (compile: 15ms, proxy.ts: 11ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 35ms (compile: 14ms, proxy.ts: 14ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 33ms (compile: 14ms, proxy.ts: 13ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 39ms (compile: 18ms, proxy.ts: 10ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 38ms (compile: 17ms, proxy.ts: 10ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 38ms (compile: 15ms, proxy.ts: 13ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 38ms (compile: 14ms, proxy.ts: 16ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 39ms (compile: 15ms, proxy.ts: 17ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 39ms (compile: 13ms, proxy.ts: 12ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 39ms (compile: 14ms, proxy.ts: 11ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 39ms (compile: 15ms, proxy.ts: 12ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 39ms (compile: 14ms, proxy.ts: 13ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 42ms (compile: 17ms, proxy.ts: 13ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 36ms (compile: 12ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 36ms (compile: 12ms, proxy.ts: 14ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 36ms (compile: 12ms, proxy.ts: 15ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 36ms (compile: 12ms, proxy.ts: 17ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 32ms (compile: 13ms, proxy.ts: 13ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 32ms (compile: 10ms, proxy.ts: 12ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 32ms (compile: 11ms, proxy.ts: 12ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 30ms (compile: 10ms, proxy.ts: 12ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 31ms (compile: 11ms, proxy.ts: 13ms, render: 6ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 726ms (compile: 1595µs, proxy.ts: 7ms, render: 718ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 27ms (compile: 2ms, proxy.ts: 6ms, render: 18ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 81ms (compile: 2ms, proxy.ts: 10ms, render: 69ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 16ms (compile: 1736µs, proxy.ts: 7ms, render: 7ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 40ms (compile: 2ms, proxy.ts: 18ms, render: 19ms)
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 509ms (compile: 114ms, proxy.ts: 39ms, render: 357ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 21ms (compile: 1697µs, proxy.ts: 7ms, render: 12ms)
|
||||||
|
GET /api/backend/health 200 in 11ms (compile: 1606µs, proxy.ts: 5ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 122ms (compile: 99ms, proxy.ts: 14ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 115ms (compile: 102ms, proxy.ts: 9ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 6ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 38ms (compile: 16ms, proxy.ts: 14ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 42ms (compile: 17ms, proxy.ts: 13ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 42ms (compile: 17ms, proxy.ts: 16ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 40ms (compile: 16ms, proxy.ts: 16ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 41ms (compile: 16ms, proxy.ts: 19ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 29ms (compile: 15ms, proxy.ts: 6ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 38ms (compile: 19ms, proxy.ts: 11ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 38ms (compile: 20ms, proxy.ts: 11ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 38ms (compile: 18ms, proxy.ts: 14ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 38ms (compile: 17ms, proxy.ts: 17ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 35ms (compile: 12ms, proxy.ts: 13ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 35ms (compile: 13ms, proxy.ts: 13ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 35ms (compile: 14ms, proxy.ts: 13ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 35ms (compile: 14ms, proxy.ts: 13ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 35ms (compile: 14ms, proxy.ts: 16ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 39ms (compile: 11ms, proxy.ts: 18ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 39ms (compile: 12ms, proxy.ts: 18ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 39ms (compile: 12ms, proxy.ts: 18ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 39ms (compile: 13ms, proxy.ts: 18ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 40ms (compile: 14ms, proxy.ts: 18ms, render: 7ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 703ms (compile: 1547µs, proxy.ts: 6ms, render: 695ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 49ms (compile: 20ms, proxy.ts: 15ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 49ms (compile: 21ms, proxy.ts: 15ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 49ms (compile: 22ms, proxy.ts: 16ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 49ms (compile: 23ms, proxy.ts: 16ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 49ms (compile: 23ms, proxy.ts: 17ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 50ms (compile: 18ms, proxy.ts: 24ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 111ms (compile: 95ms, proxy.ts: 8ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 112ms (compile: 97ms, proxy.ts: 8ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 112ms (compile: 98ms, proxy.ts: 8ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 62ms (compile: 3ms, proxy.ts: 6ms, render: 53ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 68ms (compile: 3ms, proxy.ts: 6ms, render: 59ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 23ms (compile: 1505µs, proxy.ts: 7ms, render: 15ms)
|
||||||
|
GET / 307 in 85ms (compile: 2ms, proxy.ts: 7ms, render: 77ms)
|
||||||
|
GET /documents/8337ceec-e236-41d7-a2eb-86838f46c858 200 in 451ms (compile: 118ms, proxy.ts: 6ms, render: 327ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 19ms (compile: 1817µs, proxy.ts: 6ms, render: 12ms)
|
||||||
|
GET /api/backend/health 200 in 11ms (compile: 1466µs, proxy.ts: 6ms, render: 3ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 290ms (compile: 2ms, proxy.ts: 4ms, render: 284ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 91ms, proxy.ts: 10ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 93ms, proxy.ts: 10ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 23ms (compile: 9ms, proxy.ts: 9ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 25ms (compile: 8ms, proxy.ts: 9ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 24ms (compile: 9ms, proxy.ts: 10ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 25ms (compile: 7ms, proxy.ts: 12ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 23ms (compile: 7ms, proxy.ts: 11ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 18ms (compile: 5ms, proxy.ts: 11ms, render: 2ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 21ms (compile: 6ms, proxy.ts: 8ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 21ms (compile: 7ms, proxy.ts: 8ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 21ms (compile: 8ms, proxy.ts: 8ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 24ms (compile: 8ms, proxy.ts: 10ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 24ms (compile: 10ms, proxy.ts: 9ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 23ms (compile: 10ms, proxy.ts: 9ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 21ms (compile: 7ms, proxy.ts: 9ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 21ms (compile: 7ms, proxy.ts: 9ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 21ms (compile: 7ms, proxy.ts: 10ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 117ms (compile: 96ms, proxy.ts: 9ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 117ms (compile: 97ms, proxy.ts: 9ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 118ms (compile: 98ms, proxy.ts: 9ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 46ms (compile: 6ms, proxy.ts: 33ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 46ms (compile: 7ms, proxy.ts: 33ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 44ms (compile: 8ms, proxy.ts: 32ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 21ms (compile: 7ms, proxy.ts: 7ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 21ms (compile: 8ms, proxy.ts: 8ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 21ms (compile: 8ms, proxy.ts: 9ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 19ms (compile: 7ms, proxy.ts: 7ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 19ms (compile: 7ms, proxy.ts: 7ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 20ms (compile: 8ms, proxy.ts: 8ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 16ms (compile: 5ms, proxy.ts: 7ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 16ms (compile: 5ms, proxy.ts: 9ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 57ms (compile: 2ms, proxy.ts: 6ms, render: 49ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 54ms (compile: 3ms, proxy.ts: 6ms, render: 45ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 17ms (compile: 1539µs, proxy.ts: 7ms, render: 8ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=8337ceec-e236-41d7-a2eb-86838f46c858 200 in 23ms (compile: 1655µs, proxy.ts: 7ms, render: 14ms)
|
||||||
|
GET /documents/ced55414-84b5-4d88-8af6-9a2d54f42b36 200 in 283ms (compile: 106ms, proxy.ts: 7ms, render: 170ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=ced55414-84b5-4d88-8af6-9a2d54f42b36 200 in 34ms (compile: 2ms, proxy.ts: 8ms, render: 24ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 105ms (compile: 89ms, proxy.ts: 5ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 104ms (compile: 95ms, proxy.ts: 6ms, render: 4ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=ced55414-84b5-4d88-8af6-9a2d54f42b36 200 in 18ms (compile: 1665µs, proxy.ts: 6ms, render: 10ms)
|
||||||
|
GET /documents/dba2659d-b9fd-4a3b-a28a-448756ea7768 200 in 282ms (compile: 110ms, proxy.ts: 6ms, render: 166ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=dba2659d-b9fd-4a3b-a28a-448756ea7768 200 in 33ms (compile: 2ms, proxy.ts: 8ms, render: 23ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 111ms (compile: 96ms, proxy.ts: 8ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 111ms (compile: 97ms, proxy.ts: 8ms, render: 5ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=dba2659d-b9fd-4a3b-a28a-448756ea7768 200 in 19ms (compile: 1920µs, proxy.ts: 7ms, render: 11ms)
|
||||||
|
GET /documents/0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 279ms (compile: 111ms, proxy.ts: 6ms, render: 162ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 34ms (compile: 2ms, proxy.ts: 10ms, render: 22ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 106ms (compile: 92ms, proxy.ts: 7ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 95ms, proxy.ts: 7ms, render: 5ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 19ms (compile: 1664µs, proxy.ts: 6ms, render: 10ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 21ms (compile: 1667µs, proxy.ts: 6ms, render: 13ms)
|
||||||
|
GET /documents/7575b06a-16a9-48d1-94fc-2b7532da75ca?preview=sidebar 200 in 450ms (compile: 106ms, proxy.ts: 7ms, render: 337ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 18ms (compile: 1688µs, proxy.ts: 6ms, render: 10ms)
|
||||||
|
GET /api/backend/health 200 in 13ms (compile: 1891µs, proxy.ts: 8ms, render: 4ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 303ms (compile: 1360µs, proxy.ts: 6ms, render: 296ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 106ms (compile: 92ms, proxy.ts: 7ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 106ms (compile: 94ms, proxy.ts: 7ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 20ms (compile: 6ms, proxy.ts: 7ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 21ms (compile: 10ms, proxy.ts: 8ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 17ms (compile: 4ms, proxy.ts: 8ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 17ms (compile: 5ms, proxy.ts: 8ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 13ms (compile: 5ms, proxy.ts: 6ms, render: 2ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 346ms (compile: 127ms, proxy.ts: 31ms, render: 188ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 413ms (compile: 292ms, proxy.ts: 18ms, render: 103ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 410ms (compile: 300ms, proxy.ts: 16ms, render: 94ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 412ms (compile: 163ms, proxy.ts: 195ms, render: 54ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 410ms (compile: 164ms, proxy.ts: 193ms, render: 53ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 110ms (compile: 12ms, proxy.ts: 93ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 21ms (compile: 9ms, proxy.ts: 9ms, render: 4ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 20ms (compile: 6ms, proxy.ts: 12ms, render: 3ms)
|
||||||
|
GET /documents/6700d014-d576-4279-8d76-4787c9156989?preview=sidebar 200 in 662ms (compile: 139ms, proxy.ts: 6ms, render: 517ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=6700d014-d576-4279-8d76-4787c9156989 200 in 128ms (compile: 8ms, proxy.ts: 15ms, render: 105ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 132ms (compile: 102ms, proxy.ts: 15ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 132ms (compile: 105ms, proxy.ts: 15ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 134ms (compile: 106ms, proxy.ts: 16ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 133ms (compile: 109ms, proxy.ts: 17ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 127ms (compile: 108ms, proxy.ts: 14ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 42ms (compile: 13ms, proxy.ts: 17ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 42ms (compile: 14ms, proxy.ts: 17ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 42ms (compile: 14ms, proxy.ts: 19ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 42ms (compile: 14ms, proxy.ts: 21ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 38ms (compile: 14ms, proxy.ts: 17ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 38ms (compile: 15ms, proxy.ts: 18ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 42ms (compile: 19ms, proxy.ts: 12ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 42ms (compile: 20ms, proxy.ts: 12ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 42ms (compile: 19ms, proxy.ts: 13ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 43ms (compile: 20ms, proxy.ts: 15ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 43ms (compile: 17ms, proxy.ts: 19ms, render: 7ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 44ms (compile: 4ms, proxy.ts: 20ms, render: 19ms)
|
||||||
|
GET /api/backend/health 200 in 20ms (compile: 5ms, proxy.ts: 8ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 320ms (compile: 12ms, proxy.ts: 16ms, render: 292ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 335ms (compile: 6ms, proxy.ts: 8ms, render: 321ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 119ms (compile: 95ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 119ms (compile: 96ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 13ms (compile: 3ms, proxy.ts: 7ms, render: 2ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 183ms (compile: 93ms, proxy.ts: 15ms, render: 75ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 35ms (compile: 11ms, proxy.ts: 12ms, render: 13ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 35ms (compile: 13ms, proxy.ts: 12ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 36ms (compile: 12ms, proxy.ts: 12ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 35ms (compile: 13ms, proxy.ts: 13ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 36ms (compile: 16ms, proxy.ts: 14ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 36ms (compile: 15ms, proxy.ts: 10ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 35ms (compile: 16ms, proxy.ts: 10ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 35ms (compile: 15ms, proxy.ts: 11ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 36ms (compile: 15ms, proxy.ts: 12ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 36ms (compile: 16ms, proxy.ts: 13ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 32ms (compile: 16ms, proxy.ts: 13ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 41ms (compile: 14ms, proxy.ts: 16ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 40ms (compile: 15ms, proxy.ts: 16ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 41ms (compile: 13ms, proxy.ts: 18ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 36ms (compile: 14ms, proxy.ts: 14ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 36ms (compile: 15ms, proxy.ts: 14ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 36ms (compile: 15ms, proxy.ts: 15ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 41ms (compile: 13ms, proxy.ts: 18ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 41ms (compile: 13ms, proxy.ts: 18ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 41ms (compile: 14ms, proxy.ts: 18ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 41ms (compile: 14ms, proxy.ts: 18ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 41ms (compile: 14ms, proxy.ts: 20ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 41ms (compile: 15ms, proxy.ts: 21ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 130ms (compile: 105ms, proxy.ts: 11ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 128ms (compile: 104ms, proxy.ts: 12ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 129ms (compile: 104ms, proxy.ts: 14ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 128ms (compile: 104ms, proxy.ts: 15ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 130ms (compile: 105ms, proxy.ts: 18ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 129ms (compile: 105ms, proxy.ts: 18ms, render: 6ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=6700d014-d576-4279-8d76-4787c9156989 200 in 21ms (compile: 2ms, proxy.ts: 7ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 66ms (compile: 5ms, proxy.ts: 7ms, render: 54ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 64ms (compile: 6ms, proxy.ts: 9ms, render: 49ms)
|
||||||
|
GET /documents/cbebb4af-a158-43c5-a55f-6a2a2d18d491?preview=sidebar 200 in 454ms (compile: 111ms, proxy.ts: 6ms, render: 337ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=cbebb4af-a158-43c5-a55f-6a2a2d18d491 200 in 21ms (compile: 1850µs, proxy.ts: 6ms, render: 14ms)
|
||||||
|
GET /api/backend/health 200 in 10ms (compile: 1522µs, proxy.ts: 6ms, render: 3ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 109ms (compile: 95ms, proxy.ts: 7ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 110ms (compile: 98ms, proxy.ts: 8ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js 200 in 12ms (compile: 3ms, proxy.ts: 7ms, render: 2ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_blockquote.js 200 in 43ms (compile: 13ms, proxy.ts: 19ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bold.js 200 in 43ms (compile: 15ms, proxy.ts: 20ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js 200 in 44ms (compile: 15ms, proxy.ts: 19ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_bullet_list.js 200 in 44ms (compile: 16ms, proxy.ts: 20ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code.js 200 in 44ms (compile: 14ms, proxy.ts: 24ms, render: 5ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_document.js 200 in 43ms (compile: 15ms, proxy.ts: 18ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_code_block.js 200 in 43ms (compile: 15ms, proxy.ts: 18ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_dropcursor.js 200 in 43ms (compile: 16ms, proxy.ts: 18ms, render: 8ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_gapcursor.js 200 in 35ms (compile: 12ms, proxy.ts: 17ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_hard_break.js 200 in 35ms (compile: 13ms, proxy.ts: 17ms, render: 5ms)
|
||||||
|
GET /api/mnote-web/stream?workspaceId=8f317132-506f-4399-99dc-d22318427248 200 in 688ms (compile: 1358µs, proxy.ts: 5ms, render: 681ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_heading.js 200 in 42ms (compile: 17ms, proxy.ts: 13ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_image.js 200 in 42ms (compile: 18ms, proxy.ts: 13ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_highlight.js 200 in 42ms (compile: 19ms, proxy.ts: 13ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_history.js 200 in 43ms (compile: 18ms, proxy.ts: 16ms, render: 9ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_horizontal_rule.js 200 in 43ms (compile: 18ms, proxy.ts: 18ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_italic.js 200 in 39ms (compile: 18ms, proxy.ts: 14ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_list_item.js 200 in 42ms (compile: 14ms, proxy.ts: 12ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_link.js 200 in 42ms (compile: 15ms, proxy.ts: 12ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_ordered_list.js 200 in 42ms (compile: 16ms, proxy.ts: 12ms, render: 14ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_paragraph.js 200 in 41ms (compile: 16ms, proxy.ts: 13ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_strike.js 200 in 43ms (compile: 17ms, proxy.ts: 15ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_placeholder.js 200 in 42ms (compile: 19ms, proxy.ts: 17ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_item.js 200 in 129ms (compile: 98ms, proxy.ts: 12ms, render: 19ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_align.js 200 in 129ms (compile: 100ms, proxy.ts: 12ms, render: 17ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text.js 200 in 129ms (compile: 101ms, proxy.ts: 12ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_task_list.js 200 in 129ms (compile: 101ms, proxy.ts: 13ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_text_style.js 200 in 130ms (compile: 103ms, proxy.ts: 15ms, render: 11ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js 200 in 130ms (compile: 104ms, proxy.ts: 16ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js 200 in 15ms (compile: 6ms, proxy.ts: 6ms, render: 3ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=cbebb4af-a158-43c5-a55f-6a2a2d18d491 200 in 21ms (compile: 2ms, proxy.ts: 6ms, render: 12ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 54ms (compile: 3ms, proxy.ts: 5ms, render: 47ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm 200 in 87ms (compile: 3ms, proxy.ts: 7ms, render: 77ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=0afdf760-a9aa-43e1-8b76-400d84b96b2a 200 in 22ms (compile: 1659µs, proxy.ts: 7ms, render: 14ms)
|
||||||
|
GET /documents/7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 270ms (compile: 110ms, proxy.ts: 5ms, render: 155ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 24ms (compile: 1557µs, proxy.ts: 6ms, render: 16ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 112ms (compile: 95ms, proxy.ts: 6ms, render: 10ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 11ms (compile: 2ms, proxy.ts: 6ms, render: 3ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=7575b06a-16a9-48d1-94fc-2b7532da75ca 200 in 15ms (compile: 1603µs, proxy.ts: 6ms, render: 7ms)
|
||||||
|
GET /documents/6700d014-d576-4279-8d76-4787c9156989 200 in 285ms (compile: 115ms, proxy.ts: 7ms, render: 163ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=6700d014-d576-4279-8d76-4787c9156989 200 in 38ms (compile: 2ms, proxy.ts: 20ms, render: 15ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 93ms, proxy.ts: 7ms, render: 6ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 107ms (compile: 94ms, proxy.ts: 8ms, render: 5ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=6700d014-d576-4279-8d76-4787c9156989 200 in 16ms (compile: 3ms, proxy.ts: 6ms, render: 8ms)
|
||||||
|
GET /documents/11181c74-1333-41d1-ac84-9b8e027131db 200 in 295ms (compile: 114ms, proxy.ts: 18ms, render: 163ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=filetree&channel=sidebar-file-tree-shell&host=sidebar-file-tree-shell&activeDocumentId=11181c74-1333-41d1-ac84-9b8e027131db 200 in 32ms (compile: 1970µs, proxy.ts: 8ms, render: 22ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 113ms (compile: 99ms, proxy.ts: 7ms, render: 7ms)
|
||||||
|
GET /api/leptos-tiptap-runtime/manifest.json 200 in 114ms (compile: 101ms, proxy.ts: 9ms, render: 4ms)
|
||||||
|
GET /api/references/backlinks?workspaceId=8f317132-506f-4399-99dc-d22318427248&pageId=11181c74-1333-41d1-ac84-9b8e027131db 200 in 18ms (compile: 1650µs, proxy.ts: 6ms, render: 11ms)
|
||||||
|
GET /api/tree/shell?workspaceId=8f317132-506f-4399-99dc-d22318427248&mode=page&channel=sidebar-page-tree-shell&host=sidebar-page-tree-shell&activeDocumentId=11181c74-1333-41d1-ac84-9b8e027131db 200 in 20ms (compile: 1814µs, proxy.ts: 6ms, render: 12ms)
|
||||||
@@ -12,6 +12,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
sidebarInitialData,
|
sidebarInitialData,
|
||||||
} = await loadSidebarDataFromConvex({
|
} = await loadSidebarDataFromConvex({
|
||||||
client,
|
client,
|
||||||
|
auth,
|
||||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +1,83 @@
|
|||||||
import { randomUUID } from "crypto";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
type TreeCreateResponse = {
|
||||||
import {
|
requestId?: string;
|
||||||
buildDocumentBridgeContext,
|
traceId?: string;
|
||||||
buildDocumentCommandEnvelope,
|
result?: {
|
||||||
documentBridgeErrorResponse,
|
documentId?: string;
|
||||||
} from "@/lib/documents/bridge";
|
parentId?: string | null;
|
||||||
import {
|
title?: string | null;
|
||||||
executePageLifecycleBridgeCommand,
|
sortOrder?: number | null;
|
||||||
type DocumentCreatePayload,
|
workspaceId?: string | null;
|
||||||
} from "@/lib/documents/page-command-adapter";
|
execution?: {
|
||||||
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
|
access_scope?: "private" | "shared" | "public";
|
||||||
|
is_template?: boolean;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
if (!isConvexEnabled()) {
|
||||||
if (isConvexEnabled()) {
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
return await handleCreateRequestConvex(request);
|
|
||||||
}
|
|
||||||
return await handleCreateRequest();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("创建页面失败", error);
|
|
||||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateRequestConvex(request: Request) {
|
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
|
||||||
const { parentId }: { parentId?: string | null } = await request.json();
|
|
||||||
|
|
||||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
|
||||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
|
||||||
workspaceIdIfCreate: randomUUID(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let workspaceId: string | null = null;
|
|
||||||
let accessScope: "private" | "shared" | "public" = "private";
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const parentDoc = await client.query(api.documents.getMeta, {
|
|
||||||
id: parentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parentDoc) {
|
|
||||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceId = parentDoc.workspace_id;
|
|
||||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
|
||||||
} else {
|
|
||||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = randomUUID();
|
const { parentId }: { parentId?: string | null } = await request.json();
|
||||||
const normalizedWorkspaceId = workspaceId.trim();
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
const bridgeContext = await buildDocumentBridgeContext({
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
request,
|
method: "POST",
|
||||||
workspaceId: normalizedWorkspaceId,
|
headers: new Headers({
|
||||||
});
|
"content-type": "application/json",
|
||||||
const result = await executePageLifecycleBridgeCommand<
|
}),
|
||||||
DocumentCreatePayload,
|
body: JSON.stringify({
|
||||||
{
|
action: "create",
|
||||||
id: string;
|
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
|
||||||
title?: string | null;
|
|
||||||
parent_id?: string | null;
|
|
||||||
sort_order?: number | null;
|
|
||||||
workspace_id?: string;
|
|
||||||
access_scope?: "private" | "shared" | "public";
|
|
||||||
is_template?: boolean;
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
context: bridgeContext,
|
|
||||||
envelope: buildDocumentCommandEnvelope({
|
|
||||||
name: "documents.create",
|
|
||||||
payload: {
|
|
||||||
documentId: id,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
parentId: parentId?.trim() || null,
|
|
||||||
title: "无标题",
|
|
||||||
accessScope,
|
|
||||||
content: [],
|
|
||||||
},
|
|
||||||
context: bridgeContext,
|
|
||||||
target: {
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
pageId: id,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
|
const payload = (await response.json().catch(() => null)) as TreeCreateResponse | { error?: string } | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "创建页面失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...result.result,
|
id: payload?.result?.documentId ?? "",
|
||||||
|
title: payload?.result?.title ?? "无标题",
|
||||||
|
parent_id: payload?.result?.parentId ?? null,
|
||||||
|
sort_order: payload?.result?.sortOrder ?? null,
|
||||||
|
workspace_id: payload?.result?.workspaceId ?? undefined,
|
||||||
|
access_scope: payload?.result?.execution?.access_scope ?? "private",
|
||||||
|
is_template: payload?.result?.execution?.is_template ?? false,
|
||||||
|
created_at: payload?.result?.execution?.created_at ?? null,
|
||||||
|
updated_at:
|
||||||
|
payload?.result?.execution?.updated_at ??
|
||||||
|
payload?.result?.execution?.created_at ??
|
||||||
|
null,
|
||||||
meta: {
|
meta: {
|
||||||
requestId: result.requestId,
|
requestId: payload?.requestId,
|
||||||
traceId: result.traceId,
|
traceId: payload?.traceId,
|
||||||
commandId: result.commandId,
|
commandName: "tree.node.create",
|
||||||
commandName: result.commandName,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return documentBridgeErrorResponse(error);
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "创建页面失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateRequest() {
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -1,67 +1,79 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import {
|
|
||||||
assertDocumentId,
|
|
||||||
buildDocumentBridgeContext,
|
|
||||||
buildDocumentCommandEnvelope,
|
|
||||||
documentBridgeErrorResponse,
|
|
||||||
} from "@/lib/documents/bridge";
|
|
||||||
import {
|
|
||||||
executePageLifecycleBridgeCommand,
|
|
||||||
type DocumentMovePayload,
|
|
||||||
} from "@/lib/documents/page-command-adapter";
|
|
||||||
|
|
||||||
interface MovePayload {
|
type TreeMoveResponse = {
|
||||||
documentId: string;
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
result?: {
|
||||||
|
documentId?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MovePayload = {
|
||||||
|
documentId?: string | null;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
position: number;
|
position?: number | null;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (!isConvexEnabled()) {
|
||||||
try {
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
|
|
||||||
const normalizedDocumentId = assertDocumentId(documentId);
|
|
||||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
|
||||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
|
||||||
const bridgeContext = await buildDocumentBridgeContext({
|
|
||||||
request,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
|
|
||||||
context: bridgeContext,
|
|
||||||
envelope: buildDocumentCommandEnvelope({
|
|
||||||
name: "documents.move",
|
|
||||||
payload: {
|
|
||||||
documentId: normalizedDocumentId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
parentId: parentId?.trim() || null,
|
|
||||||
sortOrder,
|
|
||||||
},
|
|
||||||
context: bridgeContext,
|
|
||||||
target: {
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
pageId: normalizedDocumentId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
meta: {
|
|
||||||
requestId: result.requestId,
|
|
||||||
traceId: result.traceId,
|
|
||||||
commandId: result.commandId,
|
|
||||||
commandName: result.commandName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return documentBridgeErrorResponse(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
try {
|
||||||
|
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
|
||||||
|
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
|
||||||
|
if (!normalizedDocumentId) {
|
||||||
|
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "move",
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
|
||||||
|
sortOrder: typeof position === "number" && Number.isFinite(position) ? Math.floor(position) : 0,
|
||||||
|
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as TreeMoveResponse | { error?: string } | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "移动失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: payload?.requestId,
|
||||||
|
traceId: payload?.traceId,
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "移动失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ import { POST as postPurge } from "@/app/api/documents/purge/route";
|
|||||||
import { POST as postTitle } from "@/app/api/documents/title/route";
|
import { POST as postTitle } from "@/app/api/documents/title/route";
|
||||||
import { POST as postOptions } from "@/app/api/documents/options/route";
|
import { POST as postOptions } from "@/app/api/documents/options/route";
|
||||||
import { POST as postSave } from "@/app/api/documents/save/route";
|
import { POST as postSave } from "@/app/api/documents/save/route";
|
||||||
|
import { POST as postCreate } from "@/app/api/documents/create/route";
|
||||||
|
import { POST as postMove } from "@/app/api/documents/move/route";
|
||||||
import { GET as getPage } from "@/app/api/documents/page/route";
|
import { GET as getPage } from "@/app/api/documents/page/route";
|
||||||
import {
|
import {
|
||||||
executeDocumentCreateChildBridgeCommand,
|
executeDocumentCreateChildBridgeCommand,
|
||||||
@@ -91,6 +93,151 @@ import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-comman
|
|||||||
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
|
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
|
||||||
|
|
||||||
describe("documents route adapters", () => {
|
describe("documents route adapters", () => {
|
||||||
|
it("create route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_create_1",
|
||||||
|
traceId: "trace_tree_create_1",
|
||||||
|
result: {
|
||||||
|
action: "create",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
documentId: "doc_new",
|
||||||
|
parentId: null,
|
||||||
|
title: "无标题",
|
||||||
|
sortOrder: 0,
|
||||||
|
updatedAt: "2026-04-23T00:00:00Z",
|
||||||
|
execution: {
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-23T00:00:00Z",
|
||||||
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postCreate(new Request("http://localhost/api/documents/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ parentId: null }),
|
||||||
|
}));
|
||||||
|
const payload = await response.json() as {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "create",
|
||||||
|
parentId: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.id).toBe("doc_new");
|
||||||
|
expect(payload.workspace_id).toBe("ws_1");
|
||||||
|
expect(payload.meta.commandName).toBe("tree.node.create");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("move route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_move_1",
|
||||||
|
traceId: "trace_tree_move_1",
|
||||||
|
result: {
|
||||||
|
action: "move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
updatedAt: "2026-04-23T00:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postMove(new Request("http://localhost/api/documents/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ documentId: "doc_1", parentId: "parent_1", position: 2.7, workspaceId: "ws_1" }),
|
||||||
|
}));
|
||||||
|
const payload = await response.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "move",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.ok).toBe(true);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.subtree.move");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
requestId: "req_tree_rename_1",
|
||||||
|
traceId: "trace_tree_rename_1",
|
||||||
|
result: {
|
||||||
|
action: "rename",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
title: "新标题",
|
||||||
|
updatedAt: "2026-04-23T00:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await postTitle(new Request("http://localhost/api/documents/title", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
|
||||||
|
}));
|
||||||
|
const payload = await response.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
meta: { commandName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost/api/tree/commands",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "rename",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
title: "新标题",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload.ok).toBe(true);
|
||||||
|
expect(payload.meta.commandName).toBe("tree.node.rename");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates child route delegates to unified adapter", async () => {
|
it("creates child route delegates to unified adapter", async () => {
|
||||||
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
|
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -146,10 +293,15 @@ describe("documents route adapters", () => {
|
|||||||
expect(payload.meta.queryName).toBe("documents.page.get");
|
expect(payload.meta.queryName).toBe("documents.page.get");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("title route delegates to unified page write adapter", async () => {
|
it("title route 在 page head 请求下仍委托 unified page write adapter", async () => {
|
||||||
await postTitle(new Request("http://localhost/api/documents/title", {
|
await postTitle(new Request("http://localhost/api/documents/title", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
|
body: JSON.stringify({
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
title: "新标题",
|
||||||
|
commandName: "page.head.updateTitle",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
|
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -17,15 +17,60 @@ interface RenamePayload {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
commandName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TreeRenameResponse = {
|
||||||
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
try {
|
try {
|
||||||
const { documentId, workspaceId, title }: RenamePayload = await request.json();
|
const { documentId, workspaceId, title, commandName }: RenamePayload = await request.json();
|
||||||
const normalizedDocumentId = assertDocumentId(documentId);
|
const normalizedDocumentId = assertDocumentId(documentId);
|
||||||
const normalizedTitle = assertTitle(title);
|
const normalizedTitle = assertTitle(title);
|
||||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
||||||
|
|
||||||
|
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
|
||||||
|
const upstreamUrl = new URL("/api/tree/commands", request.url);
|
||||||
|
const response = await fetch(upstreamUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "rename",
|
||||||
|
documentId: normalizedDocumentId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
title: normalizedTitle,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as TreeRenameResponse | { error?: string } | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
||||||
|
? payload.error
|
||||||
|
: "重命名失败,请稍后再试",
|
||||||
|
},
|
||||||
|
{ status: response.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: payload?.requestId,
|
||||||
|
traceId: payload?.traceId,
|
||||||
|
commandName: "tree.node.rename",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
|
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: PAGE_COMMAND_NAMES.updateTitle,
|
name: PAGE_COMMAND_NAMES.updateTitle,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ const mockBuildDocumentBridgeContext = vi.fn();
|
|||||||
const mockBuildDocumentQueryEnvelope = vi.fn();
|
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||||
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
||||||
const mockResolveRustBridgeQueryPlan = vi.fn();
|
const mockResolveRustBridgeQueryPlan = vi.fn();
|
||||||
|
const mockResolveKernelFileTreeProjection = vi.fn();
|
||||||
|
const mockAttachKernelFileTreeProjection = vi.fn((input: { dataset: unknown; projection: unknown }) => ({
|
||||||
|
...(input.dataset as Record<string, unknown>),
|
||||||
|
kernel_file_tree_projection: input.projection,
|
||||||
|
}));
|
||||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
Response.json(
|
Response.json(
|
||||||
{
|
{
|
||||||
@@ -29,6 +34,11 @@ vi.mock("@/lib/documents/rust-runtime", () => ({
|
|||||||
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
|
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server/kernel-file-tree", () => ({
|
||||||
|
resolveKernelFileTreeProjection: (...args: unknown[]) => mockResolveKernelFileTreeProjection(...args),
|
||||||
|
attachKernelFileTreeProjection: (...args: unknown[]) => mockAttachKernelFileTreeProjection(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("/api/mnote-web/stream route", () => {
|
describe("/api/mnote-web/stream route", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetAuthedConvexClient.mockReset();
|
mockGetAuthedConvexClient.mockReset();
|
||||||
@@ -36,11 +46,18 @@ describe("/api/mnote-web/stream route", () => {
|
|||||||
mockBuildDocumentQueryEnvelope.mockReset();
|
mockBuildDocumentQueryEnvelope.mockReset();
|
||||||
mockExecuteRustBridgeQueryTransport.mockReset();
|
mockExecuteRustBridgeQueryTransport.mockReset();
|
||||||
mockResolveRustBridgeQueryPlan.mockReset();
|
mockResolveRustBridgeQueryPlan.mockReset();
|
||||||
|
mockResolveKernelFileTreeProjection.mockReset();
|
||||||
|
mockAttachKernelFileTreeProjection.mockClear();
|
||||||
mockDocumentBridgeErrorResponse.mockClear();
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
|
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
|
||||||
mockGetAuthedConvexClient.mockResolvedValue({
|
mockGetAuthedConvexClient.mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
client: { query: vi.fn() },
|
client: { query: vi.fn() },
|
||||||
});
|
});
|
||||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
@@ -107,6 +124,13 @@ describe("/api/mnote-web/stream route", () => {
|
|||||||
has_more: false,
|
has_more: false,
|
||||||
generated_at: "2026-04-22T00:00:00Z",
|
generated_at: "2026-04-22T00:00:00Z",
|
||||||
});
|
});
|
||||||
|
mockResolveKernelFileTreeProjection.mockResolvedValue({
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { GET } = await import("./route");
|
const { GET } = await import("./route");
|
||||||
const response = await GET(
|
const response = await GET(
|
||||||
@@ -124,7 +148,103 @@ describe("/api/mnote-web/stream route", () => {
|
|||||||
expect(text).toContain('"projection":"sidebar_tree"');
|
expect(text).toContain('"projection":"sidebar_tree"');
|
||||||
expect(text).toContain('"workspaceId":"ws_1"');
|
expect(text).toContain('"workspaceId":"ws_1"');
|
||||||
expect(text).toContain('"activeWorkspaceId":"ws_1"');
|
expect(text).toContain('"activeWorkspaceId":"ws_1"');
|
||||||
|
expect(text).toContain('"kernelFileTreeProjection"');
|
||||||
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
|
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
|
||||||
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
|
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应把请求 cursor 继续透传到 overview query 和 snapshot envelope", async () => {
|
||||||
|
mockGetAuthedConvexClient.mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
|
client: { query: vi.fn() },
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_stream_2",
|
||||||
|
traceId: "trace_stream_2",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
});
|
||||||
|
mockBuildDocumentQueryEnvelope
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
name: "sidebar.dataset.list",
|
||||||
|
payload: { workspaceId: "ws_1" },
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
name: "bridge.workspace.overview",
|
||||||
|
payload: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
limit: 20,
|
||||||
|
cursor: "evt_9",
|
||||||
|
commandStatus: null,
|
||||||
|
eventStatus: null,
|
||||||
|
targetPageId: null,
|
||||||
|
targetBlockId: null,
|
||||||
|
aggregateType: null,
|
||||||
|
aggregateId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockResolveRustBridgeQueryPlan
|
||||||
|
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "query",
|
||||||
|
functionName: "bridgeLogs:listWorkspaceOverview",
|
||||||
|
argsJson: { workspaceId: "ws_1", cursor: "evt_9" },
|
||||||
|
});
|
||||||
|
mockExecuteRustBridgeQueryTransport
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
active_workspace_id: "ws_1",
|
||||||
|
workspaces: [],
|
||||||
|
documents: [],
|
||||||
|
trashed_documents: [],
|
||||||
|
media_assets: [],
|
||||||
|
trashed_media_assets: [],
|
||||||
|
mindmap_assets: [],
|
||||||
|
trashed_mindmap_assets: [],
|
||||||
|
table_assets: [],
|
||||||
|
trashed_table_assets: [],
|
||||||
|
mindmap_docs: [],
|
||||||
|
mindmap_asset_children: {},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [],
|
||||||
|
counts: { command_logs: 0, domain_events: 0 },
|
||||||
|
filters: null,
|
||||||
|
next_cursor: "evt_10",
|
||||||
|
has_more: true,
|
||||||
|
generated_at: "2026-04-22T00:00:00Z",
|
||||||
|
});
|
||||||
|
mockResolveKernelFileTreeProjection.mockResolvedValue({
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET(
|
||||||
|
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9", {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const text = await response.text();
|
||||||
|
expect(text).toContain('"cursor":"evt_9"');
|
||||||
|
expect(mockBuildDocumentQueryEnvelope).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
cursor: "evt_9",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
resolveRustBridgeQueryPlan,
|
resolveRustBridgeQueryPlan,
|
||||||
} from "@/lib/documents/rust-runtime";
|
} from "@/lib/documents/rust-runtime";
|
||||||
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
|
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
|
||||||
|
import {
|
||||||
|
attachKernelFileTreeProjection,
|
||||||
|
resolveKernelFileTreeProjection,
|
||||||
|
} from "@/lib/server/kernel-file-tree";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -26,7 +30,7 @@ export async function GET(request: Request) {
|
|||||||
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
|
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const context = await buildDocumentBridgeContext({
|
const context = await buildDocumentBridgeContext({
|
||||||
request,
|
request,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -46,6 +50,20 @@ export async function GET(request: Request) {
|
|||||||
client,
|
client,
|
||||||
plan: sidebarPlan,
|
plan: sidebarPlan,
|
||||||
});
|
});
|
||||||
|
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
|
||||||
|
dataset: sidebarDataset,
|
||||||
|
projection: await resolveKernelFileTreeProjection({
|
||||||
|
client,
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
dataset: sidebarDataset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const overviewEnvelope = buildDocumentQueryEnvelope({
|
const overviewEnvelope = buildDocumentQueryEnvelope({
|
||||||
name: "bridge.workspace.overview",
|
name: "bridge.workspace.overview",
|
||||||
@@ -79,10 +97,13 @@ export async function GET(request: Request) {
|
|||||||
cursor,
|
cursor,
|
||||||
requestId: context.requestId,
|
requestId: context.requestId,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
|
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
|
||||||
snapshot: {
|
snapshot: {
|
||||||
dataset: sidebarDataset,
|
dataset: sidebarDatasetWithFileTree,
|
||||||
tree: sidebarDataset.kernel_sidebar_projection ?? sidebarDataset.kernelSidebarProjection ?? null,
|
tree:
|
||||||
|
sidebarDatasetWithFileTree.kernel_sidebar_projection ??
|
||||||
|
sidebarDatasetWithFileTree.kernelSidebarProjection ??
|
||||||
|
null,
|
||||||
},
|
},
|
||||||
overview,
|
overview,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
mapSidebarDatasetListQueryResultToInitialData,
|
mapSidebarDatasetListQueryResultToInitialData,
|
||||||
type SidebarDatasetListQueryResult,
|
type SidebarDatasetListQueryResult,
|
||||||
} from "@/lib/sidebar-data";
|
} from "@/lib/sidebar-data";
|
||||||
|
import {
|
||||||
|
attachKernelFileTreeProjection,
|
||||||
|
resolveKernelFileTreeProjection,
|
||||||
|
} from "@/lib/server/kernel-file-tree";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -54,7 +58,21 @@ export async function GET(request: Request) {
|
|||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDataset);
|
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
|
||||||
|
dataset: sidebarDataset,
|
||||||
|
projection: await resolveKernelFileTreeProjection({
|
||||||
|
client,
|
||||||
|
request,
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
dataset: sidebarDataset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockGetAuthedConvexClient = vi.fn();
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
||||||
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const mockEnsureDocumentScaffold = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
documents: {
|
||||||
|
getMeta: "documents:getMeta",
|
||||||
|
},
|
||||||
|
workspaces: {
|
||||||
|
ensureDefaultWorkspace: "workspaces:ensureDefaultWorkspace",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/route", () => ({
|
||||||
|
getAuthedConvexClient: mockGetAuthedConvexClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
assertDocumentId: (value: string | null | undefined) => {
|
||||||
|
const normalized = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("缺少 documentId");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
},
|
||||||
|
assertTitle: (value: string | null | undefined) => {
|
||||||
|
const normalized = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("缺少标题");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
},
|
||||||
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/page-lifecycle-side-effects", () => ({
|
||||||
|
ensureDocumentScaffold: (...args: unknown[]) => mockEnsureDocumentScaffold(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/tree/commands route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(true);
|
||||||
|
mockGetAuthedConvexClient.mockReset();
|
||||||
|
mockBuildDocumentBridgeContext.mockReset();
|
||||||
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||||||
|
mockExecuteRustBridgeMutationTransport.mockReset();
|
||||||
|
mockEnsureDocumentScaffold.mockReset();
|
||||||
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create action 走 tree.node.create,并保留本地 scaffold 副作用", async () => {
|
||||||
|
const client = {
|
||||||
|
mutation: vi.fn(async () => ({ activeWorkspaceId: "ws_root" })),
|
||||||
|
query: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetAuthedConvexClient.mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_tree_1",
|
||||||
|
traceId: "trace_tree_1",
|
||||||
|
workspaceId: "ws_root",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.node.create",
|
||||||
|
commandId: "cmd_tree_create_1",
|
||||||
|
functionName: "documents:createWithParentReference",
|
||||||
|
workspaceId: "ws_root",
|
||||||
|
requestId: "req_tree_1",
|
||||||
|
traceId: "trace_tree_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {},
|
||||||
|
});
|
||||||
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
|
id: "doc_new",
|
||||||
|
title: "无标题",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
workspace_id: "ws_root",
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-23T00:00:00Z",
|
||||||
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "create",
|
||||||
|
parentId: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const payload = await response.json() as {
|
||||||
|
result: {
|
||||||
|
action: string;
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(payload.result.action).toBe("create");
|
||||||
|
expect(payload.result.documentId).toBe("doc_new");
|
||||||
|
expect(payload.result.workspaceId).toBe("ws_root");
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.node.create",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_root",
|
||||||
|
parentId: null,
|
||||||
|
title: "无标题",
|
||||||
|
accessScope: "private",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
|
||||||
|
const client = {
|
||||||
|
mutation: vi.fn(),
|
||||||
|
query: vi.fn(async () => ({
|
||||||
|
id: "doc_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
mockGetAuthedConvexClient.mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_tree_2",
|
||||||
|
traceId: "trace_tree_2",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
commandId: "cmd_tree_move_1",
|
||||||
|
functionName: "documents:move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_tree_2",
|
||||||
|
traceId: "trace_tree_2",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {},
|
||||||
|
});
|
||||||
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "move",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2.9,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const payload = await response.json() as {
|
||||||
|
result: {
|
||||||
|
action: string;
|
||||||
|
documentId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
sortOrder: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(payload.result).toMatchObject({
|
||||||
|
action: "move",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.subtree.move",
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rename action 走 tree.node.rename,并保留标题 payload", async () => {
|
||||||
|
const client = {
|
||||||
|
mutation: vi.fn(),
|
||||||
|
query: vi.fn(async () => ({
|
||||||
|
id: "doc_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
mockGetAuthedConvexClient.mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "开发用户",
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_tree_3",
|
||||||
|
traceId: "trace_tree_3",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.node.rename",
|
||||||
|
commandId: "cmd_tree_rename_1",
|
||||||
|
functionName: "documents:updateTitle",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_tree_3",
|
||||||
|
traceId: "trace_tree_3",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {},
|
||||||
|
});
|
||||||
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "rename",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
title: "新标题",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const payload = await response.json() as {
|
||||||
|
result: {
|
||||||
|
action: string;
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(payload.result).toMatchObject({
|
||||||
|
action: "rename",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
title: "新标题",
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.node.rename",
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
title: "新标题",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
assertDocumentId,
|
||||||
|
assertTitle,
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type TreeCommandPayload = {
|
||||||
|
action?: "create" | "move" | "rename";
|
||||||
|
workspaceId?: string | null;
|
||||||
|
documentId?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
accessScope?: "private" | "shared" | "public" | null;
|
||||||
|
content?: unknown;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(value: unknown) {
|
||||||
|
const title = typeof value === "string" ? value.trim() : "";
|
||||||
|
return title || "无标题";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortOrder(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isConvexEnabled()) {
|
||||||
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as TreeCommandPayload;
|
||||||
|
switch (payload.action) {
|
||||||
|
case "create":
|
||||||
|
return handleCreate(request, payload);
|
||||||
|
case "move":
|
||||||
|
return handleMove(request, payload);
|
||||||
|
case "rename":
|
||||||
|
return handleRename(request, payload);
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const parentId = trimOrNull(payload.parentId);
|
||||||
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||||
|
workspaceIdIfCreate: randomUUID(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let workspaceId: string | null = trimOrNull(payload.workspaceId);
|
||||||
|
let accessScope: "private" | "shared" | "public" = "private";
|
||||||
|
if (parentId) {
|
||||||
|
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||||
|
if (!parentDoc) {
|
||||||
|
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||||
|
}
|
||||||
|
workspaceId = trimOrNull(parentDoc.workspace_id);
|
||||||
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||||
|
} else if (!workspaceId) {
|
||||||
|
workspaceId = trimOrNull(workspaceBootstrap.activeWorkspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
||||||
|
const title = normalizeTitle(payload.title);
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.node.create",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
title,
|
||||||
|
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
|
||||||
|
content: Array.isArray(payload.content) ? payload.content : [],
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
reason: "tree-route create",
|
||||||
|
refs: ["next-tree-route"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
sort_order: number | null;
|
||||||
|
workspace_id: string;
|
||||||
|
access_scope: "private" | "shared" | "public";
|
||||||
|
is_template: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureDocumentScaffold(result.id, result.title ?? title);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "create",
|
||||||
|
workspaceId,
|
||||||
|
documentId: result.id,
|
||||||
|
parentId: result.parent_id ?? parentId,
|
||||||
|
title: result.title ?? title,
|
||||||
|
sortOrder: result.sort_order ?? null,
|
||||||
|
updatedAt: result.updated_at ?? null,
|
||||||
|
execution: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMove(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
|
const parentId = trimOrNull(payload.parentId);
|
||||||
|
const sortOrder = normalizeSortOrder(payload.sortOrder);
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.subtree.move",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
parentId,
|
||||||
|
sortOrder,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
reason: "tree-route move",
|
||||||
|
refs: ["next-tree-route"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "move",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
parentId,
|
||||||
|
sortOrder,
|
||||||
|
updatedAt: result?.updated_at ?? null,
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename(request: Request, payload: TreeCommandPayload) {
|
||||||
|
const { client } = await getAuthedConvexClient();
|
||||||
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
if (!sourceDoc) {
|
||||||
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
|
const title = assertTitle(payload.title ?? null);
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.node.rename",
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
reason: "tree-route rename",
|
||||||
|
refs: ["next-tree-route"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: {
|
||||||
|
action: "rename",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
title,
|
||||||
|
updatedAt: result?.updated_at ?? null,
|
||||||
|
execution: result ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockResolveMnoteWebInternalUrl = vi.fn();
|
||||||
|
const mockBuildForwardHeaders = vi.fn();
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/mnote-web/internal-url", () => ({
|
||||||
|
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server/forward-headers", () => ({
|
||||||
|
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/tree/shell route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockResolveMnoteWebInternalUrl.mockReset();
|
||||||
|
mockBuildForwardHeaders.mockReset();
|
||||||
|
mockFetch.mockReset();
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
|
||||||
|
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
|
||||||
|
mockBuildForwardHeaders.mockResolvedValue(
|
||||||
|
new Headers({
|
||||||
|
cookie: "session=abc",
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
new Response("<!doctype html><html><body>tree shell</body></html>", {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
"set-cookie": "debug=1",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"x-upstream": "mnote-web-tree",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET(
|
||||||
|
new Request(
|
||||||
|
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
cookie: "session=abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
headers: expect.any(Headers),
|
||||||
|
cache: "no-store",
|
||||||
|
redirect: "manual",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
|
||||||
|
expect(fetchHeaders.get("cookie")).toBe("session=abc");
|
||||||
|
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
|
||||||
|
expect(fetchHeaders.get("accept")).toContain("text/html");
|
||||||
|
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_shell_proxy");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toContain("text/html");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(response.headers.get("connection")).toBeNull();
|
||||||
|
expect(response.headers.get("x-upstream")).toBe("mnote-web-tree");
|
||||||
|
expect(await response.text()).toContain("tree shell");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
|
||||||
|
import { buildForwardHeaders } from "@/lib/server/forward-headers";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const stripHopByHopHeaders = (headers: Headers) => {
|
||||||
|
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
|
||||||
|
const hopByHopHeaders = [
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"content-length",
|
||||||
|
];
|
||||||
|
hopByHopHeaders.forEach((name) => headers.delete(name));
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||||
|
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
|
||||||
|
targetUrl.search = requestUrl.search;
|
||||||
|
|
||||||
|
const headers = await buildForwardHeaders(request);
|
||||||
|
headers.set("accept", "text/html,application/xhtml+xml");
|
||||||
|
headers.set("x-mnote-source-channel", "next_tree_shell_proxy");
|
||||||
|
headers.set("x-mnote-source-client", "wolai-frontend");
|
||||||
|
|
||||||
|
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
|
||||||
|
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
|
||||||
|
headers.set("x-mnote-workspace-id", workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(targetUrl.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const body = await upstream.arrayBuffer();
|
||||||
|
const responseHeaders = new Headers(upstream.headers);
|
||||||
|
stripHopByHopHeaders(responseHeaders);
|
||||||
|
responseHeaders.delete("set-cookie");
|
||||||
|
responseHeaders.set("cache-control", "no-store");
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "tree shell 代理失败";
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
|
|||||||
initialData,
|
initialData,
|
||||||
sidebarQueryData: sidebarQuery.data,
|
sidebarQueryData: sidebarQuery.data,
|
||||||
treeStreamData: treeStream.data,
|
treeStreamData: treeStream.data,
|
||||||
|
treeStreamStatus: treeStream.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ vi.mock("@tanstack/react-query", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockUseDocumentSearch = vi.fn(() => ({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-document-search", () => ({
|
vi.mock("@/hooks/use-document-search", () => ({
|
||||||
useDocumentSearch: () => ({
|
useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args),
|
||||||
data: null,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/ui/dialog", () => ({
|
vi.mock("@/components/ui/dialog", () => ({
|
||||||
@@ -100,6 +102,13 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
});
|
});
|
||||||
container.remove();
|
container.remove();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockUseDocumentSearch.mockReset();
|
||||||
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
delete window.__MNOTE_RUNTIME_CONFIG__;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("空查询时会使用统一 picker surface 并透传根目录选择", async () => {
|
it("空查询时会使用统一 picker surface 并透传根目录选择", async () => {
|
||||||
@@ -131,4 +140,130 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
expect(onPick).toHaveBeenCalledWith("move", null);
|
expect(onPick).toHaveBeenCalledWith("move", null);
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("搜索结果也应继续复用统一 picker surface", async () => {
|
||||||
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "doc_target",
|
||||||
|
title: "目标页面",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPick = vi.fn(async () => undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||||
|
configurable: true,
|
||||||
|
value: "目标",
|
||||||
|
});
|
||||||
|
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerSurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
|
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||||
|
expect(pickerSurface).not.toBeNull();
|
||||||
|
expect(pickerRows).toHaveLength(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pickerRows[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onPick).toHaveBeenCalledWith("move", "doc_target");
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
|
||||||
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
|
treeRendererFamily: "rust_family",
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPick = vi.fn(async () => undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MoveEmbedPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
workspaceId="ws_test"
|
||||||
|
defaultMode="move"
|
||||||
|
allowRoot
|
||||||
|
excludeIds={[]}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
|
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
mockUseDocumentSearch.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "doc_target",
|
||||||
|
title: "目标页面",
|
||||||
|
matchField: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector("input");
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||||
|
configurable: true,
|
||||||
|
value: "目标",
|
||||||
|
});
|
||||||
|
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
|
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||||
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||||
|
|
||||||
@@ -106,6 +106,7 @@ function MoveEmbedPickerDialogBody({
|
|||||||
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlighted, setHighlighted] = useState(0);
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
|
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
||||||
|
|
||||||
const handleModeChange = useCallback((value: string) => {
|
const handleModeChange = useCallback((value: string) => {
|
||||||
setMode(value as MoveEmbedMode);
|
setMode(value as MoveEmbedMode);
|
||||||
@@ -207,6 +208,11 @@ function MoveEmbedPickerDialogBody({
|
|||||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||||
) : (
|
) : (
|
||||||
<TreePickerSurface
|
<TreePickerSurface
|
||||||
|
rendererFamily={treeRendererFamily}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
treeShellEnabled={isEmptyQuery}
|
||||||
|
allowRootPick={allowRoot && mode === "move"}
|
||||||
|
excludeIds={excludeIds}
|
||||||
items={items}
|
items={items}
|
||||||
highlighted={highlighted}
|
highlighted={highlighted}
|
||||||
onHighlight={setHighlighted}
|
onHighlight={setHighlighted}
|
||||||
@@ -278,28 +284,21 @@ function MoveEmbedPickerDialogBody({
|
|||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2">
|
<TreePickerSurface
|
||||||
{items.map((item, idx) => (
|
rendererFamily={treeRendererFamily}
|
||||||
<button
|
workspaceId={workspaceId}
|
||||||
key={item.kind === "root" ? "root" : item.id}
|
treeShellEnabled={Boolean(workspaceId)}
|
||||||
type="button"
|
allowRootPick={false}
|
||||||
className={cn(
|
excludeIds={excludeIds}
|
||||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
treeShellItems={items}
|
||||||
idx === highlighted && "bg-[#e3ecff]",
|
items={items}
|
||||||
)}
|
highlighted={highlighted}
|
||||||
onMouseEnter={() => setHighlighted(idx)}
|
className="py-2"
|
||||||
onClick={() => void handlePick(item.id)}
|
onHighlight={setHighlighted}
|
||||||
>
|
onPick={(targetId) => {
|
||||||
<div
|
void handlePick(targetId);
|
||||||
className="text-sm font-medium text-gray-900"
|
}}
|
||||||
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
/>
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
|
|
||||||
type SidebarTreeSnapshot = {
|
type SidebarTreeSnapshot = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +43,20 @@ function toMillis(value: string | null | undefined): number {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeKernelFileTreeProjection(
|
||||||
|
projection: SidebarInitialData["kernelFileTreeProjection"] | undefined,
|
||||||
|
): KernelFileTreeProjection {
|
||||||
|
return (
|
||||||
|
projection ?? {
|
||||||
|
projectionId: "kernel_projection:file_tree:missing",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSidebarTreeSyncKey(nodes: SidebarTreeNode[]): string {
|
export function buildSidebarTreeSyncKey(nodes: SidebarTreeNode[]): string {
|
||||||
return JSON.stringify(toSidebarTreeSnapshot(nodes));
|
return JSON.stringify(toSidebarTreeSnapshot(nodes));
|
||||||
}
|
}
|
||||||
@@ -51,9 +66,31 @@ export function buildMediaAssetListSyncKey(assets: MediaAsset[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
||||||
|
const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
activeWorkspaceId: data.activeWorkspaceId,
|
activeWorkspaceId: data.activeWorkspaceId,
|
||||||
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
|
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
|
||||||
|
fileTree: {
|
||||||
|
projectionId: fileTreeProjection.projectionId,
|
||||||
|
rootNodeId: fileTreeProjection.rootNodeId,
|
||||||
|
items: fileTreeProjection.items.map((item) => ({
|
||||||
|
rowId: item.rowId,
|
||||||
|
rowKind: item.rowKind,
|
||||||
|
nodeId: item.nodeId,
|
||||||
|
parentNodeId: item.parentNodeId,
|
||||||
|
title: item.title,
|
||||||
|
depth: item.depth,
|
||||||
|
position: item.position,
|
||||||
|
childCount: item.childCount,
|
||||||
|
expandable: item.expandable,
|
||||||
|
expandedByDefault: item.expandedByDefault,
|
||||||
|
iconHint: item.iconHint ?? null,
|
||||||
|
resourceKind: item.resourceMeta.resourceKind,
|
||||||
|
documentId: item.resourceMeta.documentId ?? null,
|
||||||
|
assetId: item.resourceMeta.assetId ?? null,
|
||||||
|
assetKind: item.resourceMeta.assetKind ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
mediaAssets: toMediaAssetSnapshot(data.mediaAssets ?? []),
|
mediaAssets: toMediaAssetSnapshot(data.mediaAssets ?? []),
|
||||||
mindmapAssets: toMediaAssetSnapshot(data.mindmapAssets ?? []),
|
mindmapAssets: toMediaAssetSnapshot(data.mindmapAssets ?? []),
|
||||||
tableAssets: toMediaAssetSnapshot(data.tableAssets ?? []),
|
tableAssets: toMediaAssetSnapshot(data.tableAssets ?? []),
|
||||||
@@ -73,6 +110,8 @@ export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
|||||||
export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
||||||
const documentTimes = data.documents.map((item) => toMillis(item.updated_at ?? item.created_at));
|
const documentTimes = data.documents.map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||||
const treeTimes = data.kernelSidebarTree.map((item) => toMillis(item.updated_at ?? item.created_at));
|
const treeTimes = data.kernelSidebarTree.map((item) => toMillis(item.updated_at ?? item.created_at));
|
||||||
|
const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||||
|
const fileTreeTimes = fileTreeProjection.items.map((item) => item.position ?? 0);
|
||||||
const assetTimes = [
|
const assetTimes = [
|
||||||
...(data.mediaAssets ?? []),
|
...(data.mediaAssets ?? []),
|
||||||
...(data.mindmapAssets ?? []),
|
...(data.mindmapAssets ?? []),
|
||||||
@@ -87,6 +126,7 @@ export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
|||||||
0,
|
0,
|
||||||
...documentTimes,
|
...documentTimes,
|
||||||
...treeTimes,
|
...treeTimes,
|
||||||
|
...fileTreeTimes,
|
||||||
...assetTimes,
|
...assetTimes,
|
||||||
...trashedDocumentTimes,
|
...trashedDocumentTimes,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
initialData,
|
initialData,
|
||||||
sidebarQueryData: sidebarQuery.data,
|
sidebarQueryData: sidebarQuery.data,
|
||||||
treeStreamData: treeStream.data,
|
treeStreamData: treeStream.data,
|
||||||
|
treeStreamStatus: treeStream.status,
|
||||||
});
|
});
|
||||||
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
|
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
|
||||||
|
|
||||||
@@ -211,6 +212,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const { signOut } = useAuthActions();
|
const { signOut } = useAuthActions();
|
||||||
const activeId = segments?.[1] ?? "";
|
const activeId = segments?.[1] ?? "";
|
||||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||||
|
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
||||||
|
|
||||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
@@ -673,18 +675,28 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const resourceRows = useMemo(
|
const resourceRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildVisibleRows({
|
buildVisibleRows({
|
||||||
|
fileTreeItems:
|
||||||
|
filter.trim().length === 0
|
||||||
|
? sidebarData.kernelFileTreeProjection.items
|
||||||
|
: undefined,
|
||||||
pageRows: visibleFilteredPrivatePageRows,
|
pageRows: visibleFilteredPrivatePageRows,
|
||||||
expanded,
|
expanded,
|
||||||
assetsByDoc,
|
assetsByDoc,
|
||||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||||
expandedAssetFolderIds: expandedAssetFolders,
|
expandedAssetFolderIds: expandedAssetFolders,
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
assetById,
|
||||||
assetsByDoc,
|
assetsByDoc,
|
||||||
expanded,
|
expanded,
|
||||||
expandedAssetFolders,
|
expandedAssetFolders,
|
||||||
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||||
|
nodeById,
|
||||||
|
sidebarData.kernelFileTreeProjection.items,
|
||||||
visibleFilteredPrivatePageRows,
|
visibleFilteredPrivatePageRows,
|
||||||
|
filter,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -967,6 +979,118 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePageTreeShellNavigate = useCallback(
|
||||||
|
(documentId: string) => {
|
||||||
|
handleOpenDocument(documentId, "sidebar");
|
||||||
|
},
|
||||||
|
[handleOpenDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileTreeShellNavigate = useCallback(
|
||||||
|
(documentId: string) => {
|
||||||
|
handleOpenDocument(documentId, "main");
|
||||||
|
},
|
||||||
|
[handleOpenDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageTreeShellContextMenu = useCallback(
|
||||||
|
(payload: { documentId: string; x: number; y: number }) => {
|
||||||
|
const node = nodeById.get(payload.documentId);
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContextMenu({
|
||||||
|
node,
|
||||||
|
x: payload.x,
|
||||||
|
y: payload.y,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodeById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileTreeShellContextMenu = useCallback(
|
||||||
|
(payload: {
|
||||||
|
documentId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
rowId: string | null;
|
||||||
|
rowKind: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}) => {
|
||||||
|
if (payload.assetId) {
|
||||||
|
const asset = assetById.get(payload.assetId);
|
||||||
|
if (asset) {
|
||||||
|
setAssetMenu({
|
||||||
|
asset,
|
||||||
|
x: payload.x,
|
||||||
|
y: payload.y,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = payload.rowId ? resourceRowById.get(payload.rowId) : null;
|
||||||
|
const node =
|
||||||
|
row && (row.kind === "doc" || row.kind === "index")
|
||||||
|
? row.node
|
||||||
|
: payload.documentId
|
||||||
|
? nodeById.get(payload.documentId) ?? null
|
||||||
|
: null;
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContextMenu({
|
||||||
|
node,
|
||||||
|
x: payload.x,
|
||||||
|
y: payload.y,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[assetById, nodeById, resourceRowById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileTreeShellSelectionChange = useCallback(
|
||||||
|
(payload: {
|
||||||
|
selectedRowIds: string[];
|
||||||
|
anchorRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
}) => {
|
||||||
|
const visibleRowIds = resourceRows.map((row) => row.rowId);
|
||||||
|
const normalized = normalizeTreePaneSelectionForVisibleRows(
|
||||||
|
{
|
||||||
|
selectedRowIds: new Set(
|
||||||
|
payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)),
|
||||||
|
),
|
||||||
|
anchorRowId:
|
||||||
|
payload.anchorRowId && resourceRowById.has(payload.anchorRowId)
|
||||||
|
? payload.anchorRowId
|
||||||
|
: null,
|
||||||
|
focusedRowId:
|
||||||
|
payload.focusedRowId && resourceRowById.has(payload.focusedRowId)
|
||||||
|
? payload.focusedRowId
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
visibleRowIds,
|
||||||
|
);
|
||||||
|
setResourceSelection(normalized);
|
||||||
|
},
|
||||||
|
[resourceRowById, resourceRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileTreeShellAssetOpen = useCallback(
|
||||||
|
(payload: { assetId: string; documentId: string | null }) => {
|
||||||
|
const asset = assetById.get(payload.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleOpenAsset(asset);
|
||||||
|
},
|
||||||
|
[assetById, handleOpenAsset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTreeShellMutation = useCallback(() => {
|
||||||
|
void refreshTree();
|
||||||
|
}, [refreshTree]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = async (event: KeyboardEvent) => {
|
const handler = async (event: KeyboardEvent) => {
|
||||||
const isCopy =
|
const isCopy =
|
||||||
@@ -2593,6 +2717,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
<div className="flex-1 px-1 pb-2">
|
<div className="flex-1 px-1 pb-2">
|
||||||
<SidebarTreeSurface
|
<SidebarTreeSurface
|
||||||
mode="page"
|
mode="page"
|
||||||
|
rendererFamily={treeRendererFamily}
|
||||||
|
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||||
|
treeShellEnabled={filter.trim().length === 0}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
rows={visibleFilteredPrivatePageRows}
|
rows={visibleFilteredPrivatePageRows}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
@@ -2601,6 +2728,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onCreateChild={handleCreate}
|
onCreateChild={handleCreate}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
|
onNavigate={handlePageTreeShellNavigate}
|
||||||
|
onPageContextMenu={handlePageTreeShellContextMenu}
|
||||||
|
onTreeMutation={handleTreeShellMutation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -2618,6 +2748,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
>
|
>
|
||||||
<SidebarTreeSurface
|
<SidebarTreeSurface
|
||||||
mode="filetree"
|
mode="filetree"
|
||||||
|
rendererFamily={treeRendererFamily}
|
||||||
|
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||||
|
treeShellEnabled={filter.trim().length === 0}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
rows={resourceRows}
|
rows={resourceRows}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
@@ -2635,6 +2768,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
onBlankMouseDown={handleResourcePaneBlankMouseDown}
|
onBlankMouseDown={handleResourcePaneBlankMouseDown}
|
||||||
onDropFiles={handleResourcePaneDropFiles}
|
onDropFiles={handleResourcePaneDropFiles}
|
||||||
onInternalDrop={handleResourcePaneInternalDrop}
|
onInternalDrop={handleResourcePaneInternalDrop}
|
||||||
|
onNavigate={handleFileTreeShellNavigate}
|
||||||
|
onFileTreeContextMenu={handleFileTreeShellContextMenu}
|
||||||
|
onFileTreeSelectionChange={handleFileTreeShellSelectionChange}
|
||||||
|
onAssetOpen={handleFileTreeShellAssetOpen}
|
||||||
|
onTreeMutation={handleTreeShellMutation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
TreeShellIframeHost,
|
||||||
|
type TreeShellPickerItem,
|
||||||
|
} from "@/components/sidebar/tree-shell-iframe-host";
|
||||||
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type TreeRendererFamily = "react" | "rust_family";
|
||||||
|
|
||||||
|
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
||||||
|
|
||||||
|
type TreeShellHostProps = {
|
||||||
|
mode: TreeShellHostMode;
|
||||||
|
surfaceTestId: string;
|
||||||
|
rendererFamily?: TreeRendererFamily;
|
||||||
|
className?: string;
|
||||||
|
treeShellEnabled?: boolean;
|
||||||
|
workspaceId?: string | null;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
allowRootPick?: boolean;
|
||||||
|
excludeIds?: string[];
|
||||||
|
pickerItems?: TreeShellPickerItem[];
|
||||||
|
fileTreeRows?: FileTreeRow[];
|
||||||
|
channel?: string;
|
||||||
|
host?: string;
|
||||||
|
onNavigate?: (documentId: string) => void;
|
||||||
|
onPick?: (targetId: string | null) => void;
|
||||||
|
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||||
|
onFileTreeContextMenu?: (payload: {
|
||||||
|
documentId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
rowId: string | null;
|
||||||
|
rowKind: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}) => void;
|
||||||
|
onFileTreeSelectionChange?: (payload: {
|
||||||
|
selectedRowIds: string[];
|
||||||
|
anchorRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
}) => void;
|
||||||
|
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||||
|
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||||
|
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||||
|
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TreeShellHost({
|
||||||
|
mode,
|
||||||
|
surfaceTestId,
|
||||||
|
rendererFamily = "react",
|
||||||
|
className,
|
||||||
|
treeShellEnabled = true,
|
||||||
|
workspaceId = null,
|
||||||
|
rootNodeId = null,
|
||||||
|
activeDocumentId = null,
|
||||||
|
allowRootPick = false,
|
||||||
|
excludeIds = [],
|
||||||
|
pickerItems = [],
|
||||||
|
fileTreeRows = [],
|
||||||
|
channel,
|
||||||
|
host,
|
||||||
|
onNavigate,
|
||||||
|
onPick,
|
||||||
|
onPageContextMenu,
|
||||||
|
onFileTreeContextMenu,
|
||||||
|
onFileTreeSelectionChange,
|
||||||
|
onInternalDrop,
|
||||||
|
onDropFiles,
|
||||||
|
onAssetOpen,
|
||||||
|
onTreeMutation,
|
||||||
|
children,
|
||||||
|
}: TreeShellHostProps) {
|
||||||
|
const useRustHost = rendererFamily === "rust_family";
|
||||||
|
const useIframeHost = useRustHost && treeShellEnabled && Boolean(workspaceId?.trim());
|
||||||
|
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
||||||
|
const implementation = useIframeHost
|
||||||
|
? "mnote_web_iframe_proxy"
|
||||||
|
: rendererFamily === "rust_family"
|
||||||
|
? "react_fallback"
|
||||||
|
: "react_primary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={surfaceTestId}
|
||||||
|
data-shell-mode={mode}
|
||||||
|
data-renderer-family={rendererFamily}
|
||||||
|
data-tree-host-kind={hostKind}
|
||||||
|
data-tree-host-implementation={implementation}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
{useRustHost ? (
|
||||||
|
<div
|
||||||
|
data-testid={`${surfaceTestId}-rust-host`}
|
||||||
|
data-tree-host-mode={mode}
|
||||||
|
data-tree-host-kind="rust_family"
|
||||||
|
data-tree-host-implementation={implementation}
|
||||||
|
className="contents"
|
||||||
|
>
|
||||||
|
{useIframeHost && workspaceId ? (
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode={mode}
|
||||||
|
surfaceTestId={surfaceTestId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
rootNodeId={rootNodeId}
|
||||||
|
activeDocumentId={activeDocumentId}
|
||||||
|
allowRootPick={allowRootPick}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
pickerItems={pickerItems}
|
||||||
|
fileTreeRows={fileTreeRows}
|
||||||
|
channel={channel}
|
||||||
|
host={host}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
onPick={onPick}
|
||||||
|
onPageContextMenu={onPageContextMenu}
|
||||||
|
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||||
|
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||||
|
onInternalDrop={onInternalDrop}
|
||||||
|
onDropFiles={onDropFiles}
|
||||||
|
onAssetOpen={onAssetOpen}
|
||||||
|
onTreeMutation={onTreeMutation}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
TreeShellIframeHost,
|
||||||
|
buildTreeShellIframeSrc,
|
||||||
|
buildTreeShellInlinePickerItems,
|
||||||
|
injectTreeShellInlineOverrides,
|
||||||
|
} from "./tree-shell-iframe-host";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("tree-shell-iframe-host", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应构造同源 tree shell iframe 地址,并透传 picker 所需 query", () => {
|
||||||
|
const src = buildTreeShellIframeSrc({
|
||||||
|
mode: "picker",
|
||||||
|
workspaceId: "ws_picker",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
allowRootPick: true,
|
||||||
|
excludeIds: ["doc_hidden", "doc_other"],
|
||||||
|
channel: "tree-picker-surface",
|
||||||
|
host: "tree-picker-surface",
|
||||||
|
});
|
||||||
|
const url = new URL(src, "http://127.0.0.1:3000");
|
||||||
|
|
||||||
|
expect(url.pathname).toBe("/api/tree/shell");
|
||||||
|
expect(url.searchParams.get("workspaceId")).toBe("ws_picker");
|
||||||
|
expect(url.searchParams.get("mode")).toBe("picker");
|
||||||
|
expect(url.searchParams.get("activeDocumentId")).toBe("doc_active");
|
||||||
|
expect(url.searchParams.get("allowRootPick")).toBe("1");
|
||||||
|
expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other");
|
||||||
|
expect(url.searchParams.get("channel")).toBe("tree-picker-surface");
|
||||||
|
expect(url.searchParams.get("host")).toBe("tree-picker-surface");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应能把 picker 搜索结果转换为 inline shell items 并注入到 HTML", () => {
|
||||||
|
const pickerItems = buildTreeShellInlinePickerItems([
|
||||||
|
{ kind: "doc", id: "doc_target", title: "目标页面", depth: 0 },
|
||||||
|
{ kind: "doc", id: "doc_recent", title: "最近</script>打开", depth: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(pickerItems).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "doc_target",
|
||||||
|
parentNodeId: null,
|
||||||
|
title: "目标页面",
|
||||||
|
depth: 0,
|
||||||
|
childCount: 0,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
nodeId: "doc_recent",
|
||||||
|
parentNodeId: null,
|
||||||
|
title: "最近</script>打开",
|
||||||
|
depth: 0,
|
||||||
|
childCount: 0,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const html = injectTreeShellInlineOverrides(
|
||||||
|
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||||
|
{ items: pickerItems },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||||
|
expect(html).toContain("\\u003c/script\\u003e");
|
||||||
|
expect(html.indexOf("__MNOTE_TREE_SHELL_OVERRIDE__")).toBeGreaterThan(-1);
|
||||||
|
expect(html.indexOf("__MNOTE_TREE_SHELL_OVERRIDE__")).toBeLessThan(
|
||||||
|
html.indexOf('id="tree-shell-state"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => {
|
||||||
|
const onNavigate = vi.fn();
|
||||||
|
const onPageContextMenu = vi.fn();
|
||||||
|
const onPick = vi.fn();
|
||||||
|
const onFileTreeContextMenu = vi.fn();
|
||||||
|
const onFileTreeSelectionChange = vi.fn();
|
||||||
|
const onAssetOpen = vi.fn();
|
||||||
|
const onTreeMutation = vi.fn();
|
||||||
|
const onInternalDrop = vi.fn();
|
||||||
|
const onDropFiles = vi.fn();
|
||||||
|
const targetRow = {
|
||||||
|
kind: "doc",
|
||||||
|
rowId: "doc:doc_target",
|
||||||
|
depth: 0,
|
||||||
|
docId: "doc_target",
|
||||||
|
parentDocId: null,
|
||||||
|
node: {
|
||||||
|
_id: "doc_target",
|
||||||
|
id: "doc_target",
|
||||||
|
title: "目标页面",
|
||||||
|
},
|
||||||
|
hasChildren: false,
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="filetree"
|
||||||
|
surfaceTestId="sidebar-file-tree-shell"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_1"
|
||||||
|
channel="sidebar-file-tree-shell"
|
||||||
|
host="sidebar-file-tree-shell"
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
onPageContextMenu={onPageContextMenu}
|
||||||
|
onPick={onPick}
|
||||||
|
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||||
|
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||||
|
onAssetOpen={onAssetOpen}
|
||||||
|
onTreeMutation={onTreeMutation}
|
||||||
|
onInternalDrop={onInternalDrop}
|
||||||
|
onDropFiles={onDropFiles}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector("iframe");
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
Object.defineProperty(iframe, "contentWindow", {
|
||||||
|
configurable: true,
|
||||||
|
value: window,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "wrong-channel",
|
||||||
|
type: "tree.navigate",
|
||||||
|
documentId: "doc_wrong",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onNavigate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.navigate",
|
||||||
|
documentId: "doc_2",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.page.context-menu",
|
||||||
|
documentId: "doc_2",
|
||||||
|
x: 12,
|
||||||
|
y: 34,
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.pick.root",
|
||||||
|
documentId: null,
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.filetree.context-menu",
|
||||||
|
documentId: "doc_2",
|
||||||
|
assetId: "asset_2",
|
||||||
|
rowId: "asset:asset_2",
|
||||||
|
rowKind: "asset",
|
||||||
|
x: 56,
|
||||||
|
y: 78,
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.filetree.selection.changed",
|
||||||
|
selectedRowIds: ["doc:doc_2", "index:doc_2"],
|
||||||
|
anchorRowId: "doc:doc_2",
|
||||||
|
focusedRowId: "index:doc_2",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.asset.open",
|
||||||
|
documentId: "doc_2",
|
||||||
|
assetId: "asset_2",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.node.renamed",
|
||||||
|
documentId: "doc_2",
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith("doc_2");
|
||||||
|
expect(onPageContextMenu).toHaveBeenCalledWith({
|
||||||
|
documentId: "doc_2",
|
||||||
|
x: 12,
|
||||||
|
y: 34,
|
||||||
|
});
|
||||||
|
expect(onPick).toHaveBeenCalledWith(null);
|
||||||
|
expect(onFileTreeContextMenu).toHaveBeenCalledWith({
|
||||||
|
documentId: "doc_2",
|
||||||
|
assetId: "asset_2",
|
||||||
|
rowId: "asset:asset_2",
|
||||||
|
rowKind: "asset",
|
||||||
|
x: 56,
|
||||||
|
y: 78,
|
||||||
|
});
|
||||||
|
expect(onFileTreeSelectionChange).toHaveBeenCalledWith({
|
||||||
|
selectedRowIds: ["doc:doc_2", "index:doc_2"],
|
||||||
|
anchorRowId: "doc:doc_2",
|
||||||
|
focusedRowId: "index:doc_2",
|
||||||
|
});
|
||||||
|
expect(onAssetOpen).toHaveBeenCalledWith({
|
||||||
|
documentId: "doc_2",
|
||||||
|
assetId: "asset_2",
|
||||||
|
});
|
||||||
|
expect(onTreeMutation).toHaveBeenCalledWith({
|
||||||
|
type: "tree.node.renamed",
|
||||||
|
documentId: "doc_2",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.filetree.internal-drop",
|
||||||
|
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||||
|
copy: true,
|
||||||
|
targetRow,
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.filetree.drop-files",
|
||||||
|
documentId: "doc_target",
|
||||||
|
targetRow,
|
||||||
|
files: [droppedFile],
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||||
|
targetRow,
|
||||||
|
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||||
|
copy: true,
|
||||||
|
});
|
||||||
|
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { TreeShellHostMode } from "@/components/sidebar/tree-shell-host";
|
||||||
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||||
|
|
||||||
|
type TreeShellDocRow = Extract<FileTreeRow, { kind: "doc" }>;
|
||||||
|
type TreeShellIndexRow = Extract<FileTreeRow, { kind: "index" }>;
|
||||||
|
type TreeShellAssetFolderRow = Extract<FileTreeRow, { kind: "asset-folder" }>;
|
||||||
|
type TreeShellAssetRow = Extract<FileTreeRow, { kind: "asset" }>;
|
||||||
|
|
||||||
|
type TreeShellBridgeContextMenuPayload = {
|
||||||
|
documentId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
rowId: string | null;
|
||||||
|
rowKind: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeShellBridgeMutationPayload = {
|
||||||
|
type: string;
|
||||||
|
documentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeShellBridgeSelectionPayload = {
|
||||||
|
selectedRowIds: string[];
|
||||||
|
anchorRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeShellPickerItem =
|
||||||
|
| { kind: "root"; id: null; title: string; subtitle?: string; depth?: number }
|
||||||
|
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
|
||||||
|
|
||||||
|
type TreeShellInlineProjectionItem = {
|
||||||
|
nodeId: string;
|
||||||
|
parentNodeId: string | null;
|
||||||
|
title: string;
|
||||||
|
depth: number;
|
||||||
|
childCount: number;
|
||||||
|
position: number;
|
||||||
|
expandedByDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeShellIframeHostProps = {
|
||||||
|
mode: TreeShellHostMode;
|
||||||
|
surfaceTestId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
allowRootPick?: boolean;
|
||||||
|
excludeIds?: string[];
|
||||||
|
pickerItems?: TreeShellPickerItem[];
|
||||||
|
fileTreeRows?: FileTreeRow[];
|
||||||
|
channel?: string;
|
||||||
|
host?: string;
|
||||||
|
onNavigate?: (documentId: string) => void;
|
||||||
|
onPick?: (targetId: string | null) => void;
|
||||||
|
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||||
|
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
|
||||||
|
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
|
||||||
|
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||||
|
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||||
|
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||||
|
onTreeMutation?: (payload: TreeShellBridgeMutationPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeShellBridgeMessage = {
|
||||||
|
channel: string;
|
||||||
|
type: string;
|
||||||
|
documentId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
rowId: string | null;
|
||||||
|
rowKind: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
selectedRowIds: string[];
|
||||||
|
anchorRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
copy: boolean;
|
||||||
|
targetRow: FileTreeRow | null;
|
||||||
|
files: File[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const INLINE_TREE_SHELL_LOADING_HTML = [
|
||||||
|
"<!doctype html>",
|
||||||
|
'<html lang="zh-CN">',
|
||||||
|
"<head>",
|
||||||
|
' <meta charset="utf-8" />',
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||||
|
" <title>Tree Shell Loading</title>",
|
||||||
|
" <style>",
|
||||||
|
" body { margin: 0; font-family: \"Noto Sans CJK SC\", \"Source Han Sans SC\", sans-serif; background: #f8fafc; color: #475569; }",
|
||||||
|
" main { min-height: 100vh; display: grid; place-items: center; }",
|
||||||
|
" p { margin: 0; font-size: 13px; }",
|
||||||
|
" </style>",
|
||||||
|
"</head>",
|
||||||
|
"<body>",
|
||||||
|
" <main><p>正在加载树结果…</p></main>",
|
||||||
|
"</body>",
|
||||||
|
"</html>",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const normalizeString = (value: unknown, fallback = "") => {
|
||||||
|
if (typeof value !== "string") return fallback;
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNullableString = (value: unknown) => {
|
||||||
|
const normalized = normalizeString(value);
|
||||||
|
return normalized || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNumber = (value: unknown) => {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBoolean = (value: unknown) => value === true;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return Boolean(value) && typeof value === "object";
|
||||||
|
};
|
||||||
|
|
||||||
|
const readStringArray = (value: unknown) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.map((item) => normalizeString(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFiles = (value: unknown) => {
|
||||||
|
if (typeof FileList !== "undefined" && value instanceof FileList) {
|
||||||
|
return Array.from(value);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.filter((item): item is File => typeof File !== "undefined" && item instanceof File);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFileTreeRow = (value: unknown): FileTreeRow | null => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = normalizeString(value.kind);
|
||||||
|
const rowId = normalizeString(value.rowId);
|
||||||
|
const docId = normalizeString(value.docId);
|
||||||
|
const depth = Math.max(0, readNumber(value.depth));
|
||||||
|
|
||||||
|
if (!kind || !rowId || !docId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case "doc":
|
||||||
|
if (!isRecord(value.node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "doc",
|
||||||
|
rowId: rowId as FileTreeRow["rowId"],
|
||||||
|
depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: readNullableString(value.parentDocId),
|
||||||
|
node: value.node as TreeShellDocRow["node"],
|
||||||
|
hasChildren: readBoolean(value.hasChildren),
|
||||||
|
isExpanded: readBoolean(value.isExpanded),
|
||||||
|
};
|
||||||
|
case "index":
|
||||||
|
if (!isRecord(value.node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "index",
|
||||||
|
rowId: rowId as FileTreeRow["rowId"],
|
||||||
|
depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: readNullableString(value.parentDocId) ?? docId,
|
||||||
|
node: value.node as TreeShellIndexRow["node"],
|
||||||
|
};
|
||||||
|
case "asset-folder":
|
||||||
|
if (!isRecord(value.asset)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "asset-folder",
|
||||||
|
rowId: rowId as FileTreeRow["rowId"],
|
||||||
|
depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: readNullableString(value.parentDocId) ?? docId,
|
||||||
|
asset: value.asset as TreeShellAssetFolderRow["asset"],
|
||||||
|
hasChildren: readBoolean(value.hasChildren),
|
||||||
|
isExpanded: readBoolean(value.isExpanded),
|
||||||
|
};
|
||||||
|
case "asset":
|
||||||
|
if (!isRecord(value.asset)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "asset",
|
||||||
|
rowId: rowId as FileTreeRow["rowId"],
|
||||||
|
depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: readNullableString(value.parentDocId) ?? docId,
|
||||||
|
asset: value.asset as TreeShellAssetRow["asset"],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTreeShellBridgeMessage(
|
||||||
|
value: unknown,
|
||||||
|
expectedChannel: string,
|
||||||
|
): TreeShellBridgeMessage | null {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = value as Record<string, unknown>;
|
||||||
|
const channel = normalizeString(payload.channel);
|
||||||
|
const type = normalizeString(payload.type);
|
||||||
|
if (!channel || !type || channel !== expectedChannel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
type,
|
||||||
|
documentId: readNullableString(payload.documentId ?? payload.docId),
|
||||||
|
assetId: readNullableString(payload.assetId),
|
||||||
|
rowId: readNullableString(payload.rowId ?? payload.targetRowId),
|
||||||
|
rowKind: readNullableString(payload.rowKind ?? payload.targetRowKind),
|
||||||
|
x: readNumber(payload.x),
|
||||||
|
y: readNumber(payload.y),
|
||||||
|
selectedRowIds: readStringArray(payload.selectedRowIds),
|
||||||
|
anchorRowId: readNullableString(payload.anchorRowId),
|
||||||
|
focusedRowId: readNullableString(payload.focusedRowId),
|
||||||
|
rowIds: readStringArray(payload.rowIds),
|
||||||
|
copy: readBoolean(payload.copy),
|
||||||
|
targetRow: readFileTreeRow(payload.targetRow),
|
||||||
|
files: readFiles(payload.files),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeInlineScriptJson = (input: string) => {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "\\u0026")
|
||||||
|
.replace(/</g, "\\u003c")
|
||||||
|
.replace(/>/g, "\\u003e");
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildTreeShellInlinePickerItems(
|
||||||
|
items: TreeShellPickerItem[],
|
||||||
|
): TreeShellInlineProjectionItem[] {
|
||||||
|
return items
|
||||||
|
.filter(
|
||||||
|
(item): item is Extract<TreeShellPickerItem, { kind: "doc"; id: string }> =>
|
||||||
|
item.kind === "doc" && Boolean(normalizeString(item.id)),
|
||||||
|
)
|
||||||
|
.map((item, index) => ({
|
||||||
|
nodeId: normalizeString(item.id),
|
||||||
|
parentNodeId: null,
|
||||||
|
title: normalizeString(item.title, "无标题"),
|
||||||
|
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
|
||||||
|
childCount: 0,
|
||||||
|
position: index,
|
||||||
|
expandedByDefault: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectTreeShellInlineOverrides(
|
||||||
|
html: string,
|
||||||
|
overrides: { items?: TreeShellInlineProjectionItem[] },
|
||||||
|
) {
|
||||||
|
const payloadJson = escapeInlineScriptJson(JSON.stringify(overrides));
|
||||||
|
const scriptTag = `<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ = ${payloadJson};</script>`;
|
||||||
|
const anchor = '<script id="tree-shell-state"';
|
||||||
|
if (html.includes(anchor)) {
|
||||||
|
return html.replace(anchor, `${scriptTag}${anchor}`);
|
||||||
|
}
|
||||||
|
return `${scriptTag}${html}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTreeShellIframeSrc(input: {
|
||||||
|
mode: TreeShellHostMode;
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
allowRootPick?: boolean;
|
||||||
|
excludeIds?: string[];
|
||||||
|
channel: string;
|
||||||
|
host: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("workspaceId", input.workspaceId);
|
||||||
|
params.set("mode", input.mode);
|
||||||
|
params.set("channel", input.channel);
|
||||||
|
params.set("host", input.host);
|
||||||
|
|
||||||
|
const rootNodeId = normalizeString(input.rootNodeId);
|
||||||
|
if (rootNodeId) {
|
||||||
|
params.set("rootNodeId", rootNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDocumentId = normalizeString(input.activeDocumentId);
|
||||||
|
if (activeDocumentId) {
|
||||||
|
params.set("activeDocumentId", activeDocumentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.allowRootPick) {
|
||||||
|
params.set("allowRootPick", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludeIds = (input.excludeIds ?? [])
|
||||||
|
.map((item) => normalizeString(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (excludeIds.length > 0) {
|
||||||
|
params.set("excludeIds", excludeIds.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/api/tree/shell?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreeShellIframeHost({
|
||||||
|
mode,
|
||||||
|
surfaceTestId,
|
||||||
|
workspaceId,
|
||||||
|
rootNodeId = null,
|
||||||
|
activeDocumentId = null,
|
||||||
|
allowRootPick = false,
|
||||||
|
excludeIds = [],
|
||||||
|
pickerItems = [],
|
||||||
|
fileTreeRows = [],
|
||||||
|
channel,
|
||||||
|
host,
|
||||||
|
onNavigate,
|
||||||
|
onPick,
|
||||||
|
onPageContextMenu,
|
||||||
|
onFileTreeContextMenu,
|
||||||
|
onFileTreeSelectionChange,
|
||||||
|
onInternalDrop,
|
||||||
|
onDropFiles,
|
||||||
|
onAssetOpen,
|
||||||
|
onTreeMutation,
|
||||||
|
}: TreeShellIframeHostProps) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const [inlineSrcDoc, setInlineSrcDoc] = useState<string | null>(null);
|
||||||
|
const [inlineLoadFailed, setInlineLoadFailed] = useState(false);
|
||||||
|
const resolvedChannel = channel?.trim() || surfaceTestId;
|
||||||
|
const resolvedHost = host?.trim() || surfaceTestId;
|
||||||
|
const fileTreeRowById = useMemo(
|
||||||
|
() => new Map(fileTreeRows.map((row) => [row.rowId, row])),
|
||||||
|
[fileTreeRows],
|
||||||
|
);
|
||||||
|
const inlinePickerItems = useMemo(
|
||||||
|
() => (mode === "picker" ? buildTreeShellInlinePickerItems(pickerItems) : []),
|
||||||
|
[mode, pickerItems],
|
||||||
|
);
|
||||||
|
const useInlinePickerOverride = inlinePickerItems.length > 0;
|
||||||
|
const src = useMemo(
|
||||||
|
() =>
|
||||||
|
buildTreeShellIframeSrc({
|
||||||
|
mode,
|
||||||
|
workspaceId,
|
||||||
|
rootNodeId,
|
||||||
|
activeDocumentId,
|
||||||
|
allowRootPick,
|
||||||
|
excludeIds,
|
||||||
|
channel: resolvedChannel,
|
||||||
|
host: resolvedHost,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeDocumentId,
|
||||||
|
allowRootPick,
|
||||||
|
excludeIds,
|
||||||
|
mode,
|
||||||
|
resolvedChannel,
|
||||||
|
resolvedHost,
|
||||||
|
rootNodeId,
|
||||||
|
workspaceId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useInlinePickerOverride) {
|
||||||
|
setInlineSrcDoc(null);
|
||||||
|
setInlineLoadFailed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
setInlineLoadFailed(false);
|
||||||
|
setInlineSrcDoc(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(src, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`tree shell inline override fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInlineSrcDoc(
|
||||||
|
injectTreeShellInlineOverrides(html, {
|
||||||
|
items: inlinePickerItems,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInlineLoadFailed(true);
|
||||||
|
setInlineSrcDoc(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [inlinePickerItems, src, useInlinePickerOverride]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.source !== iframeRef.current?.contentWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = parseTreeShellBridgeMessage(event.data, resolvedChannel);
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "tree.navigate":
|
||||||
|
if (message.documentId) {
|
||||||
|
onNavigate?.(message.documentId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "tree.pick":
|
||||||
|
onPick?.(message.documentId);
|
||||||
|
return;
|
||||||
|
case "tree.pick.root":
|
||||||
|
onPick?.(null);
|
||||||
|
return;
|
||||||
|
case "tree.page.context-menu":
|
||||||
|
if (message.documentId) {
|
||||||
|
onPageContextMenu?.({
|
||||||
|
documentId: message.documentId,
|
||||||
|
x: message.x,
|
||||||
|
y: message.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "tree.filetree.context-menu":
|
||||||
|
onFileTreeContextMenu?.({
|
||||||
|
documentId: message.documentId,
|
||||||
|
assetId: message.assetId,
|
||||||
|
rowId: message.rowId,
|
||||||
|
rowKind: message.rowKind,
|
||||||
|
x: message.x,
|
||||||
|
y: message.y,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "tree.filetree.selection.changed":
|
||||||
|
onFileTreeSelectionChange?.({
|
||||||
|
selectedRowIds: message.selectedRowIds,
|
||||||
|
anchorRowId: message.anchorRowId,
|
||||||
|
focusedRowId: message.focusedRowId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "tree.filetree.internal-drop":
|
||||||
|
if (message.rowIds.length > 0) {
|
||||||
|
const targetRow =
|
||||||
|
message.targetRow ??
|
||||||
|
(message.rowId ? (fileTreeRowById.get(message.rowId) ?? null) : null);
|
||||||
|
if (!targetRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onInternalDrop?.({
|
||||||
|
targetRow,
|
||||||
|
rowIds: message.rowIds,
|
||||||
|
copy: message.copy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "tree.filetree.external-drop":
|
||||||
|
case "tree.filetree.drop-files":
|
||||||
|
if (message.files.length > 0) {
|
||||||
|
const targetRow =
|
||||||
|
message.targetRow ??
|
||||||
|
(message.rowId ? (fileTreeRowById.get(message.rowId) ?? null) : null);
|
||||||
|
const targetDocId = message.documentId ?? targetRow?.docId ?? null;
|
||||||
|
if (!targetDocId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDropFiles?.(targetDocId, message.files, targetRow ?? undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "tree.asset.open":
|
||||||
|
if (message.assetId) {
|
||||||
|
onAssetOpen?.({
|
||||||
|
assetId: message.assetId,
|
||||||
|
documentId: message.documentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "tree.node.created":
|
||||||
|
case "tree.node.renamed":
|
||||||
|
case "tree.subtree.moved":
|
||||||
|
onTreeMutation?.({
|
||||||
|
type: message.type,
|
||||||
|
documentId: message.documentId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", onMessage);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
onAssetOpen,
|
||||||
|
fileTreeRowById,
|
||||||
|
onFileTreeContextMenu,
|
||||||
|
onFileTreeSelectionChange,
|
||||||
|
onInternalDrop,
|
||||||
|
onDropFiles,
|
||||||
|
onNavigate,
|
||||||
|
onPageContextMenu,
|
||||||
|
onPick,
|
||||||
|
onTreeMutation,
|
||||||
|
resolvedChannel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={useInlinePickerOverride && !inlineLoadFailed ? undefined : src}
|
||||||
|
srcDoc={useInlinePickerOverride && !inlineLoadFailed ? inlineSrcDoc ?? INLINE_TREE_SHELL_LOADING_HTML : undefined}
|
||||||
|
title={`tree-shell-${mode}`}
|
||||||
|
data-testid={`${surfaceTestId}-rust-iframe`}
|
||||||
|
data-tree-shell-mode={mode}
|
||||||
|
data-tree-shell-channel={resolvedChannel}
|
||||||
|
data-tree-shell-inline={useInlinePickerOverride && !inlineLoadFailed ? "1" : "0"}
|
||||||
|
className="h-full w-full border-0 bg-white"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/private-tree", () => ({
|
||||||
|
PrivateTree: () => <div data-testid="private-tree-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/file-tree", () => ({
|
||||||
|
FileTree: () => <div data-testid="file-tree-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("tree-shell-surface", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPageSurface(rendererFamily: TreeRendererFamily) {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="page"
|
||||||
|
rendererFamily={rendererFamily}
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled
|
||||||
|
rows={[]}
|
||||||
|
expanded={new Set<string>()}
|
||||||
|
activeId=""
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onMove={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
onContextMenu={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("page tree surface 在 rust_family 下可切到同源 iframe host", () => {
|
||||||
|
renderPageSurface("rust_family");
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
|
const rustHost = container.querySelector(
|
||||||
|
'[data-testid="sidebar-page-tree-shell-rust-host"]',
|
||||||
|
);
|
||||||
|
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||||
|
|
||||||
|
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(rustHost).not.toBeNull();
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="private-tree-fallback"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page tree 在禁用 tree shell 时应安全回退到 React fallback", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="page"
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled={false}
|
||||||
|
rows={[]}
|
||||||
|
expanded={new Set<string>()}
|
||||||
|
activeId=""
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onMove={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
onContextMenu={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
||||||
|
expect(container.querySelector('[data-testid="private-tree-fallback"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="filetree"
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled
|
||||||
|
rows={[]}
|
||||||
|
activeId=""
|
||||||
|
selectedRowIds={new Set<string>()}
|
||||||
|
onRowClick={() => undefined}
|
||||||
|
onRowDoubleClick={() => undefined}
|
||||||
|
onRowContextMenu={() => undefined}
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||||
|
const rustHost = container.querySelector(
|
||||||
|
'[data-testid="sidebar-file-tree-shell-rust-host"]',
|
||||||
|
);
|
||||||
|
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
||||||
|
|
||||||
|
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(rustHost).not.toBeNull();
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="file-tree-fallback"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
|
||||||
|
const onInternalDrop = vi.fn();
|
||||||
|
const onDropFiles = vi.fn();
|
||||||
|
const targetRow = {
|
||||||
|
kind: "doc",
|
||||||
|
rowId: "doc:doc_target",
|
||||||
|
depth: 0,
|
||||||
|
docId: "doc_target",
|
||||||
|
parentDocId: null,
|
||||||
|
node: {
|
||||||
|
_id: "doc_target",
|
||||||
|
id: "doc_target",
|
||||||
|
title: "目标页面",
|
||||||
|
},
|
||||||
|
hasChildren: false,
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<SidebarTreeSurface
|
||||||
|
mode="filetree"
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled
|
||||||
|
rows={[targetRow]}
|
||||||
|
activeId=""
|
||||||
|
selectedRowIds={new Set<string>()}
|
||||||
|
onRowClick={() => undefined}
|
||||||
|
onRowDoubleClick={() => undefined}
|
||||||
|
onRowContextMenu={() => undefined}
|
||||||
|
onToggleExpand={() => undefined}
|
||||||
|
onCreateChild={() => undefined}
|
||||||
|
onInternalDrop={onInternalDrop}
|
||||||
|
onDropFiles={onDropFiles}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector(
|
||||||
|
'[data-testid="sidebar-file-tree-shell-rust-iframe"]',
|
||||||
|
);
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
Object.defineProperty(iframe, "contentWindow", {
|
||||||
|
configurable: true,
|
||||||
|
value: window,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.filetree.internal-drop",
|
||||||
|
rowIds: ["doc:doc_source"],
|
||||||
|
copy: false,
|
||||||
|
targetRow,
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
channel: "sidebar-file-tree-shell",
|
||||||
|
type: "tree.filetree.external-drop",
|
||||||
|
documentId: "doc_target",
|
||||||
|
targetRow,
|
||||||
|
files: [droppedFile],
|
||||||
|
},
|
||||||
|
source: window,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||||
|
targetRow,
|
||||||
|
rowIds: ["doc:doc_source"],
|
||||||
|
copy: false,
|
||||||
|
});
|
||||||
|
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
|
||||||
|
const onPick = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreePickerSurface
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled
|
||||||
|
items={[{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]}
|
||||||
|
highlighted={0}
|
||||||
|
onHighlight={() => undefined}
|
||||||
|
onPick={onPick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
|
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||||
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
|
||||||
|
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||||
|
expect(rustHost).not.toBeNull();
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
expect(onPick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picker 在 rust_family 但 tree shell 不可用时仍应保留 React fallback", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<TreePickerSurface
|
||||||
|
rendererFamily="rust_family"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
treeShellEnabled={false}
|
||||||
|
items={[{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }]}
|
||||||
|
highlighted={0}
|
||||||
|
emptyText="没有匹配结果"
|
||||||
|
onHighlight={() => undefined}
|
||||||
|
onPick={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
|
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||||
|
const row = container.querySelector('[data-testid="tree-picker-row"]');
|
||||||
|
|
||||||
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
||||||
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
|
expect(rustHost).not.toBeNull();
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,13 +3,19 @@
|
|||||||
import type { DragEvent, MouseEvent } from "react";
|
import type { DragEvent, MouseEvent } from "react";
|
||||||
import { FileTree } from "@/components/sidebar/file-tree";
|
import { FileTree } from "@/components/sidebar/file-tree";
|
||||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||||
|
import { TreeShellHost, type TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
|
||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type { TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
|
||||||
|
|
||||||
type SidebarPageTreeSurfaceProps = {
|
type SidebarPageTreeSurfaceProps = {
|
||||||
mode: "page";
|
mode: "page";
|
||||||
|
rendererFamily?: TreeRendererFamily;
|
||||||
|
workspaceId: string | null;
|
||||||
|
treeShellEnabled?: boolean;
|
||||||
rows: PageTreeProjectionItem[];
|
rows: PageTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
@@ -18,10 +24,16 @@ type SidebarPageTreeSurfaceProps = {
|
|||||||
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
onMove: (nodeId: string, parentId: string | null, index: number) => void;
|
||||||
onCreateChild: (parentId: string | null) => void;
|
onCreateChild: (parentId: string | null) => void;
|
||||||
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
|
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
|
||||||
|
onNavigate?: (documentId: string) => void;
|
||||||
|
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||||
|
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SidebarFileTreeSurfaceProps = {
|
type SidebarFileTreeSurfaceProps = {
|
||||||
mode: "filetree";
|
mode: "filetree";
|
||||||
|
rendererFamily?: TreeRendererFamily;
|
||||||
|
workspaceId: string | null;
|
||||||
|
treeShellEnabled?: boolean;
|
||||||
rows: FileTreeRow[];
|
rows: FileTreeRow[];
|
||||||
activeId: string;
|
activeId: string;
|
||||||
selectedRowIds: Set<string>;
|
selectedRowIds: Set<string>;
|
||||||
@@ -34,8 +46,24 @@ type SidebarFileTreeSurfaceProps = {
|
|||||||
onToggleAssetFolderExpand?: (assetId: string) => void;
|
onToggleAssetFolderExpand?: (assetId: string) => void;
|
||||||
onCreateChild: (parentId: string | null) => void;
|
onCreateChild: (parentId: string | null) => void;
|
||||||
onBlankMouseDown?: (event: MouseEvent) => void;
|
onBlankMouseDown?: (event: MouseEvent) => void;
|
||||||
onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
|
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||||
|
onNavigate?: (documentId: string) => void;
|
||||||
|
onFileTreeContextMenu?: (payload: {
|
||||||
|
documentId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
rowId: string | null;
|
||||||
|
rowKind: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}) => void;
|
||||||
|
onFileTreeSelectionChange?: (payload: {
|
||||||
|
selectedRowIds: string[];
|
||||||
|
anchorRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
}) => void;
|
||||||
|
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||||
|
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SidebarTreeSurfaceProps =
|
export type SidebarTreeSurfaceProps =
|
||||||
@@ -47,6 +75,13 @@ export type TreePickerSurfaceItem =
|
|||||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
|
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
|
||||||
|
|
||||||
type TreePickerSurfaceProps = {
|
type TreePickerSurfaceProps = {
|
||||||
|
rendererFamily?: TreeRendererFamily;
|
||||||
|
workspaceId: string | null;
|
||||||
|
treeShellEnabled?: boolean;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
allowRootPick?: boolean;
|
||||||
|
excludeIds?: string[];
|
||||||
|
treeShellItems?: TreePickerSurfaceItem[];
|
||||||
items: TreePickerSurfaceItem[];
|
items: TreePickerSurfaceItem[];
|
||||||
highlighted: number;
|
highlighted: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -60,48 +95,71 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
|||||||
props.mode === "page"
|
props.mode === "page"
|
||||||
? "sidebar-page-tree-shell"
|
? "sidebar-page-tree-shell"
|
||||||
: "sidebar-file-tree-shell";
|
: "sidebar-file-tree-shell";
|
||||||
|
const rendererFamily = props.rendererFamily ?? "react";
|
||||||
|
const fallbackContent =
|
||||||
|
props.mode === "page" ? (
|
||||||
|
<PrivateTree
|
||||||
|
rows={props.rows}
|
||||||
|
expanded={props.expanded}
|
||||||
|
activeId={props.activeId}
|
||||||
|
onToggleExpand={props.onToggleExpand}
|
||||||
|
onMove={props.onMove}
|
||||||
|
onCreateChild={props.onCreateChild}
|
||||||
|
onContextMenu={props.onContextMenu}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileTree
|
||||||
|
rows={props.rows}
|
||||||
|
activeId={props.activeId}
|
||||||
|
selectedRowIds={props.selectedRowIds}
|
||||||
|
onRowClick={props.onRowClick}
|
||||||
|
onRowDoubleClick={props.onRowDoubleClick}
|
||||||
|
onRowContextMenu={props.onRowContextMenu}
|
||||||
|
onRowDragStart={props.onRowDragStart}
|
||||||
|
onToggleExpand={props.onToggleExpand}
|
||||||
|
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
|
||||||
|
onCreateChild={props.onCreateChild}
|
||||||
|
onBlankMouseDown={props.onBlankMouseDown}
|
||||||
|
onDropFiles={props.onDropFiles}
|
||||||
|
onInternalDrop={props.onInternalDrop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TreeShellHost
|
||||||
data-testid={surfaceTestId}
|
mode={props.mode}
|
||||||
data-shell-mode={props.mode}
|
surfaceTestId={surfaceTestId}
|
||||||
|
rendererFamily={rendererFamily}
|
||||||
|
treeShellEnabled={props.treeShellEnabled}
|
||||||
|
workspaceId={props.workspaceId}
|
||||||
|
activeDocumentId={props.activeId}
|
||||||
|
fileTreeRows={props.mode === "filetree" ? props.rows : undefined}
|
||||||
|
onNavigate={props.onNavigate}
|
||||||
|
onPageContextMenu={props.mode === "page" ? props.onPageContextMenu : undefined}
|
||||||
|
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
|
||||||
|
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
|
||||||
|
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
|
||||||
|
onDropFiles={props.mode === "filetree" ? props.onDropFiles : undefined}
|
||||||
|
onAssetOpen={props.mode === "filetree" ? props.onAssetOpen : undefined}
|
||||||
|
onTreeMutation={props.onTreeMutation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
|
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.mode === "page" ? (
|
{fallbackContent}
|
||||||
<PrivateTree
|
</TreeShellHost>
|
||||||
rows={props.rows}
|
|
||||||
expanded={props.expanded}
|
|
||||||
activeId={props.activeId}
|
|
||||||
onToggleExpand={props.onToggleExpand}
|
|
||||||
onMove={props.onMove}
|
|
||||||
onCreateChild={props.onCreateChild}
|
|
||||||
onContextMenu={props.onContextMenu}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FileTree
|
|
||||||
rows={props.rows}
|
|
||||||
activeId={props.activeId}
|
|
||||||
selectedRowIds={props.selectedRowIds}
|
|
||||||
onRowClick={props.onRowClick}
|
|
||||||
onRowDoubleClick={props.onRowDoubleClick}
|
|
||||||
onRowContextMenu={props.onRowContextMenu}
|
|
||||||
onRowDragStart={props.onRowDragStart}
|
|
||||||
onToggleExpand={props.onToggleExpand}
|
|
||||||
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
|
|
||||||
onCreateChild={props.onCreateChild}
|
|
||||||
onBlankMouseDown={props.onBlankMouseDown}
|
|
||||||
onDropFiles={props.onDropFiles}
|
|
||||||
onInternalDrop={props.onInternalDrop}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreePickerSurface({
|
export function TreePickerSurface({
|
||||||
|
rendererFamily = "react",
|
||||||
|
workspaceId,
|
||||||
|
treeShellEnabled = true,
|
||||||
|
activeDocumentId = null,
|
||||||
|
allowRootPick = false,
|
||||||
|
excludeIds = [],
|
||||||
|
treeShellItems,
|
||||||
items,
|
items,
|
||||||
highlighted,
|
highlighted,
|
||||||
className,
|
className,
|
||||||
@@ -109,42 +167,53 @@ export function TreePickerSurface({
|
|||||||
onHighlight,
|
onHighlight,
|
||||||
onPick,
|
onPick,
|
||||||
}: TreePickerSurfaceProps) {
|
}: TreePickerSurfaceProps) {
|
||||||
if (items.length === 0) {
|
const hasItems = items.length > 0;
|
||||||
return <div className="p-4 text-sm text-gray-400">{emptyText}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TreeShellHost
|
||||||
data-testid="tree-picker-surface"
|
mode="picker"
|
||||||
className={cn("py-2", className)}
|
surfaceTestId="tree-picker-surface"
|
||||||
|
rendererFamily={rendererFamily}
|
||||||
|
treeShellEnabled={treeShellEnabled}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
activeDocumentId={activeDocumentId}
|
||||||
|
allowRootPick={allowRootPick}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
pickerItems={treeShellItems}
|
||||||
|
onPick={onPick}
|
||||||
|
className={cn(hasItems ? "py-2" : null, className)}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => {
|
{!hasItems ? (
|
||||||
const isRoot = item.kind === "root";
|
<div className="p-4 text-sm text-gray-400">{emptyText}</div>
|
||||||
return (
|
) : (
|
||||||
<button
|
items.map((item, index) => {
|
||||||
key={isRoot ? "root" : item.id}
|
const isRoot = item.kind === "root";
|
||||||
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
|
return (
|
||||||
data-node-id={isRoot ? "" : item.id}
|
<button
|
||||||
type="button"
|
key={isRoot ? "root" : item.id}
|
||||||
className={cn(
|
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
|
||||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
data-node-id={isRoot ? "" : item.id}
|
||||||
index === highlighted && "bg-[#e3ecff]",
|
type="button"
|
||||||
)}
|
className={cn(
|
||||||
onMouseEnter={() => onHighlight(index)}
|
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||||
onClick={() => onPick(item.id)}
|
index === highlighted && "bg-[#e3ecff]",
|
||||||
>
|
)}
|
||||||
<div
|
onMouseEnter={() => onHighlight(index)}
|
||||||
className="text-sm font-medium text-gray-900"
|
onClick={() => onPick(item.id)}
|
||||||
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
|
||||||
>
|
>
|
||||||
{item.title}
|
<div
|
||||||
</div>
|
className="text-sm font-medium text-gray-900"
|
||||||
{item.subtitle ? (
|
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||||
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
|
>
|
||||||
) : null}
|
{item.title}
|
||||||
</button>
|
</div>
|
||||||
);
|
{item.subtitle ? (
|
||||||
})}
|
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
|
||||||
</div>
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TreeShellHost>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DocumentRecord } from "@/lib/documents";
|
import type { DocumentRecord } from "@/lib/documents";
|
||||||
|
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
@@ -19,6 +20,7 @@ export interface SidebarInitialData {
|
|||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
kernelSidebarProjection: KernelSidebarProjection;
|
kernelSidebarProjection: KernelSidebarProjection;
|
||||||
kernelSidebarTree: SidebarTreeNode[];
|
kernelSidebarTree: SidebarTreeNode[];
|
||||||
|
kernelFileTreeProjection: KernelFileTreeProjection;
|
||||||
trashedDocuments: TrashRecord[];
|
trashedDocuments: TrashRecord[];
|
||||||
trashedMediaAssets?: MediaAsset[];
|
trashedMediaAssets?: MediaAsset[];
|
||||||
trashedMindmapAssets?: MediaAsset[];
|
trashedMindmapAssets?: MediaAsset[];
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function Harness(props: {
|
|||||||
initialData: SidebarInitialData;
|
initialData: SidebarInitialData;
|
||||||
sidebarQueryData: SidebarInitialData;
|
sidebarQueryData: SidebarInitialData;
|
||||||
treeStreamData: SidebarInitialData | null;
|
treeStreamData: SidebarInitialData | null;
|
||||||
|
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||||
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
||||||
}) {
|
}) {
|
||||||
const state = usePreferredSidebarSnapshot(props);
|
const state = usePreferredSidebarSnapshot(props);
|
||||||
@@ -71,7 +72,55 @@ describe("usePreferredSidebarSnapshot", () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tree stream 落后于 query refetch 时应优先使用更新后的 query 快照", async () => {
|
it("tree stream live 时即使 query 更新也应继续优先使用 stream 快照", async () => {
|
||||||
|
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||||
|
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||||
|
const refreshedQuery = buildSidebarData([
|
||||||
|
buildDocument({
|
||||||
|
title: "标题 B",
|
||||||
|
updated_at: "2026-04-21T00:00:01.000Z",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<Harness
|
||||||
|
initialData={initialData}
|
||||||
|
sidebarQueryData={initialData}
|
||||||
|
treeStreamData={staleTreeStream}
|
||||||
|
treeStreamStatus="live"
|
||||||
|
onState={onState}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||||
|
source: "tree_stream",
|
||||||
|
data: expect.objectContaining({
|
||||||
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<Harness
|
||||||
|
initialData={initialData}
|
||||||
|
sidebarQueryData={refreshedQuery}
|
||||||
|
treeStreamData={staleTreeStream}
|
||||||
|
treeStreamStatus="live"
|
||||||
|
onState={onState}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||||
|
source: "tree_stream",
|
||||||
|
data: expect.objectContaining({
|
||||||
|
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
|
||||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||||
const refreshedQuery = buildSidebarData([
|
const refreshedQuery = buildSidebarData([
|
||||||
@@ -87,30 +136,13 @@ describe("usePreferredSidebarSnapshot", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<Harness
|
|
||||||
initialData={initialData}
|
|
||||||
sidebarQueryData={initialData}
|
|
||||||
treeStreamData={staleTreeStream}
|
|
||||||
onState={onState}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
|
||||||
source: "tree_stream",
|
|
||||||
data: expect.objectContaining({
|
|
||||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<Harness
|
<Harness
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
sidebarQueryData={refreshedQuery}
|
sidebarQueryData={refreshedQuery}
|
||||||
treeStreamData={staleTreeStream}
|
treeStreamData={staleTreeStream}
|
||||||
|
treeStreamStatus="fallback"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -129,6 +161,7 @@ describe("usePreferredSidebarSnapshot", () => {
|
|||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
sidebarQueryData={refreshedQuery}
|
sidebarQueryData={refreshedQuery}
|
||||||
treeStreamData={caughtUpTreeStream}
|
treeStreamData={caughtUpTreeStream}
|
||||||
|
treeStreamStatus="live"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import {
|
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
||||||
buildSidebarDataSyncKey,
|
|
||||||
getSidebarDataFreshness,
|
|
||||||
} from "@/components/sidebar/sidebar-sync";
|
|
||||||
|
|
||||||
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
|
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
|
||||||
|
|
||||||
@@ -11,6 +8,7 @@ export function usePreferredSidebarSnapshot(input: {
|
|||||||
initialData: SidebarInitialData;
|
initialData: SidebarInitialData;
|
||||||
sidebarQueryData: SidebarInitialData | null;
|
sidebarQueryData: SidebarInitialData | null;
|
||||||
treeStreamData: SidebarInitialData | null;
|
treeStreamData: SidebarInitialData | null;
|
||||||
|
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||||
}) {
|
}) {
|
||||||
const querySyncKey = useMemo(
|
const querySyncKey = useMemo(
|
||||||
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
|
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
|
||||||
@@ -21,23 +19,10 @@ export function usePreferredSidebarSnapshot(input: {
|
|||||||
[input.treeStreamData],
|
[input.treeStreamData],
|
||||||
);
|
);
|
||||||
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
||||||
const queryFreshness = useMemo(
|
const streamIsPreferred = input.treeStreamStatus !== "fallback";
|
||||||
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : Number.NEGATIVE_INFINITY),
|
|
||||||
[input.sidebarQueryData],
|
|
||||||
);
|
|
||||||
const treeStreamFreshness = useMemo(
|
|
||||||
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : Number.NEGATIVE_INFINITY),
|
|
||||||
[input.treeStreamData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||||
if (input.treeStreamData && input.sidebarQueryData) {
|
if (input.treeStreamData && streamIsPreferred) {
|
||||||
if (treeStreamSyncKey === querySyncKey) {
|
|
||||||
return "tree_stream";
|
|
||||||
}
|
|
||||||
return queryFreshness > treeStreamFreshness ? "query" : "tree_stream";
|
|
||||||
}
|
|
||||||
if (input.treeStreamData) {
|
|
||||||
return "tree_stream";
|
return "tree_stream";
|
||||||
}
|
}
|
||||||
if (input.sidebarQueryData) {
|
if (input.sidebarQueryData) {
|
||||||
@@ -47,10 +32,7 @@ export function usePreferredSidebarSnapshot(input: {
|
|||||||
}, [
|
}, [
|
||||||
input.sidebarQueryData,
|
input.sidebarQueryData,
|
||||||
input.treeStreamData,
|
input.treeStreamData,
|
||||||
queryFreshness,
|
streamIsPreferred,
|
||||||
querySyncKey,
|
|
||||||
treeStreamFreshness,
|
|
||||||
treeStreamSyncKey,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
@@ -61,7 +43,7 @@ export function usePreferredSidebarSnapshot(input: {
|
|||||||
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
|
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
|
||||||
const syncKey =
|
const syncKey =
|
||||||
source === "tree_stream"
|
source === "tree_stream"
|
||||||
? treeStreamSyncKey ?? initialSyncKey
|
? treeStreamSyncKey ?? querySyncKey ?? initialSyncKey
|
||||||
: source === "query"
|
: source === "query"
|
||||||
? querySyncKey ?? initialSyncKey
|
? querySyncKey ?? initialSyncKey
|
||||||
: initialSyncKey;
|
: initialSyncKey;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { useSidebarData } from "./use-sidebar-data";
|
import { useSidebarData } from "./use-sidebar-data";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
|
import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
|
|
||||||
const mockUseConvexSidebarData = vi.fn();
|
const mockUseConvexSidebarData = vi.fn();
|
||||||
const mockUseQuery = vi.fn();
|
const mockUseQuery = vi.fn();
|
||||||
@@ -16,10 +17,18 @@ vi.mock("@tanstack/react-query", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function buildInitialData(): SidebarInitialData {
|
function buildInitialData(): SidebarInitialData {
|
||||||
|
const kernelFileTreeProjection = buildKernelFileTreeProjection({
|
||||||
|
documents: [],
|
||||||
|
mediaAssets: [],
|
||||||
|
mindmapAssets: [],
|
||||||
|
tableAssets: [],
|
||||||
|
mindmapAssetChildren: {},
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
activeWorkspaceId: "ws_1",
|
activeWorkspaceId: "ws_1",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
kernelFileTreeProjection,
|
||||||
kernelSidebarProjection: {
|
kernelSidebarProjection: {
|
||||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||||
projection: "sidebar_tree",
|
projection: "sidebar_tree",
|
||||||
@@ -169,4 +178,104 @@ describe("useSidebarData", () => {
|
|||||||
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||||
expect(refreshedState.data.documents[0]?.title).toBe("新标题");
|
expect(refreshedState.data.documents[0]?.title).toBe("新标题");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("手动 refetch 只应临时覆盖主链,底层 live 数据变化后应回到新的 live snapshot", async () => {
|
||||||
|
const initialData = buildInitialData();
|
||||||
|
const manualSnapshot: SidebarInitialData = {
|
||||||
|
...buildInitialData(),
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
access_scope: "private",
|
||||||
|
id: "doc-1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "HTTP 标题",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-21T00:00:00.000Z",
|
||||||
|
updated_at: "2026-04-21T00:00:02.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kernelSidebarTree: [
|
||||||
|
{
|
||||||
|
id: "doc-1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "HTTP 标题",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
access_scope: "private",
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-21T00:00:00.000Z",
|
||||||
|
updated_at: "2026-04-21T00:00:02.000Z",
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const nextLiveData: SidebarInitialData = {
|
||||||
|
...buildInitialData(),
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
access_scope: "private",
|
||||||
|
id: "doc-1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "Live 标题",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-21T00:00:00.000Z",
|
||||||
|
updated_at: "2026-04-21T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kernelSidebarTree: [
|
||||||
|
{
|
||||||
|
id: "doc-1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "Live 标题",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
access_scope: "private",
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-21T00:00:00.000Z",
|
||||||
|
updated_at: "2026-04-21T00:00:00.000Z",
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let liveData = initialData;
|
||||||
|
mockUseConvexSidebarData.mockImplementation(() => ({
|
||||||
|
data: liveData,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
hasLiveSubscription: true,
|
||||||
|
canUseHttpFallback: false,
|
||||||
|
error: null,
|
||||||
|
refetch: stableRefetch,
|
||||||
|
}));
|
||||||
|
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => manualSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||||
|
await act(async () => {
|
||||||
|
await state.refetch();
|
||||||
|
});
|
||||||
|
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data.documents[0]?.title).toBe("HTTP 标题");
|
||||||
|
|
||||||
|
liveData = nextLiveData;
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data.documents[0]?.title).toBe("Live 标题");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
||||||
buildSidebarDataSyncKey,
|
|
||||||
getSidebarDataFreshness,
|
|
||||||
} from "@/components/sidebar/sidebar-sync";
|
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||||
|
|
||||||
@@ -56,9 +53,11 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
|||||||
const [manualSnapshotState, setManualSnapshotState] = useState<{
|
const [manualSnapshotState, setManualSnapshotState] = useState<{
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
data: SidebarInitialData | null;
|
data: SidebarInitialData | null;
|
||||||
|
baseSyncKey: string | null;
|
||||||
}>({
|
}>({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
data: null,
|
data: null,
|
||||||
|
baseSyncKey: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpQuery = useQuery({
|
const httpQuery = useQuery({
|
||||||
@@ -73,40 +72,37 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
|||||||
manualSnapshotState.workspaceId === workspaceId
|
manualSnapshotState.workspaceId === workspaceId
|
||||||
? manualSnapshotState.data
|
? manualSnapshotState.data
|
||||||
: null;
|
: null;
|
||||||
|
const manualSnapshotBaseSyncKey =
|
||||||
|
manualSnapshotState.workspaceId === workspaceId
|
||||||
|
? manualSnapshotState.baseSyncKey
|
||||||
|
: null;
|
||||||
|
|
||||||
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
|
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
|
||||||
const baseLiveDataSyncKey = useMemo(
|
const baseLiveDataSyncKey = useMemo(
|
||||||
() => buildSidebarDataSyncKey(baseLiveData),
|
() => buildSidebarDataSyncKey(baseLiveData),
|
||||||
[baseLiveData],
|
[baseLiveData],
|
||||||
);
|
);
|
||||||
const baseLiveDataFreshness = useMemo(
|
|
||||||
() => getSidebarDataFreshness(baseLiveData),
|
|
||||||
[baseLiveData],
|
|
||||||
);
|
|
||||||
const manualSnapshotSyncKey = useMemo(
|
const manualSnapshotSyncKey = useMemo(
|
||||||
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
|
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
|
||||||
[manualSnapshot],
|
[manualSnapshot],
|
||||||
);
|
);
|
||||||
const manualSnapshotFreshness = useMemo(
|
|
||||||
() => (manualSnapshot ? getSidebarDataFreshness(manualSnapshot) : Number.NEGATIVE_INFINITY),
|
|
||||||
[manualSnapshot],
|
|
||||||
);
|
|
||||||
const liveData = useMemo(() => {
|
const liveData = useMemo(() => {
|
||||||
if (!manualSnapshot) {
|
if (!manualSnapshot) {
|
||||||
return baseLiveData;
|
return baseLiveData;
|
||||||
}
|
}
|
||||||
if (manualSnapshotSyncKey === baseLiveDataSyncKey) {
|
if (
|
||||||
return baseLiveData;
|
manualSnapshotBaseSyncKey === baseLiveDataSyncKey &&
|
||||||
|
manualSnapshotSyncKey &&
|
||||||
|
manualSnapshotSyncKey !== baseLiveDataSyncKey
|
||||||
|
) {
|
||||||
|
return manualSnapshot;
|
||||||
}
|
}
|
||||||
return manualSnapshotFreshness >= baseLiveDataFreshness
|
return baseLiveData;
|
||||||
? manualSnapshot
|
|
||||||
: baseLiveData;
|
|
||||||
}, [
|
}, [
|
||||||
baseLiveData,
|
baseLiveData,
|
||||||
baseLiveDataFreshness,
|
|
||||||
baseLiveDataSyncKey,
|
baseLiveDataSyncKey,
|
||||||
manualSnapshot,
|
manualSnapshot,
|
||||||
manualSnapshotFreshness,
|
manualSnapshotBaseSyncKey,
|
||||||
manualSnapshotSyncKey,
|
manualSnapshotSyncKey,
|
||||||
]);
|
]);
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -145,6 +141,7 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
|||||||
setManualSnapshotState({
|
setManualSnapshotState({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
data: refreshedSnapshot,
|
data: refreshedSnapshot,
|
||||||
|
baseSyncKey: buildSidebarDataSyncKey(liveDataRef.current),
|
||||||
});
|
});
|
||||||
return refreshedSnapshot;
|
return refreshedSnapshot;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe("tree-command-client", () => {
|
|||||||
it("通过统一 client 发送 tree/document command 并返回结果", async () => {
|
it("通过统一 client 发送 tree/document command 并返回结果", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ ok: true, success: true, items: [] }),
|
json: async () => ({ ok: true, success: true, items: [], id: "doc_created", documentId: "doc_created" }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
await createDocumentCommand(null);
|
await createDocumentCommand(null);
|
||||||
@@ -41,9 +41,9 @@ describe("tree-command-client", () => {
|
|||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(10);
|
expect(fetchMock).toHaveBeenCalledTimes(10);
|
||||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
|
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
|
||||||
"/api/documents/create",
|
"/api/tree/commands",
|
||||||
"/api/documents/title",
|
"/api/tree/commands",
|
||||||
"/api/documents/move",
|
"/api/tree/commands",
|
||||||
"/api/documents/delete",
|
"/api/documents/delete",
|
||||||
"/api/documents/restore",
|
"/api/documents/restore",
|
||||||
"/api/documents/purge",
|
"/api/documents/purge",
|
||||||
@@ -52,6 +52,24 @@ describe("tree-command-client", () => {
|
|||||||
"/api/documents/title",
|
"/api/documents/title",
|
||||||
"/api/documents/options",
|
"/api/documents/options",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
||||||
|
action: "create",
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
|
||||||
|
action: "rename",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: null,
|
||||||
|
title: "新标题",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({
|
||||||
|
action: "move",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
workspaceId: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("在后端返回错误时抛出统一异常", async () => {
|
it("在后端返回错误时抛出统一异常", async () => {
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ type MoveDocumentInput = {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TreeCommandAction = "create" | "rename" | "move";
|
||||||
|
|
||||||
type DeleteDocumentInput = {
|
type DeleteDocumentInput = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
@@ -148,8 +150,56 @@ async function postDocumentCommand<TResult>(path: string, payload: unknown, fall
|
|||||||
return body as TResult;
|
return body as TResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TreeCommandResponse = {
|
||||||
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
result?: {
|
||||||
|
action?: TreeCommandAction;
|
||||||
|
workspaceId?: string | null;
|
||||||
|
documentId?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
execution?: {
|
||||||
|
access_scope?: "private" | "shared" | "public";
|
||||||
|
is_template?: boolean;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function postTreeCommand<TResult>(payload: unknown, fallbackMessage: string): Promise<TResult> {
|
||||||
|
return postDocumentCommand<TResult>("/api/tree/commands", payload, fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDocumentCommand(parentId: string | null): Promise<DocumentCreateCommandResult> {
|
export async function createDocumentCommand(parentId: string | null): Promise<DocumentCreateCommandResult> {
|
||||||
return postDocumentCommand<DocumentCreateCommandResult>("/api/documents/create", { parentId }, "新建页面失败,请稍后再试");
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
|
{
|
||||||
|
action: "create",
|
||||||
|
parentId,
|
||||||
|
},
|
||||||
|
"新建页面失败,请稍后再试",
|
||||||
|
);
|
||||||
|
const result = response.result;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result?.documentId ?? "",
|
||||||
|
title: result?.title ?? "无标题",
|
||||||
|
parent_id: result?.parentId ?? parentId,
|
||||||
|
sort_order: result?.sortOrder ?? null,
|
||||||
|
workspace_id: result?.workspaceId ?? undefined,
|
||||||
|
access_scope: result?.execution?.access_scope ?? "private",
|
||||||
|
is_template: result?.execution?.is_template ?? false,
|
||||||
|
created_at: result?.execution?.created_at ?? null,
|
||||||
|
updated_at: result?.execution?.updated_at ?? result?.updatedAt ?? null,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.create.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createChildDocumentCommand(
|
export async function createChildDocumentCommand(
|
||||||
@@ -163,15 +213,24 @@ export async function createChildDocumentCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function renameDocumentCommand(input: RenameDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
export async function renameDocumentCommand(input: RenameDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
"/api/documents/title",
|
|
||||||
{
|
{
|
||||||
|
action: "rename",
|
||||||
documentId: input.documentId,
|
documentId: input.documentId,
|
||||||
workspaceId: input.workspaceId ?? null,
|
workspaceId: input.workspaceId ?? null,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
},
|
},
|
||||||
"重命名失败,请稍后再试",
|
"重命名失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.rename.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePageTitleCommand(
|
export async function updatePageTitleCommand(
|
||||||
@@ -192,16 +251,25 @@ export async function updatePageOptionsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
const response = await postTreeCommand<TreeCommandResponse>(
|
||||||
"/api/documents/move",
|
|
||||||
{
|
{
|
||||||
|
action: "move",
|
||||||
documentId: input.documentId,
|
documentId: input.documentId,
|
||||||
parentId: input.parentId ?? null,
|
parentId: input.parentId ?? null,
|
||||||
position: input.position,
|
sortOrder: Number.isFinite(input.position) ? Math.floor(input.position) : 0,
|
||||||
workspaceId: input.workspaceId ?? null,
|
workspaceId: input.workspaceId ?? null,
|
||||||
},
|
},
|
||||||
"移动失败,请稍后再试",
|
"移动失败,请稍后再试",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meta: {
|
||||||
|
requestId: response.requestId,
|
||||||
|
traceId: response.traceId,
|
||||||
|
commandName: TREE_COMMAND_PROTOCOL.move.preferredCommandName,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocumentCommand(
|
export async function deleteDocumentCommand(
|
||||||
|
|||||||
@@ -117,6 +117,172 @@ describe("buildVisibleRows", () => {
|
|||||||
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
||||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
|
||||||
|
const rows = buildVisibleRows({
|
||||||
|
fileTreeItems: [
|
||||||
|
{
|
||||||
|
rowId: "doc:page_root",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "page_root",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "根页面",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: 4,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: ["expand", "open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "page_root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:page_root",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:page_root",
|
||||||
|
parentNodeId: "page_root",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "page_root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset_folder",
|
||||||
|
nodeId: "asset-folder:mind_1",
|
||||||
|
parentNodeId: "page_root",
|
||||||
|
nodeType: "mindmap",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "头脑风暴",
|
||||||
|
depth: 1,
|
||||||
|
position: 1,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["expand", "open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "mindmap",
|
||||||
|
documentId: "page_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "mindmap",
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:asset_child_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: "asset:asset_child_1",
|
||||||
|
parentNodeId: "asset-folder:mind_1",
|
||||||
|
nodeType: "asset",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "节点图片.png",
|
||||||
|
depth: 2,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "asset",
|
||||||
|
documentId: "page_root",
|
||||||
|
assetId: "asset_child_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "image",
|
||||||
|
iconHint: "image",
|
||||||
|
},
|
||||||
|
iconHint: "image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:table_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: "asset:table_1",
|
||||||
|
parentNodeId: "page_root",
|
||||||
|
nodeType: "table",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "预算.luckysheet",
|
||||||
|
depth: 1,
|
||||||
|
position: 2,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open-asset", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "table",
|
||||||
|
documentId: "page_root",
|
||||||
|
assetId: "table_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
assetKind: "table",
|
||||||
|
iconHint: "table",
|
||||||
|
},
|
||||||
|
iconHint: "table",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expanded: new Set(["page_root"]),
|
||||||
|
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows.map((row) => `${row.kind}:${row.depth}:${row.rowId}`)).toEqual([
|
||||||
|
"doc:0:doc:page_root",
|
||||||
|
"index:1:index:page_root",
|
||||||
|
"asset-folder:1:asset-folder:mind_1",
|
||||||
|
"asset:2:asset:asset_child_1",
|
||||||
|
"asset:1:asset:table_1",
|
||||||
|
]);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
kind: "doc",
|
||||||
|
docId: "page_root",
|
||||||
|
hasChildren: true,
|
||||||
|
isExpanded: true,
|
||||||
|
});
|
||||||
|
expect(rows[2]).toMatchObject({
|
||||||
|
kind: "asset-folder",
|
||||||
|
docId: "page_root",
|
||||||
|
asset: {
|
||||||
|
id: "mind_1",
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_name: "头脑风暴",
|
||||||
|
},
|
||||||
|
hasChildren: true,
|
||||||
|
isExpanded: true,
|
||||||
|
});
|
||||||
|
expect(rows[3]).toMatchObject({
|
||||||
|
kind: "asset",
|
||||||
|
asset: {
|
||||||
|
id: "asset_child_1",
|
||||||
|
asset_type: "image",
|
||||||
|
file_name: "节点图片.png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(rows[4]).toMatchObject({
|
||||||
|
kind: "asset",
|
||||||
|
asset: {
|
||||||
|
id: "table_1",
|
||||||
|
asset_type: "luckysheet",
|
||||||
|
file_name: "预算.luckysheet",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseFileTreeRowId", () => {
|
describe("parseFileTreeRowId", () => {
|
||||||
|
|||||||
@@ -1,28 +1,129 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||||
|
import {
|
||||||
|
getDocIdFromFileTreeItem,
|
||||||
|
resolveFileTreeRowAsset,
|
||||||
|
resolveFileTreeRowNode,
|
||||||
|
} from "@/lib/kernel-file-tree";
|
||||||
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import type { FileTreeRow } from "./types";
|
import type { FileTreeRow } from "./types";
|
||||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||||
|
|
||||||
|
function buildRowsFromKernelFileTreeProjection(input: {
|
||||||
|
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||||
|
expanded: Set<string>;
|
||||||
|
expandedAssetFolderIds?: Set<string>;
|
||||||
|
nodeById?: Map<string, SidebarTreeNode>;
|
||||||
|
assetById?: Map<string, MediaAsset>;
|
||||||
|
}): FileTreeRow[] {
|
||||||
|
const rows: FileTreeRow[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
input.fileTreeItems.forEach((item) => {
|
||||||
|
if (!item?.rowId || seen.has(item.rowId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(item.rowId);
|
||||||
|
const docId = getDocIdFromFileTreeItem(item);
|
||||||
|
|
||||||
|
switch (item.rowKind) {
|
||||||
|
case "document": {
|
||||||
|
const node = resolveFileTreeRowNode(item, input.nodeById);
|
||||||
|
rows.push({
|
||||||
|
kind: "doc",
|
||||||
|
rowId: makeDocRowId(docId),
|
||||||
|
depth: item.depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: item.parentNodeId,
|
||||||
|
node,
|
||||||
|
hasChildren: item.childCount > 0,
|
||||||
|
isExpanded: input.expanded.has(docId),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "index": {
|
||||||
|
const node = resolveFileTreeRowNode(item, input.nodeById);
|
||||||
|
rows.push({
|
||||||
|
kind: "index",
|
||||||
|
rowId: makeIndexRowId(docId),
|
||||||
|
depth: item.depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: docId,
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "asset_folder": {
|
||||||
|
const asset = resolveFileTreeRowAsset(item, input.assetById);
|
||||||
|
rows.push({
|
||||||
|
kind: "asset-folder",
|
||||||
|
rowId: makeAssetFolderRowId(asset.id),
|
||||||
|
depth: item.depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: docId,
|
||||||
|
asset,
|
||||||
|
hasChildren: item.childCount > 0,
|
||||||
|
isExpanded: input.expandedAssetFolderIds?.has(asset.id) ?? false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "asset": {
|
||||||
|
const asset = resolveFileTreeRowAsset(item, input.assetById);
|
||||||
|
rows.push({
|
||||||
|
kind: "asset",
|
||||||
|
rowId: makeAssetRowId(asset.id),
|
||||||
|
depth: item.depth,
|
||||||
|
docId,
|
||||||
|
parentDocId: docId,
|
||||||
|
asset,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildVisibleRows({
|
export function buildVisibleRows({
|
||||||
|
fileTreeItems,
|
||||||
pageRows,
|
pageRows,
|
||||||
expanded,
|
expanded,
|
||||||
assetsByDoc,
|
assetsByDoc,
|
||||||
assetChildrenByAssetId,
|
assetChildrenByAssetId,
|
||||||
expandedAssetFolderIds,
|
expandedAssetFolderIds,
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
}: {
|
}: {
|
||||||
|
fileTreeItems?: KernelFileTreeProjectionItem[];
|
||||||
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
||||||
pageRows: PageTreeProjectionItem[];
|
pageRows?: PageTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
assetsByDoc: Record<string, MediaAsset[]>;
|
assetsByDoc?: Record<string, MediaAsset[]>;
|
||||||
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
||||||
expandedAssetFolderIds?: Set<string>;
|
expandedAssetFolderIds?: Set<string>;
|
||||||
|
nodeById?: Map<string, SidebarTreeNode>;
|
||||||
|
assetById?: Map<string, MediaAsset>;
|
||||||
}): FileTreeRow[] {
|
}): FileTreeRow[] {
|
||||||
|
if (fileTreeItems && fileTreeItems.length > 0) {
|
||||||
|
return buildRowsFromKernelFileTreeProjection({
|
||||||
|
fileTreeItems,
|
||||||
|
expanded,
|
||||||
|
expandedAssetFolderIds,
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const safePageRows = pageRows ?? [];
|
||||||
|
const safeAssetsByDoc = assetsByDoc ?? {};
|
||||||
const rows: FileTreeRow[] = [];
|
const rows: FileTreeRow[] = [];
|
||||||
|
|
||||||
pageRows.forEach((item) => {
|
safePageRows.forEach((item) => {
|
||||||
const assets = assetsByDoc[item.nodeId] ?? [];
|
const assets = safeAssetsByDoc[item.nodeId] ?? [];
|
||||||
const hasChildren = item.childCount > 0 || assets.length > 0;
|
const hasChildren = item.childCount > 0 || assets.length > 0;
|
||||||
const isExpanded = expanded.has(item.nodeId);
|
const isExpanded = expanded.has(item.nodeId);
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import type { DocumentRecord } from "@/lib/documents";
|
||||||
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
|
import type {
|
||||||
|
TreeProjectionAssetKind,
|
||||||
|
TreeProjectionCapability,
|
||||||
|
TreeProjectionItemBase,
|
||||||
|
TreeProjectionNodeType,
|
||||||
|
TreeProjectionResourceKind,
|
||||||
|
} from "@/lib/tree-protocol";
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
|
||||||
|
export type KernelFileTreeProjectionRowKind =
|
||||||
|
| "document"
|
||||||
|
| "index"
|
||||||
|
| "asset"
|
||||||
|
| "asset_folder";
|
||||||
|
|
||||||
|
export type KernelFileTreeProjectionItem = TreeProjectionItemBase & {
|
||||||
|
projectionKind: "file_tree";
|
||||||
|
rowId: string;
|
||||||
|
rowKind: KernelFileTreeProjectionRowKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KernelFileTreeProjectionEdge = {
|
||||||
|
id: string;
|
||||||
|
edgeType: "parent_of";
|
||||||
|
workspaceId: string | null;
|
||||||
|
fromNodeId: string;
|
||||||
|
toNodeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KernelFileTreeProjection = {
|
||||||
|
projectionId: string;
|
||||||
|
projection: "file_tree";
|
||||||
|
rootNodeId: string | null;
|
||||||
|
items: KernelFileTreeProjectionItem[];
|
||||||
|
edges: KernelFileTreeProjectionEdge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildKernelFileTreeProjectionInput = {
|
||||||
|
documents: DocumentRecord[];
|
||||||
|
mediaAssets?: MediaAsset[] | null;
|
||||||
|
mindmapAssets?: MediaAsset[] | null;
|
||||||
|
tableAssets?: MediaAsset[] | null;
|
||||||
|
mindmapAssetChildren?: Record<string, string[]> | null;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedFileTreeAsset = {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string | null;
|
||||||
|
title: string;
|
||||||
|
resourceKind: TreeProjectionResourceKind;
|
||||||
|
assetKind: TreeProjectionAssetKind;
|
||||||
|
iconHint: string;
|
||||||
|
nodeType: TreeProjectionNodeType;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readRowId(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMillis(value: string | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDocuments(records: DocumentRecord[]) {
|
||||||
|
return [...records].sort((a, b) => {
|
||||||
|
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (orderA !== orderB) {
|
||||||
|
return orderA - orderB;
|
||||||
|
}
|
||||||
|
return toMillis(a.created_at) - toMillis(b.created_at);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeDocuments(records: DocumentRecord[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique: DocumentRecord[] = [];
|
||||||
|
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||||
|
const record = records[index]!;
|
||||||
|
if (seen.has(record.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(record.id);
|
||||||
|
unique.push(record);
|
||||||
|
}
|
||||||
|
unique.reverse();
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentChildren(records: DocumentRecord[]) {
|
||||||
|
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
|
||||||
|
const recordIds = new Set(records.map((record) => record.id));
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const parentId =
|
||||||
|
record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
|
||||||
|
const bucket = childrenByParentId.get(parentId) ?? [];
|
||||||
|
bucket.push(record);
|
||||||
|
childrenByParentId.set(parentId, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [parentId, bucket] of childrenByParentId.entries()) {
|
||||||
|
childrenByParentId.set(parentId, sortDocuments(bucket));
|
||||||
|
}
|
||||||
|
|
||||||
|
return childrenByParentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProjectionEdge(
|
||||||
|
fromNodeId: string,
|
||||||
|
toNodeId: string,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): KernelFileTreeProjectionEdge {
|
||||||
|
return {
|
||||||
|
id: `edge:${fromNodeId}:${toNodeId}:parent_of`,
|
||||||
|
edgeType: "parent_of",
|
||||||
|
workspaceId,
|
||||||
|
fromNodeId,
|
||||||
|
toNodeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||||
|
const capabilities: TreeProjectionCapability[] = [
|
||||||
|
"open",
|
||||||
|
"drag",
|
||||||
|
"drop",
|
||||||
|
"select",
|
||||||
|
"create-child",
|
||||||
|
"rename",
|
||||||
|
"archive",
|
||||||
|
"restore",
|
||||||
|
"context-menu",
|
||||||
|
"reorder",
|
||||||
|
];
|
||||||
|
if (childCount > 0) {
|
||||||
|
capabilities.unshift("expand");
|
||||||
|
}
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssetFolderCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||||
|
const capabilities: TreeProjectionCapability[] = [
|
||||||
|
"open-asset",
|
||||||
|
"select",
|
||||||
|
"context-menu",
|
||||||
|
];
|
||||||
|
if (childCount > 0) {
|
||||||
|
capabilities.unshift("expand");
|
||||||
|
}
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeafCapabilities(openAsset: boolean): TreeProjectionCapability[] {
|
||||||
|
const capabilities: TreeProjectionCapability[] = ["select", "context-menu"];
|
||||||
|
capabilities.unshift(openAsset ? "open-asset" : "open");
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyGenericAssetKind(asset: MediaAsset): TreeProjectionAssetKind {
|
||||||
|
const assetType = String(asset.asset_type ?? "").trim().toLowerCase();
|
||||||
|
const name = String(asset.file_name ?? "").trim().toLowerCase();
|
||||||
|
const mimeType = String(asset.mime_type ?? "").trim().toLowerCase();
|
||||||
|
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||||
|
|
||||||
|
if (assetType === "mindmap") return "mindmap";
|
||||||
|
if (assetType === "luckysheet") return "table";
|
||||||
|
if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
|
||||||
|
if (ext === "epub" || mimeType.includes("epub")) return "book";
|
||||||
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
|
if (mimeType.startsWith("audio/")) return "audio";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyResourceKind(assetKind: TreeProjectionAssetKind): TreeProjectionResourceKind {
|
||||||
|
switch (assetKind) {
|
||||||
|
case "mindmap":
|
||||||
|
return "mindmap";
|
||||||
|
case "table":
|
||||||
|
return "table";
|
||||||
|
case "book":
|
||||||
|
return "book";
|
||||||
|
case "pdf":
|
||||||
|
return "pdf";
|
||||||
|
default:
|
||||||
|
return "asset";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyNodeType(assetKind: TreeProjectionAssetKind): TreeProjectionNodeType {
|
||||||
|
switch (assetKind) {
|
||||||
|
case "mindmap":
|
||||||
|
return "mindmap";
|
||||||
|
case "table":
|
||||||
|
return "table";
|
||||||
|
case "book":
|
||||||
|
return "book";
|
||||||
|
case "pdf":
|
||||||
|
return "pdf";
|
||||||
|
default:
|
||||||
|
return "asset";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyIconHint(assetKind: TreeProjectionAssetKind): string {
|
||||||
|
switch (assetKind) {
|
||||||
|
case "mindmap":
|
||||||
|
return "mindmap";
|
||||||
|
case "table":
|
||||||
|
return "table";
|
||||||
|
case "book":
|
||||||
|
return "book";
|
||||||
|
case "pdf":
|
||||||
|
return "pdf";
|
||||||
|
case "image":
|
||||||
|
return "image";
|
||||||
|
case "video":
|
||||||
|
return "video";
|
||||||
|
case "audio":
|
||||||
|
return "audio";
|
||||||
|
default:
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAsset(asset: MediaAsset): NormalizedFileTreeAsset {
|
||||||
|
const assetKind = classifyGenericAssetKind(asset);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
documentId: asset.document_id,
|
||||||
|
workspaceId: asset.workspace_id ?? null,
|
||||||
|
title: asset.file_name?.trim() || "附件",
|
||||||
|
resourceKind: classifyResourceKind(assetKind),
|
||||||
|
assetKind,
|
||||||
|
iconHint: classifyIconHint(assetKind),
|
||||||
|
nodeType: classifyNodeType(assetKind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssetCollections(input: BuildKernelFileTreeProjectionInput) {
|
||||||
|
const assetsByDoc = new Map<string, NormalizedFileTreeAsset[]>();
|
||||||
|
const assetById = new Map<string, NormalizedFileTreeAsset>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const asset of [
|
||||||
|
...(input.mediaAssets ?? []),
|
||||||
|
...(input.mindmapAssets ?? []),
|
||||||
|
...(input.tableAssets ?? []),
|
||||||
|
]) {
|
||||||
|
if (!asset?.id || seen.has(asset.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(asset.id);
|
||||||
|
const normalized = normalizeAsset(asset);
|
||||||
|
const bucket = assetsByDoc.get(normalized.documentId) ?? [];
|
||||||
|
bucket.push(normalized);
|
||||||
|
assetsByDoc.set(normalized.documentId, bucket);
|
||||||
|
assetById.set(normalized.id, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
const childAssetIdsByParentId = new Map<string, string[]>();
|
||||||
|
Object.entries(input.mindmapAssetChildren ?? {}).forEach(([parentAssetId, childIds]) => {
|
||||||
|
childAssetIdsByParentId.set(
|
||||||
|
parentAssetId,
|
||||||
|
(childIds ?? []).filter((childId) => typeof childId === "string" && childId.trim().length > 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetsByDoc,
|
||||||
|
assetById,
|
||||||
|
childAssetIdsByParentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFallbackNode(item: KernelFileTreeProjectionItem): SidebarTreeNode {
|
||||||
|
const documentId =
|
||||||
|
item.resourceMeta.documentId ??
|
||||||
|
(item.rowKind === "document" ? item.nodeId : item.parentNodeId ?? item.nodeId);
|
||||||
|
return {
|
||||||
|
access_scope: "private",
|
||||||
|
id: documentId,
|
||||||
|
workspace_id: item.resourceMeta.workspaceId ?? "",
|
||||||
|
title: item.title,
|
||||||
|
parent_id: item.rowKind === "document" ? item.parentNodeId : documentId,
|
||||||
|
sort_order: item.position,
|
||||||
|
is_starred: false,
|
||||||
|
is_template: false,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: null,
|
||||||
|
children: [],
|
||||||
|
kernel: {
|
||||||
|
nodeType: "page",
|
||||||
|
depth: item.depth,
|
||||||
|
position: item.position,
|
||||||
|
childCount: item.childCount,
|
||||||
|
expandedByDefault: item.expandedByDefault,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFallbackAsset(item: KernelFileTreeProjectionItem): MediaAsset {
|
||||||
|
const assetKind = item.resourceMeta.assetKind ?? "unknown";
|
||||||
|
const resourceKind = item.resourceMeta.resourceKind;
|
||||||
|
const assetType =
|
||||||
|
assetKind === "mindmap"
|
||||||
|
? "mindmap"
|
||||||
|
: assetKind === "table" || resourceKind === "table"
|
||||||
|
? "luckysheet"
|
||||||
|
: assetKind;
|
||||||
|
return {
|
||||||
|
id: item.resourceMeta.assetId ?? item.nodeId,
|
||||||
|
workspace_id: item.resourceMeta.workspaceId ?? "",
|
||||||
|
document_id: item.resourceMeta.documentId ?? "",
|
||||||
|
asset_type: assetType,
|
||||||
|
file_url: null,
|
||||||
|
thumbnail_url: null,
|
||||||
|
bucket: null,
|
||||||
|
storage_path: null,
|
||||||
|
file_name: item.title,
|
||||||
|
file_size: null,
|
||||||
|
mime_type: null,
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
signed_url: null,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocIdFromFileTreeItem(item: KernelFileTreeProjectionItem): string {
|
||||||
|
if (item.resourceMeta.documentId) {
|
||||||
|
return item.resourceMeta.documentId;
|
||||||
|
}
|
||||||
|
if (item.rowKind === "document") {
|
||||||
|
return item.nodeId;
|
||||||
|
}
|
||||||
|
const rowId = readRowId(item.rowId);
|
||||||
|
if (rowId?.startsWith("index:")) {
|
||||||
|
return rowId.slice("index:".length);
|
||||||
|
}
|
||||||
|
return item.parentNodeId ?? item.nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFileTreeRowNode(
|
||||||
|
item: KernelFileTreeProjectionItem,
|
||||||
|
nodeById?: Map<string, SidebarTreeNode>,
|
||||||
|
): SidebarTreeNode {
|
||||||
|
const documentId = getDocIdFromFileTreeItem(item);
|
||||||
|
return nodeById?.get(documentId) ?? makeFallbackNode(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFileTreeRowAsset(
|
||||||
|
item: KernelFileTreeProjectionItem,
|
||||||
|
assetById?: Map<string, MediaAsset>,
|
||||||
|
): MediaAsset {
|
||||||
|
const assetId = item.resourceMeta.assetId ?? item.nodeId;
|
||||||
|
return assetById?.get(assetId) ?? makeFallbackAsset(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKernelFileTreeProjection(value: unknown): value is KernelFileTreeProjection {
|
||||||
|
return (
|
||||||
|
Boolean(value) &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
(value as KernelFileTreeProjection).projection === "file_tree" &&
|
||||||
|
Array.isArray((value as KernelFileTreeProjection).items) &&
|
||||||
|
Array.isArray((value as KernelFileTreeProjection).edges)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKernelFileTreeProjection(
|
||||||
|
input: BuildKernelFileTreeProjectionInput,
|
||||||
|
): KernelFileTreeProjection {
|
||||||
|
const uniqueDocuments = dedupeDocuments(input.documents);
|
||||||
|
const documentById = new Map(uniqueDocuments.map((document) => [document.id, document]));
|
||||||
|
const childrenByParentId = buildDocumentChildren(uniqueDocuments);
|
||||||
|
const { assetsByDoc, assetById, childAssetIdsByParentId } = buildAssetCollections(input);
|
||||||
|
const rootNodeId = input.rootNodeId?.trim() || null;
|
||||||
|
const roots =
|
||||||
|
rootNodeId && documentById.has(rootNodeId)
|
||||||
|
? [rootNodeId]
|
||||||
|
: sortDocuments(
|
||||||
|
uniqueDocuments.filter((document) => {
|
||||||
|
const parentId = document.parent_id?.trim() || null;
|
||||||
|
return !parentId || !documentById.has(parentId);
|
||||||
|
}),
|
||||||
|
).map((document) => document.id);
|
||||||
|
|
||||||
|
const items: KernelFileTreeProjectionItem[] = [];
|
||||||
|
const edges: KernelFileTreeProjectionEdge[] = [];
|
||||||
|
|
||||||
|
const walk = (documentId: string, depth: number) => {
|
||||||
|
const document = documentById.get(documentId);
|
||||||
|
if (!document) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childDocuments = childrenByParentId.get(documentId) ?? [];
|
||||||
|
const documentAssets = assetsByDoc.get(documentId) ?? [];
|
||||||
|
const nestedChildIds = new Set<string>();
|
||||||
|
documentAssets.forEach((asset) => {
|
||||||
|
const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
|
||||||
|
childIds.forEach((childId) => nestedChildIds.add(childId));
|
||||||
|
});
|
||||||
|
const directAssets = documentAssets.filter((asset) => !nestedChildIds.has(asset.id));
|
||||||
|
const childCount = 1 + directAssets.length + childDocuments.length;
|
||||||
|
const parentNodeId = depth === 0 ? null : document.parent_id ?? null;
|
||||||
|
const workspaceId = document.workspace_id ?? null;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
rowId: `doc:${document.id}`,
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: document.id,
|
||||||
|
parentNodeId,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: document.title ?? "无标题",
|
||||||
|
depth,
|
||||||
|
position: document.sort_order ?? null,
|
||||||
|
childCount,
|
||||||
|
expandable: childCount > 0,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: buildDocumentCapabilities(childCount),
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: document.id,
|
||||||
|
workspaceId,
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexNodeId = `index:${document.id}`;
|
||||||
|
items.push({
|
||||||
|
rowId: indexNodeId,
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: indexNodeId,
|
||||||
|
parentNodeId: document.id,
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: depth + 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: buildLeafCapabilities(false),
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: document.id,
|
||||||
|
workspaceId,
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
});
|
||||||
|
edges.push(makeProjectionEdge(document.id, indexNodeId, workspaceId));
|
||||||
|
|
||||||
|
let assetPosition = 1;
|
||||||
|
directAssets.forEach((asset) => {
|
||||||
|
const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
|
||||||
|
if (childIds.length > 0) {
|
||||||
|
const folderNodeId = `asset-folder:${asset.id}`;
|
||||||
|
items.push({
|
||||||
|
rowId: folderNodeId,
|
||||||
|
rowKind: "asset_folder",
|
||||||
|
nodeId: folderNodeId,
|
||||||
|
parentNodeId: document.id,
|
||||||
|
nodeType: "mindmap",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: asset.title.replace(/\.json$/i, ""),
|
||||||
|
depth: depth + 1,
|
||||||
|
position: assetPosition,
|
||||||
|
childCount: childIds.length,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: buildAssetFolderCapabilities(childIds.length),
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: asset.resourceKind,
|
||||||
|
documentId: asset.documentId,
|
||||||
|
assetId: asset.id,
|
||||||
|
workspaceId: asset.workspaceId,
|
||||||
|
assetKind: asset.assetKind,
|
||||||
|
iconHint: "mindmap",
|
||||||
|
},
|
||||||
|
iconHint: "mindmap",
|
||||||
|
});
|
||||||
|
edges.push(makeProjectionEdge(document.id, folderNodeId, workspaceId));
|
||||||
|
|
||||||
|
childIds.forEach((childAssetId, index) => {
|
||||||
|
const childAsset = assetById.get(childAssetId);
|
||||||
|
if (!childAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const childNodeId = `asset:${childAsset.id}`;
|
||||||
|
items.push({
|
||||||
|
rowId: childNodeId,
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: childNodeId,
|
||||||
|
parentNodeId: folderNodeId,
|
||||||
|
nodeType: childAsset.nodeType,
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: childAsset.title,
|
||||||
|
depth: depth + 2,
|
||||||
|
position: index,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: buildLeafCapabilities(true),
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: childAsset.resourceKind,
|
||||||
|
documentId: childAsset.documentId,
|
||||||
|
assetId: childAsset.id,
|
||||||
|
workspaceId: childAsset.workspaceId,
|
||||||
|
assetKind: childAsset.assetKind,
|
||||||
|
iconHint: childAsset.iconHint,
|
||||||
|
},
|
||||||
|
iconHint: childAsset.iconHint,
|
||||||
|
});
|
||||||
|
edges.push(makeProjectionEdge(folderNodeId, childNodeId, workspaceId));
|
||||||
|
});
|
||||||
|
|
||||||
|
assetPosition += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetNodeId = `asset:${asset.id}`;
|
||||||
|
items.push({
|
||||||
|
rowId: assetNodeId,
|
||||||
|
rowKind: "asset",
|
||||||
|
nodeId: assetNodeId,
|
||||||
|
parentNodeId: document.id,
|
||||||
|
nodeType: asset.nodeType,
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: asset.title,
|
||||||
|
depth: depth + 1,
|
||||||
|
position: assetPosition,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: buildLeafCapabilities(true),
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: asset.resourceKind,
|
||||||
|
documentId: asset.documentId,
|
||||||
|
assetId: asset.id,
|
||||||
|
workspaceId: asset.workspaceId,
|
||||||
|
assetKind: asset.assetKind,
|
||||||
|
iconHint: asset.iconHint,
|
||||||
|
},
|
||||||
|
iconHint: asset.iconHint,
|
||||||
|
});
|
||||||
|
edges.push(makeProjectionEdge(document.id, assetNodeId, workspaceId));
|
||||||
|
assetPosition += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
childDocuments.forEach((childDocument) => {
|
||||||
|
walk(childDocument.id, depth + 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
roots.forEach((documentId) => walk(documentId, 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectionId: `kernel_projection:file_tree:${rootNodeId ?? "root"}`,
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId,
|
||||||
|
items,
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
const DEFAULT_MNOTE_WEB_INTERNAL_URL = "http://127.0.0.1:3104";
|
||||||
|
const DEFAULT_MNOTE_WEB_INTERNAL_URL_CANDIDATES = [
|
||||||
|
DEFAULT_MNOTE_WEB_INTERNAL_URL,
|
||||||
|
"http://localhost:3104",
|
||||||
|
];
|
||||||
|
const MNOTE_WEB_PROBE_PATH = "/health";
|
||||||
|
const RESOLVE_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
let cachedMnoteWebInternalUrl = "";
|
||||||
|
let cachedMnoteWebInternalUrlAt = 0;
|
||||||
|
let pendingMnoteWebInternalUrl: Promise<string> | null = null;
|
||||||
|
|
||||||
|
const normalizeMnoteWebInternalUrl = (raw?: string | null) => {
|
||||||
|
const value = String(raw || "").trim().replace(/\/+$/, "");
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/+$/, "");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMnoteWebInternalUrlCandidates = () => {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const push = (value?: string | null) => {
|
||||||
|
const normalized = normalizeMnoteWebInternalUrl(value);
|
||||||
|
if (!normalized) return;
|
||||||
|
if (!candidates.includes(normalized)) {
|
||||||
|
candidates.push(normalized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
push(process.env.MNOTE_WEB_INTERNAL_URL);
|
||||||
|
|
||||||
|
for (const raw of String(process.env.MNOTE_WEB_INTERNAL_URL_CANDIDATES || "").split(",")) {
|
||||||
|
push(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of DEFAULT_MNOTE_WEB_INTERNAL_URL_CANDIDATES) {
|
||||||
|
push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.length > 0 ? candidates : [DEFAULT_MNOTE_WEB_INTERNAL_URL];
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeMnoteWebInternalUrl = async (candidate: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(MNOTE_WEB_PROBE_PATH, `${candidate}/`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "follow",
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(2_500),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveMnoteWebInternalUrl = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedMnoteWebInternalUrl && now - cachedMnoteWebInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
|
||||||
|
return cachedMnoteWebInternalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingMnoteWebInternalUrl) {
|
||||||
|
return pendingMnoteWebInternalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMnoteWebInternalUrl = (async () => {
|
||||||
|
const candidates = getMnoteWebInternalUrlCandidates();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (await probeMnoteWebInternalUrl(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0] || DEFAULT_MNOTE_WEB_INTERNAL_URL;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await pendingMnoteWebInternalUrl;
|
||||||
|
cachedMnoteWebInternalUrl = resolved;
|
||||||
|
cachedMnoteWebInternalUrlAt = Date.now();
|
||||||
|
return resolved;
|
||||||
|
} finally {
|
||||||
|
pendingMnoteWebInternalUrl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,12 +11,41 @@ describe("runtime-config public projection", () => {
|
|||||||
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
|
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("允许通过新 runtime 配置显式选择树 renderer family", () => {
|
||||||
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
|
treeRendererFamily: "rust_family",
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = getMnoteRuntimeConfig();
|
||||||
|
|
||||||
|
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("树 renderer family 别名应归一到 rust_family", () => {
|
||||||
|
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||||
|
treeRendererFamily: "tree_shell" as "rust_family",
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = getMnoteRuntimeConfig();
|
||||||
|
|
||||||
|
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("树 renderer family 缺省时应回落到 react", () => {
|
||||||
|
const runtime = getMnoteRuntimeConfig();
|
||||||
|
|
||||||
|
expect(runtime.treeRendererFamily).toBe("react");
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
|
delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
|
||||||
delete process.env.MNOTE_WEB_BASE_URL;
|
delete process.env.MNOTE_WEB_BASE_URL;
|
||||||
delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
|
delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
|
||||||
delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
|
delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
|
||||||
|
delete process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY;
|
||||||
|
delete process.env.TREE_RENDERER_FAMILY;
|
||||||
|
delete window.__MNOTE_RUNTIME_CONFIG__;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
|
it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export type MnoteRuntimeConfig = {
|
|||||||
* 说明:开启后,未显式传入 query host 的文档页会优先回退到 blocknote。
|
* 说明:开启后,未显式传入 query host 的文档页会优先回退到 blocknote。
|
||||||
*/
|
*/
|
||||||
documentEditorBlocknoteKillSwitch?: boolean;
|
documentEditorBlocknoteKillSwitch?: boolean;
|
||||||
|
/**
|
||||||
|
* 树域 renderer family 选择。
|
||||||
|
* 说明:默认仍为 react;`rust_family` 只作为渐进切流开关,不代表已完全切主路径。
|
||||||
|
*/
|
||||||
|
treeRendererFamily?: "react" | "rust_family";
|
||||||
/**
|
/**
|
||||||
* 是否为桌面端(Electron)运行。
|
* 是否为桌面端(Electron)运行。
|
||||||
*/
|
*/
|
||||||
@@ -109,6 +114,25 @@ const parseDocumentEditorHost = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseTreeRendererFamily = (
|
||||||
|
value: unknown,
|
||||||
|
): MnoteRuntimeConfig["treeRendererFamily"] | undefined => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (normalized === "rust_family" || normalized === "rust" || normalized === "tree_shell") {
|
||||||
|
return "rust_family";
|
||||||
|
}
|
||||||
|
if (normalized === "react") {
|
||||||
|
return "react";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
function getServerNodeBuiltin<T>(moduleName: string): T | null {
|
function getServerNodeBuiltin<T>(moduleName: string): T | null {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return null;
|
return null;
|
||||||
@@ -167,6 +191,17 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(parseTreeRendererFamily(
|
||||||
|
process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY ??
|
||||||
|
process.env.TREE_RENDERER_FAMILY,
|
||||||
|
) !== undefined
|
||||||
|
? {
|
||||||
|
treeRendererFamily: parseTreeRendererFamily(
|
||||||
|
process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY ??
|
||||||
|
process.env.TREE_RENDERER_FAMILY,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
||||||
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
||||||
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
||||||
@@ -256,12 +291,15 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
|||||||
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
|
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
|
||||||
const documentEditorBlocknoteKillSwitch =
|
const documentEditorBlocknoteKillSwitch =
|
||||||
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
|
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
|
||||||
|
const treeRendererFamily =
|
||||||
|
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "react";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
documentEditorHost,
|
documentEditorHost,
|
||||||
documentEditorBlocknoteKillSwitch,
|
documentEditorBlocknoteKillSwitch,
|
||||||
|
treeRendererFamily,
|
||||||
onlyofficeBaseUrl,
|
onlyofficeBaseUrl,
|
||||||
onlyofficeStorageHostOverride,
|
onlyofficeStorageHostOverride,
|
||||||
onlyofficeProxyOrigin,
|
onlyofficeProxyOrigin,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||||
|
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContextWithActor,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
type BridgeActor,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { executeRustBridgeQuery } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
export async function resolveKernelFileTreeProjection(input: {
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
request: Request;
|
||||||
|
workspaceId: string;
|
||||||
|
actor: BridgeActor;
|
||||||
|
dataset: SidebarDatasetListQueryResult;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
depth?: number | null;
|
||||||
|
}): Promise<KernelFileTreeProjection> {
|
||||||
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
|
request: input.request,
|
||||||
|
actor: input.actor,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return executeRustBridgeQuery<KernelFileTreeProjection>({
|
||||||
|
context,
|
||||||
|
envelope: buildDocumentQueryEnvelope({
|
||||||
|
name: "kernel.project_view",
|
||||||
|
payload: {
|
||||||
|
projection: "file_tree",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: input.rootNodeId ?? null,
|
||||||
|
depth: input.depth ?? null,
|
||||||
|
includeEdges: true,
|
||||||
|
includeContent: false,
|
||||||
|
nodeTypes: ["page"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: input.dataset as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachKernelFileTreeProjection<T extends SidebarDatasetListQueryResult>(input: {
|
||||||
|
dataset: T;
|
||||||
|
projection: KernelFileTreeProjection;
|
||||||
|
}): T {
|
||||||
|
return {
|
||||||
|
...input.dataset,
|
||||||
|
kernel_file_tree_projection: input.projection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,9 +9,15 @@ import {
|
|||||||
type SidebarDatasetListQueryResult,
|
type SidebarDatasetListQueryResult,
|
||||||
} from "@/lib/sidebar-data";
|
} from "@/lib/sidebar-data";
|
||||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||||
|
import {
|
||||||
|
attachKernelFileTreeProjection,
|
||||||
|
resolveKernelFileTreeProjection,
|
||||||
|
} from "@/lib/server/kernel-file-tree";
|
||||||
|
import type { AuthContext } from "@/lib/auth/types";
|
||||||
|
|
||||||
type LoadSidebarDataFromConvexInput = {
|
type LoadSidebarDataFromConvexInput = {
|
||||||
client: ConvexHttpClient;
|
client: ConvexHttpClient;
|
||||||
|
auth: AuthContext;
|
||||||
fallbackName: string;
|
fallbackName: string;
|
||||||
requestedWorkspaceId?: string | null;
|
requestedWorkspaceId?: string | null;
|
||||||
};
|
};
|
||||||
@@ -64,14 +70,29 @@ export async function loadSidebarDataFromConvex(
|
|||||||
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
||||||
workspaceId: targetWorkspaceId,
|
workspaceId: targetWorkspaceId,
|
||||||
})) as SidebarDatasetListQueryResult;
|
})) as SidebarDatasetListQueryResult;
|
||||||
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
const syntheticRequest = new Request(`http://127.0.0.1:3000/api/sidebar?workspaceId=${targetWorkspaceId}`);
|
||||||
|
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
|
||||||
|
dataset: sidebarDataset,
|
||||||
|
projection: await resolveKernelFileTreeProjection({
|
||||||
|
client: input.client,
|
||||||
|
request: syntheticRequest,
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: input.auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
dataset: sidebarDataset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const normalizedDocuments = (sidebarDatasetWithFileTree.documents ?? []) as DocumentRecord[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaces: sidebarDataset.workspaces ?? workspaces,
|
workspaces: sidebarDatasetWithFileTree.workspaces ?? workspaces,
|
||||||
activeWorkspaceId,
|
activeWorkspaceId,
|
||||||
targetWorkspaceId,
|
targetWorkspaceId,
|
||||||
sidebarDataset,
|
sidebarDataset: sidebarDatasetWithFileTree,
|
||||||
sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
|
sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
|
||||||
documents: normalizedDocuments,
|
documents: normalizedDocuments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ describe("buildSidebarInitialData", () => {
|
|||||||
|
|
||||||
expect(payload.activeWorkspaceId).toBe("ws_1");
|
expect(payload.activeWorkspaceId).toBe("ws_1");
|
||||||
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
|
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
|
||||||
|
expect(payload.kernelFileTreeProjection?.projection).toBe("file_tree");
|
||||||
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
|
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
|
||||||
expect(payload.mindmapDocs).toEqual(["doc_1"]);
|
expect(payload.mindmapDocs).toEqual(["doc_1"]);
|
||||||
expect(payload.mindmapAssetChildren).toEqual({
|
expect(payload.mindmapAssetChildren).toEqual({
|
||||||
@@ -251,6 +252,78 @@ describe("buildSidebarInitialData", () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
},
|
},
|
||||||
|
kernel_file_tree_projection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rowId: "doc:doc_1",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "doc_1",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "页面 1",
|
||||||
|
depth: 0,
|
||||||
|
position: 1,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: [
|
||||||
|
"expand",
|
||||||
|
"open",
|
||||||
|
"drag",
|
||||||
|
"drop",
|
||||||
|
"select",
|
||||||
|
"create-child",
|
||||||
|
"rename",
|
||||||
|
"archive",
|
||||||
|
"restore",
|
||||||
|
"context-menu",
|
||||||
|
"reorder",
|
||||||
|
],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:doc_1",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:doc_1",
|
||||||
|
parentNodeId: "doc_1",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select", "context-menu"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "edge:doc_1:index:doc_1:parent_of",
|
||||||
|
edgeType: "parent_of",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
fromNodeId: "doc_1",
|
||||||
|
toNodeId: "index:doc_1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
trashed_documents: [],
|
trashed_documents: [],
|
||||||
media_assets: [],
|
media_assets: [],
|
||||||
trashed_media_assets: [],
|
trashed_media_assets: [],
|
||||||
@@ -349,6 +422,78 @@ describe("buildSidebarInitialData", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rowId: "doc:doc_1",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "doc_1",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "页面 1",
|
||||||
|
depth: 0,
|
||||||
|
position: 1,
|
||||||
|
childCount: 1,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: [
|
||||||
|
"expand",
|
||||||
|
"open",
|
||||||
|
"drag",
|
||||||
|
"drop",
|
||||||
|
"select",
|
||||||
|
"create-child",
|
||||||
|
"rename",
|
||||||
|
"archive",
|
||||||
|
"restore",
|
||||||
|
"context-menu",
|
||||||
|
"reorder",
|
||||||
|
],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:doc_1",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:doc_1",
|
||||||
|
parentNodeId: "doc_1",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select", "context-menu"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "edge:doc_1:index:doc_1:parent_of",
|
||||||
|
edgeType: "parent_of",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
fromNodeId: "doc_1",
|
||||||
|
toNodeId: "index:doc_1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
trashedDocuments: [],
|
trashedDocuments: [],
|
||||||
trashedMediaAssets: [],
|
trashedMediaAssets: [],
|
||||||
trashedMindmapAssets: [],
|
trashedMindmapAssets: [],
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||||
import type { DocumentRecord } from "@/lib/documents";
|
import type { DocumentRecord } from "@/lib/documents";
|
||||||
|
import {
|
||||||
|
buildKernelFileTreeProjection,
|
||||||
|
isKernelFileTreeProjection,
|
||||||
|
type KernelFileTreeProjection,
|
||||||
|
} from "@/lib/kernel-file-tree";
|
||||||
import {
|
import {
|
||||||
buildKernelSidebarProjection as buildProjectionContract,
|
buildKernelSidebarProjection as buildProjectionContract,
|
||||||
buildSidebarTreeFromKernelProjection,
|
buildSidebarTreeFromKernelProjection,
|
||||||
@@ -51,6 +56,8 @@ export type SidebarDatasetListQueryResult = {
|
|||||||
active_workspace_id: string;
|
active_workspace_id: string;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
|
kernel_file_tree_projection?: KernelFileTreeProjection;
|
||||||
|
kernelFileTreeProjection?: KernelFileTreeProjection;
|
||||||
kernel_sidebar_projection?: KernelSidebarProjection;
|
kernel_sidebar_projection?: KernelSidebarProjection;
|
||||||
kernelSidebarProjection?: KernelSidebarProjection;
|
kernelSidebarProjection?: KernelSidebarProjection;
|
||||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||||
@@ -72,6 +79,14 @@ const EMPTY_KERNEL_SIDEBAR_PROJECTION: KernelSidebarProjection = {
|
|||||||
edges: [],
|
edges: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_KERNEL_FILE_TREE_PROJECTION: KernelFileTreeProjection = {
|
||||||
|
projectionId: "kernel_projection:file_tree:missing",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
|
||||||
function isKernelSidebarProjection(value: unknown): value is KernelSidebarProjection {
|
function isKernelSidebarProjection(value: unknown): value is KernelSidebarProjection {
|
||||||
return (
|
return (
|
||||||
Boolean(value) &&
|
Boolean(value) &&
|
||||||
@@ -105,6 +120,34 @@ function readKernelSidebarProjection(
|
|||||||
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
|
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readKernelFileTreeProjection(
|
||||||
|
result: SidebarDatasetListQueryResult,
|
||||||
|
): KernelFileTreeProjection {
|
||||||
|
const snakeCaseProjection =
|
||||||
|
"kernel_file_tree_projection" in result ? result.kernel_file_tree_projection : undefined;
|
||||||
|
if (isKernelFileTreeProjection(snakeCaseProjection)) {
|
||||||
|
return snakeCaseProjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camelCaseProjection =
|
||||||
|
"kernelFileTreeProjection" in result ? result.kernelFileTreeProjection : undefined;
|
||||||
|
if (isKernelFileTreeProjection(camelCaseProjection)) {
|
||||||
|
return camelCaseProjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(result.documents)) {
|
||||||
|
return buildKernelFileTreeProjection({
|
||||||
|
documents: result.documents,
|
||||||
|
mediaAssets: result.media_assets,
|
||||||
|
mindmapAssets: result.mindmap_assets,
|
||||||
|
tableAssets: result.table_assets,
|
||||||
|
mindmapAssetChildren: result.mindmap_asset_children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return EMPTY_KERNEL_FILE_TREE_PROJECTION;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStringArray(values: Iterable<string>): string[] {
|
function normalizeStringArray(values: Iterable<string>): string[] {
|
||||||
return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
|
return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
|
||||||
}
|
}
|
||||||
@@ -262,11 +305,19 @@ export function buildSidebarDatasetListQueryResult(
|
|||||||
): SidebarDatasetListQueryResult {
|
): SidebarDatasetListQueryResult {
|
||||||
const derived = deriveSidebarDataset(input);
|
const derived = deriveSidebarDataset(input);
|
||||||
const kernelSidebarProjection = buildProjectionContract(input.documents);
|
const kernelSidebarProjection = buildProjectionContract(input.documents);
|
||||||
|
const kernelFileTreeProjection = buildKernelFileTreeProjection({
|
||||||
|
documents: input.documents,
|
||||||
|
mediaAssets: input.mediaAssets,
|
||||||
|
mindmapAssets: derived.mindmapAssets,
|
||||||
|
tableAssets: derived.tableAssets,
|
||||||
|
mindmapAssetChildren: derived.mindmapAssetChildren,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active_workspace_id: input.activeWorkspaceId,
|
active_workspace_id: input.activeWorkspaceId,
|
||||||
workspaces: [...input.workspaces],
|
workspaces: [...input.workspaces],
|
||||||
documents: [...input.documents],
|
documents: [...input.documents],
|
||||||
|
kernel_file_tree_projection: kernelFileTreeProjection,
|
||||||
kernel_sidebar_projection: kernelSidebarProjection,
|
kernel_sidebar_projection: kernelSidebarProjection,
|
||||||
trashed_documents: [...input.trashedDocuments],
|
trashed_documents: [...input.trashedDocuments],
|
||||||
media_assets: [...(input.mediaAssets ?? [])],
|
media_assets: [...(input.mediaAssets ?? [])],
|
||||||
@@ -284,6 +335,7 @@ export function mapSidebarDatasetListQueryResultToInitialData(
|
|||||||
result: SidebarDatasetListQueryResult,
|
result: SidebarDatasetListQueryResult,
|
||||||
): SidebarInitialData {
|
): SidebarInitialData {
|
||||||
const kernelSidebarProjection = readKernelSidebarProjection(result);
|
const kernelSidebarProjection = readKernelSidebarProjection(result);
|
||||||
|
const kernelFileTreeProjection = readKernelFileTreeProjection(result);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeWorkspaceId: result.active_workspace_id,
|
activeWorkspaceId: result.active_workspace_id,
|
||||||
@@ -294,6 +346,7 @@ export function mapSidebarDatasetListQueryResultToInitialData(
|
|||||||
records: result.documents,
|
records: result.documents,
|
||||||
projection: kernelSidebarProjection,
|
projection: kernelSidebarProjection,
|
||||||
}),
|
}),
|
||||||
|
kernelFileTreeProjection,
|
||||||
trashedDocuments: [...result.trashed_documents],
|
trashedDocuments: [...result.trashed_documents],
|
||||||
trashedMediaAssets: [...result.trashed_media_assets],
|
trashedMediaAssets: [...result.trashed_media_assets],
|
||||||
trashedMindmapAssets: [...result.trashed_mindmap_assets],
|
trashedMindmapAssets: [...result.trashed_mindmap_assets],
|
||||||
|
|||||||
@@ -31,6 +31,58 @@ const baseSidebarData: SidebarInitialData = {
|
|||||||
updated_at: null,
|
updated_at: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rowId: "doc:root",
|
||||||
|
rowKind: "document",
|
||||||
|
nodeId: "root",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "Root",
|
||||||
|
depth: 0,
|
||||||
|
position: 0,
|
||||||
|
childCount: 2,
|
||||||
|
expandable: true,
|
||||||
|
expandedByDefault: true,
|
||||||
|
capabilities: ["expand", "open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "document",
|
||||||
|
documentId: "root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
iconHint: "page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:root",
|
||||||
|
rowKind: "index",
|
||||||
|
nodeId: "index:root",
|
||||||
|
parentNodeId: "root",
|
||||||
|
nodeType: "index",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
title: "index.md",
|
||||||
|
depth: 1,
|
||||||
|
position: 0,
|
||||||
|
childCount: 0,
|
||||||
|
expandable: false,
|
||||||
|
expandedByDefault: false,
|
||||||
|
capabilities: ["open", "select"],
|
||||||
|
resourceMeta: {
|
||||||
|
resourceKind: "index",
|
||||||
|
documentId: "root",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
iconHint: "index",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
kernelSidebarProjection: {
|
kernelSidebarProjection: {
|
||||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||||
projection: "sidebar_tree",
|
projection: "sidebar_tree",
|
||||||
@@ -172,6 +224,13 @@ describe("tree-stream/tree-delta", () => {
|
|||||||
items: [],
|
items: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
},
|
},
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
kernelSidebarTree: [],
|
kernelSidebarTree: [],
|
||||||
trashedDocuments: [],
|
trashedDocuments: [],
|
||||||
trashedMediaAssets: [],
|
trashedMediaAssets: [],
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
|
|||||||
...data,
|
...data,
|
||||||
workspaces: [...data.workspaces],
|
workspaces: [...data.workspaces],
|
||||||
documents: [...data.documents],
|
documents: [...data.documents],
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
...data.kernelFileTreeProjection,
|
||||||
|
items: [...data.kernelFileTreeProjection.items],
|
||||||
|
edges: [...data.kernelFileTreeProjection.edges],
|
||||||
|
},
|
||||||
kernelSidebarProjection: {
|
kernelSidebarProjection: {
|
||||||
...data.kernelSidebarProjection,
|
...data.kernelSidebarProjection,
|
||||||
items: [...data.kernelSidebarProjection.items],
|
items: [...data.kernelSidebarProjection.items],
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ describe("tree-stream/protocol", () => {
|
|||||||
active_workspace_id: "ws_1",
|
active_workspace_id: "ws_1",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
kernel_file_tree_projection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
kernel_sidebar_projection: {
|
kernel_sidebar_projection: {
|
||||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||||
projection: "sidebar_tree",
|
projection: "sidebar_tree",
|
||||||
@@ -101,6 +108,13 @@ describe("tree-stream/protocol", () => {
|
|||||||
active_workspace_id: "ws_1",
|
active_workspace_id: "ws_1",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
kernel_file_tree_projection: {
|
||||||
|
projectionId: "kernel_projection:file_tree:root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: null,
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
kernel_sidebar_projection: {
|
kernel_sidebar_projection: {
|
||||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||||
projection: "sidebar_tree",
|
projection: "sidebar_tree",
|
||||||
@@ -121,6 +135,9 @@ describe("tree-stream/protocol", () => {
|
|||||||
|
|
||||||
expect(snapshot).toMatchObject({
|
expect(snapshot).toMatchObject({
|
||||||
activeWorkspaceId: "ws_1",
|
activeWorkspaceId: "ws_1",
|
||||||
|
kernelFileTreeProjection: {
|
||||||
|
projection: "file_tree",
|
||||||
|
},
|
||||||
kernelSidebarProjection: {
|
kernelSidebarProjection: {
|
||||||
projection: "sidebar_tree",
|
projection: "sidebar_tree",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user