chore: 收口 review 执行清单与 runtime 验证
- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录 - 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目 - 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑 - 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
@@ -24,6 +24,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
treeStreamStatus: treeStream.status,
|
||||
treeStreamCursor: treeStream.cursor,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -815,6 +815,7 @@ export function DocumentAiAgentPanelRuntime({
|
||||
subtree: contextSubtree,
|
||||
outline: contextOutline,
|
||||
evidence: contextEvidence,
|
||||
pageSubtreeSource: pageAggregateSnapshot.pageSubtreeSource ?? "none",
|
||||
pageOptions: pageAggregateSnapshot.pageOptions,
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
export type PageAggregateAiSnapshot = {
|
||||
blocks: Json | null;
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
pageSubtreeSource?: "server" | "local" | "none";
|
||||
persistedMeta: {
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
|
||||
@@ -45,6 +45,9 @@ type SidebarProps = {
|
||||
activeTab: SidebarPanel | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
|
||||
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
|
||||
|
||||
const ColorInput = ({
|
||||
value,
|
||||
@@ -1631,41 +1634,43 @@ const AiPanel = ({
|
||||
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
pressed={expandUseSearx}
|
||||
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="h-4 w-4 mr-1" />
|
||||
联网
|
||||
</Toggle>
|
||||
{MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
pressed={expandUseSearx}
|
||||
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="h-4 w-4 mr-1" />
|
||||
联网
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={3}
|
||||
value={expandInstruction}
|
||||
onChange={(e) => setExpandInstruction(e.target.value)}
|
||||
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runExpandSelectedNode}
|
||||
>
|
||||
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
||||
</button>
|
||||
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
||||
<p className="text-xs text-gray-400">
|
||||
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={3}
|
||||
value={expandInstruction}
|
||||
onChange={(e) => setExpandInstruction(e.target.value)}
|
||||
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runExpandSelectedNode}
|
||||
>
|
||||
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
||||
</button>
|
||||
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
||||
<p className="text-xs text-gray-400">
|
||||
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Label className="text-xs text-gray-500">模式</Label>
|
||||
<NativeSelect
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("page-aggregate-client-state", () => {
|
||||
|
||||
const state = createPageAggregateClientState(page);
|
||||
|
||||
expect(state.documentId).toBe("doc_1");
|
||||
expect(state.serverPageTitle).toBe("页面标题");
|
||||
expect(state.persistedPageTitle).toBeNull();
|
||||
expect(state.draftPageTitle).toBeNull();
|
||||
@@ -221,7 +222,7 @@ describe("page-aggregate-client-state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
|
||||
it("本地标题或正文与服务端快照不一致时,应生成临时 pageSubtree 而不是复用旧快照", () => {
|
||||
const page = createPageAggregate();
|
||||
const initialState = createPageAggregateClientState(page);
|
||||
|
||||
@@ -239,7 +240,17 @@ describe("page-aggregate-client-state", () => {
|
||||
selectPageAggregateClientPageSubtree(localContentState, {
|
||||
liveSidebarTitle: "页面标题",
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
projectionId: expect.any(String),
|
||||
projection: "page_tree",
|
||||
rootNode: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
title: "页面标题",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const draftTitleState = pageAggregateClientStateReducer(initialState, {
|
||||
type: "set_draft_page_title",
|
||||
@@ -249,7 +260,16 @@ describe("page-aggregate-client-state", () => {
|
||||
selectPageAggregateClientPageSubtree(draftTitleState, {
|
||||
liveSidebarTitle: "页面标题",
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
projection: "page_tree",
|
||||
rootNode: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
title: "草稿标题",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
|
||||
type: "commit_persisted_page_title",
|
||||
@@ -299,6 +319,7 @@ describe("page-aggregate-client-state", () => {
|
||||
expect(snapshot).toEqual({
|
||||
blocks: page.body.content as Json,
|
||||
pageSubtree: page.tree.pageSubtree,
|
||||
pageSubtreeSource: "server",
|
||||
persistedMeta: {
|
||||
workspaceId: "ws_1",
|
||||
revision: 3,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
|
||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { buildPageSubtreeProjection, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type PageAggregateClientPageSubtreeSource = "server" | "local" | "none";
|
||||
|
||||
export type PageAggregateClientState = {
|
||||
documentId: string;
|
||||
serverPageTitle: string;
|
||||
persistedPageTitle: string | null;
|
||||
draftPageTitle: string | null;
|
||||
@@ -71,6 +74,7 @@ export function createPageAggregateClientState(
|
||||
page: PageAggregateProjection,
|
||||
): PageAggregateClientState {
|
||||
return {
|
||||
documentId: page.identity.documentId,
|
||||
serverPageTitle: normalizePageTitle(page.head.title),
|
||||
persistedPageTitle: null,
|
||||
draftPageTitle: null,
|
||||
@@ -160,13 +164,43 @@ export function selectPageAggregateClientPageSubtree(
|
||||
liveSidebarTitle: string | null;
|
||||
},
|
||||
): PageSubtreeProjection | null {
|
||||
return selectPageAggregateClientPageSubtreeState(state, input).pageSubtree;
|
||||
}
|
||||
|
||||
export function selectPageAggregateClientPageSubtreeState(
|
||||
state: PageAggregateClientState,
|
||||
input: {
|
||||
liveSidebarTitle: string | null;
|
||||
},
|
||||
): {
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
source: PageAggregateClientPageSubtreeSource;
|
||||
} {
|
||||
const titleState = selectPageAggregateClientTitleState(state, input);
|
||||
const contentUnchanged = state.content === state.serverContentSnapshot;
|
||||
|
||||
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
|
||||
return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
|
||||
return {
|
||||
pageSubtree: withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle),
|
||||
source: "server",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
if (state.content != null) {
|
||||
return {
|
||||
pageSubtree: buildPageSubtreeProjection({
|
||||
documentId: state.documentId,
|
||||
title: titleState.displayTitle,
|
||||
content: state.content,
|
||||
}),
|
||||
source: "local",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pageSubtree: null,
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
export function selectPageAggregateClientAiSnapshot(
|
||||
@@ -178,6 +212,7 @@ export function selectPageAggregateClientAiSnapshot(
|
||||
): {
|
||||
blocks: Json | null;
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
pageSubtreeSource: PageAggregateClientPageSubtreeSource;
|
||||
persistedMeta: {
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
@@ -186,12 +221,14 @@ export function selectPageAggregateClientAiSnapshot(
|
||||
pageOptions: PageOptionsState;
|
||||
} {
|
||||
const blocks = state.content as Json | null;
|
||||
const pageSubtreeState = selectPageAggregateClientPageSubtreeState(state, {
|
||||
liveSidebarTitle: input.liveSidebarTitle,
|
||||
});
|
||||
|
||||
return {
|
||||
blocks,
|
||||
pageSubtree: selectPageAggregateClientPageSubtree(state, {
|
||||
liveSidebarTitle: input.liveSidebarTitle,
|
||||
}),
|
||||
pageSubtree: pageSubtreeState.pageSubtree,
|
||||
pageSubtreeSource: pageSubtreeState.source,
|
||||
persistedMeta: {
|
||||
workspaceId: input.workspaceId,
|
||||
revision: state.contentRevision,
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
|
||||
const TREE_SHELL_DOM_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-dom-host.tsx");
|
||||
|
||||
describe("sidebar file tree delete preflight source", () => {
|
||||
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => {
|
||||
@@ -28,4 +29,58 @@ describe("sidebar file tree delete preflight source", () => {
|
||||
expect(source).toContain('if (viewMode === "filesystem") {');
|
||||
expect(source).toContain("await handleDeleteResourceSelection();");
|
||||
});
|
||||
|
||||
it("页面新建/删除成功后应本地更新 Sidebar,不再同步等待整树刷新", () => {
|
||||
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||
const createStart = source.indexOf("const handleCreate = useCallback(");
|
||||
const createEnd = source.indexOf("const handleRename = useCallback(");
|
||||
const deleteSelectionStart = source.indexOf("const handleDeleteResourceSelection = useCallback(");
|
||||
const deleteSelectionEnd = source.indexOf("const handleFileTreeShellDeleteSelection = useCallback(");
|
||||
const deleteStart = source.indexOf("const handleDelete = useCallback(");
|
||||
const deleteEnd = source.indexOf("const handleDeleteFromContextMenuNode = useCallback(");
|
||||
|
||||
expect(createStart).toBeGreaterThanOrEqual(0);
|
||||
expect(createEnd).toBeGreaterThan(createStart);
|
||||
expect(deleteSelectionStart).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteSelectionEnd).toBeGreaterThan(deleteSelectionStart);
|
||||
expect(deleteStart).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteEnd).toBeGreaterThan(deleteStart);
|
||||
|
||||
const createBody = source.slice(createStart, createEnd);
|
||||
const deleteSelectionBody = source.slice(deleteSelectionStart, deleteSelectionEnd);
|
||||
const deleteBody = source.slice(deleteStart, deleteEnd);
|
||||
|
||||
expect(createBody).not.toContain("await refreshTree();");
|
||||
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
|
||||
expect(deleteSelectionBody).not.toContain("await refreshTree();");
|
||||
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
|
||||
expect(deleteBody).not.toContain("await refreshTree();");
|
||||
});
|
||||
|
||||
it("Rust-family tree shell mutation 成功后应本地 apply,不再默认整树刷新", () => {
|
||||
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||
const domHostSource = fs.readFileSync(TREE_SHELL_DOM_HOST_SOURCE, "utf8");
|
||||
const mutationStart = source.indexOf("const handleTreeShellMutation = useCallback(");
|
||||
const mutationEnd = source.indexOf("useEffect(() => {", mutationStart);
|
||||
|
||||
expect(mutationStart).toBeGreaterThanOrEqual(0);
|
||||
expect(mutationEnd).toBeGreaterThan(mutationStart);
|
||||
|
||||
const mutationBody = source.slice(mutationStart, mutationEnd);
|
||||
expect(mutationBody).toContain('payload.type === "tree.node.created"');
|
||||
expect(mutationBody).toContain('payload.type === "tree.node.renamed"');
|
||||
expect(mutationBody).toContain('payload.type === "tree.subtree.moved"');
|
||||
expect(mutationBody).toContain("insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode)");
|
||||
expect(mutationBody).toContain("renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null)");
|
||||
expect(mutationBody).toContain("moveLocalNode(prev, documentId, parentId, sortOrder)");
|
||||
expect(mutationBody).not.toMatch(
|
||||
/const handleTreeShellMutation = useCallback\(\(\) => \{\s*void refreshTree\(\);\s*\}, \[refreshTree\]\);/,
|
||||
);
|
||||
|
||||
expect(domHostSource).toContain("onTreeMutation?.({");
|
||||
expect(domHostSource).toContain('type: "tree.node.created"');
|
||||
expect(domHostSource).toContain("parentId: commandParentId");
|
||||
expect(domHostSource).toContain("title: commandTitle");
|
||||
expect(domHostSource).toContain("sortOrder: commandSortOrder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
|
||||
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
|
||||
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
import type { TreeShellMutationPayload } from "@/components/sidebar/tree-shell-host";
|
||||
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
buildPageTreeProjectionItems,
|
||||
@@ -203,6 +204,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
sidebarQueryData: sidebarQuery.data,
|
||||
treeStreamData: treeStream.data,
|
||||
treeStreamStatus: treeStream.status,
|
||||
treeStreamCursor: treeStream.cursor,
|
||||
});
|
||||
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
|
||||
|
||||
@@ -1188,9 +1190,91 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[assetById, handleOpenAssetInMain],
|
||||
);
|
||||
|
||||
const handleTreeShellMutation = useCallback(() => {
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
const cloned = cloneNodes(currentTree);
|
||||
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
|
||||
if (!removed) {
|
||||
return currentTree;
|
||||
}
|
||||
const next = insertNode(withoutTarget, parentId, index, removed);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const handleTreeShellMutation = useCallback((payload: TreeShellMutationPayload) => {
|
||||
const documentId = payload.documentId?.trim() || "";
|
||||
if (!documentId) {
|
||||
void refreshTree();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "tree.node.created") {
|
||||
const parentId = payload.parentId?.trim() || null;
|
||||
const title = payload.title?.trim() || "无标题";
|
||||
const createdAt = payload.execution?.created_at || new Date().toISOString();
|
||||
const updatedAt = payload.execution?.updated_at ?? payload.updatedAt ?? createdAt;
|
||||
const commandSortOrder =
|
||||
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
|
||||
? payload.sortOrder
|
||||
: null;
|
||||
const accessScope = payload.execution?.access_scope ?? "private";
|
||||
const nextNode: SidebarTreeNode = {
|
||||
id: documentId,
|
||||
workspace_id: payload.workspaceId?.trim() || sidebarData.activeWorkspaceId || "",
|
||||
title,
|
||||
parent_id: parentId,
|
||||
sort_order: commandSortOrder,
|
||||
access_scope: accessScope,
|
||||
is_starred: false,
|
||||
is_template: payload.execution?.is_template ?? false,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: commandSortOrder,
|
||||
childCount: 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
};
|
||||
setTree((prev) => {
|
||||
if (nodeById.has(documentId)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) next.add(parentId);
|
||||
next.add(documentId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "tree.node.renamed") {
|
||||
const title = payload.title?.trim();
|
||||
if (!title) {
|
||||
void refreshTree();
|
||||
return;
|
||||
}
|
||||
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "tree.subtree.moved") {
|
||||
const parentId = payload.parentId?.trim() || null;
|
||||
const sortOrder =
|
||||
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
|
||||
? payload.sortOrder
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
|
||||
if (parentId) {
|
||||
setExpanded((prev) => new Set(prev).add(parentId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshTree();
|
||||
}, [refreshTree]);
|
||||
}, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (event: KeyboardEvent) => {
|
||||
@@ -1619,6 +1703,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
|
||||
);
|
||||
|
||||
const removeDocumentsFromTree = useCallback((documentIds: string[]) => {
|
||||
const targetIds = Array.from(new Set(documentIds.map((item) => item.trim()).filter(Boolean)));
|
||||
if (targetIds.length === 0) return;
|
||||
setTree((prev) => {
|
||||
let next = prev;
|
||||
let changed = false;
|
||||
targetIds.forEach((documentId) => {
|
||||
const result = removeNode(next, documentId);
|
||||
if (result.removed) {
|
||||
next = result.tree;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
@@ -1734,6 +1835,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
|
||||
removeDocumentsFromTree(docIds);
|
||||
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
|
||||
if (activeId && docIds.includes(activeId)) {
|
||||
router.push("/");
|
||||
@@ -1744,7 +1846,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
await handleDeleteAssets(assetIds, selectedAssetHints);
|
||||
}
|
||||
|
||||
await refreshTree();
|
||||
setContextMenu(null);
|
||||
if (!isRustFamilyTreeRenderer) {
|
||||
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
@@ -1760,7 +1861,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
resourceShellRowById,
|
||||
resourceSelection,
|
||||
handleDeleteAssets,
|
||||
refreshTree,
|
||||
removeDocumentsFromTree,
|
||||
router,
|
||||
sidebarData.activeWorkspaceId,
|
||||
]);
|
||||
@@ -1844,7 +1945,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return next;
|
||||
});
|
||||
|
||||
await refreshTree();
|
||||
const query = new URLSearchParams();
|
||||
if (nextNode.workspace_id) {
|
||||
query.set("workspaceId", nextNode.workspace_id);
|
||||
@@ -1861,7 +1961,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
},
|
||||
[refreshTree, router],
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -1885,16 +1985,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[refreshTree, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
|
||||
const cloned = cloneNodes(currentTree);
|
||||
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
|
||||
if (!removed) {
|
||||
return currentTree;
|
||||
}
|
||||
const next = insertNode(withoutTarget, parentId, index, removed);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (documentId: string, parentId: string | null, index: number) => {
|
||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
|
||||
@@ -1948,8 +2038,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadTargetPlan.targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId));
|
||||
const targetMindmapId = uploadTargetPlan.targetMindmapId;
|
||||
if (targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
@@ -2151,13 +2242,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
documentId,
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
});
|
||||
await refreshTree();
|
||||
removeDocumentsFromTree([documentId]);
|
||||
emitDocumentsChanged(documentId);
|
||||
if (activeId === documentId) {
|
||||
router.push("/");
|
||||
}
|
||||
},
|
||||
[activeId, refreshTree, router, sidebarData.activeWorkspaceId],
|
||||
[activeId, removeDocumentsFromTree, router, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const handleDeleteFromContextMenuNode = useCallback(
|
||||
@@ -3489,6 +3580,32 @@ const removeNode = (
|
||||
return { removed, tree: nextTree };
|
||||
};
|
||||
|
||||
const renameDocumentInTree = (
|
||||
nodes: SidebarTreeNode[],
|
||||
documentId: string,
|
||||
title: string,
|
||||
updatedAt: string | null,
|
||||
): SidebarTreeNode[] => {
|
||||
let changed = false;
|
||||
const next = nodes.map((node) => {
|
||||
if (node.id === documentId) {
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
title,
|
||||
updated_at: updatedAt ?? node.updated_at,
|
||||
};
|
||||
}
|
||||
const children = renameDocumentInTree(node.children, documentId, title, updatedAt);
|
||||
if (children !== node.children) {
|
||||
changed = true;
|
||||
return { ...node, children };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
return changed ? next : nodes;
|
||||
};
|
||||
|
||||
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
|
||||
if (!parentId) {
|
||||
const next = [...nodes];
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
FileTreeShellDeleteSelectionPayload,
|
||||
FileTreeShellExternalDropPayload,
|
||||
FileTreeShellInternalDropPayload,
|
||||
TreeShellMutationPayload,
|
||||
TreeShellHostMode,
|
||||
TreeShellPickerCommand,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
@@ -100,7 +101,7 @@ type TreeShellRustDomShellHostProps = {
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -120,6 +121,19 @@ type TreeShellRuntimeWasmModule = {
|
||||
|
||||
type TreeShellRuntimeReducer = (request: unknown) => Promise<unknown> | unknown;
|
||||
|
||||
type TreeCommandResponsePayload = {
|
||||
id?: string;
|
||||
result?: {
|
||||
documentId?: string;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
workspaceId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: TreeShellMutationPayload["execution"];
|
||||
} | null;
|
||||
};
|
||||
|
||||
type TreeShellRuntimeGlobal = typeof globalThis & {
|
||||
__MNOTE_TREE_SHELL_RUNTIME__?: {
|
||||
reduceTreeShellRuntime?: TreeShellRuntimeReducer;
|
||||
@@ -179,6 +193,20 @@ function normalizeShellRowKind(value: unknown, defaultValue = "") {
|
||||
return rowKind;
|
||||
}
|
||||
|
||||
function readTreeCommandResult(payload: unknown): NonNullable<TreeCommandResponsePayload["result"]> {
|
||||
if (!isRecord(payload) || !isRecord(payload.result)) {
|
||||
return {};
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function readTreeCommandDocumentId(payload: unknown, result: NonNullable<TreeCommandResponsePayload["result"]>) {
|
||||
if (result.documentId) {
|
||||
return normalizeString(result.documentId);
|
||||
}
|
||||
return isRecord(payload) ? normalizeString(payload.id) : "";
|
||||
}
|
||||
|
||||
function itemHasVisibleChildren(item: TreeShellDomProjectionItem, childrenByParent: Map<string, TreeShellDomProjectionItem[]>) {
|
||||
return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0;
|
||||
}
|
||||
@@ -796,16 +824,40 @@ export function TreeShellRustDomShellHost({
|
||||
if (event.kind === "createNode") {
|
||||
const parentId = normalizeString(event.parentNodeId) || null;
|
||||
const result = await runTreeCommand({ action: "create", parentId });
|
||||
const documentId = normalizeString((result as { result?: { documentId?: string }; id?: string })?.result?.documentId, normalizeString((result as { id?: string })?.id));
|
||||
onTreeMutation?.({ type: "tree.node.created", documentId: documentId || null });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
const documentId = readTreeCommandDocumentId(result, commandResult);
|
||||
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
|
||||
const commandTitle = normalizeString(commandResult.title, "无标题");
|
||||
const commandSortOrder =
|
||||
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
|
||||
? commandResult.sortOrder
|
||||
: null;
|
||||
onTreeMutation?.({
|
||||
type: "tree.node.created",
|
||||
documentId: documentId || null,
|
||||
parentId: commandParentId,
|
||||
title: commandTitle,
|
||||
sortOrder: commandSortOrder,
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.kind === "renameNode") {
|
||||
const documentId = normalizeString(event.nodeId);
|
||||
const title = normalizeString(event.title);
|
||||
if (!documentId || !title) continue;
|
||||
await runTreeCommand({ action: "rename", documentId, title });
|
||||
onTreeMutation?.({ type: "tree.node.renamed", documentId });
|
||||
const result = await runTreeCommand({ action: "rename", documentId, title });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
onTreeMutation?.({
|
||||
type: "tree.node.renamed",
|
||||
documentId,
|
||||
title: normalizeString(commandResult.title, title),
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.kind === "moveSubtree") {
|
||||
@@ -813,12 +865,26 @@ export function TreeShellRustDomShellHost({
|
||||
if (!documentId) continue;
|
||||
const parentId = normalizeString(event.targetParentId) || null;
|
||||
const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0;
|
||||
await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
|
||||
onTreeMutation?.({ type: "tree.subtree.moved", documentId });
|
||||
const result = await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
|
||||
const commandSortOrder =
|
||||
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
|
||||
? commandResult.sortOrder
|
||||
: sortOrder;
|
||||
onTreeMutation?.({
|
||||
type: "tree.subtree.moved",
|
||||
documentId,
|
||||
parentId: commandParentId,
|
||||
sortOrder: commandSortOrder,
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[onTreeMutation, runTreeCommand],
|
||||
[onTreeMutation, runTreeCommand, workspaceId],
|
||||
);
|
||||
|
||||
const handlePageOpen = useCallback(
|
||||
@@ -837,7 +903,7 @@ export function TreeShellRustDomShellHost({
|
||||
onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) });
|
||||
}
|
||||
},
|
||||
[onPageExpandChange, pageState.expandedIds, reducePageAction],
|
||||
[onPageExpandChange, pageState, reducePageAction],
|
||||
);
|
||||
|
||||
const handlePageKeyDown = useCallback(
|
||||
|
||||
@@ -46,6 +46,22 @@ export type FileTreeShellDeleteSelectionPayload = {
|
||||
focusedRowId: string | null;
|
||||
};
|
||||
|
||||
export type TreeShellMutationPayload = {
|
||||
type: string;
|
||||
documentId: string | null;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
workspaceId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: ({
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} & Record<string, unknown>) | null;
|
||||
};
|
||||
|
||||
type TreeShellHostProps = {
|
||||
mode: TreeShellHostMode;
|
||||
surfaceTestId: string;
|
||||
@@ -89,7 +105,7 @@ type TreeShellHostProps = {
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type {
|
||||
FileTreeShellDeleteSelectionPayload,
|
||||
FileTreeShellExternalDropPayload,
|
||||
FileTreeShellInternalDropPayload,
|
||||
TreeShellHostMode,
|
||||
TreeShellMutationPayload,
|
||||
TreeShellPickerCommand,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
import {
|
||||
@@ -33,10 +35,7 @@ type TreeShellBridgePageExpandPayload = {
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
type TreeShellBridgeMutationPayload = {
|
||||
type: string;
|
||||
documentId: string | null;
|
||||
};
|
||||
type TreeShellBridgeMutationPayload = TreeShellMutationPayload;
|
||||
|
||||
type TreeShellBridgeSelectionPayload = {
|
||||
selectedRowIds: string[];
|
||||
@@ -137,6 +136,7 @@ export type TreeShellIframeHostProps = {
|
||||
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
|
||||
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
@@ -160,6 +160,12 @@ type TreeShellBridgeMessage = {
|
||||
copy: boolean;
|
||||
files: File[];
|
||||
itemKey: string | null;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
workspaceId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: TreeShellMutationPayload["execution"];
|
||||
};
|
||||
|
||||
const INLINE_TREE_SHELL_LOADING_HTML = [
|
||||
@@ -1269,15 +1275,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
sortOrder = position === "after" ? targetIndex + 1 : targetIndex;
|
||||
}
|
||||
try {
|
||||
await runTreeCommand({
|
||||
const result = await runTreeCommand({
|
||||
action: "move",
|
||||
documentId: sourceNodeId,
|
||||
parentId,
|
||||
sortOrder,
|
||||
});
|
||||
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
|
||||
setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动");
|
||||
postToHost("tree.subtree.moved", {
|
||||
documentId: sourceNodeId,
|
||||
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
|
||||
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : sortOrder,
|
||||
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
|
||||
updatedAt: normalizeText(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
|
||||
target: { documentId: sourceNodeId },
|
||||
});
|
||||
return true;
|
||||
@@ -1301,8 +1313,15 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
normalizeText(result && result.id),
|
||||
);
|
||||
setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面");
|
||||
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
|
||||
postToHost("tree.node.created", {
|
||||
documentId: documentId || null,
|
||||
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
|
||||
title: normalizeText(commandResult.title, "无标题"),
|
||||
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : null,
|
||||
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
|
||||
updatedAt: normalizeText(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
|
||||
target: { documentId: documentId || null },
|
||||
});
|
||||
return true;
|
||||
@@ -1322,16 +1341,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
);
|
||||
if (!nodeId || !title) return false;
|
||||
try {
|
||||
await runTreeCommand({
|
||||
const result = await runTreeCommand({
|
||||
action: "rename",
|
||||
documentId: nodeId,
|
||||
title,
|
||||
});
|
||||
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
|
||||
const item = itemById.get(nodeId);
|
||||
if (item) item.title = title;
|
||||
setLastAction(\`已重命名为 \${title}\`);
|
||||
postToHost("tree.node.renamed", {
|
||||
documentId: nodeId,
|
||||
title: normalizeText(commandResult.title, title),
|
||||
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
|
||||
updatedAt: normalizeText(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
|
||||
target: { documentId: nodeId },
|
||||
});
|
||||
if (fallbackCommandEvent?.titleElement) {
|
||||
@@ -4424,8 +4448,11 @@ export function TreeShellIframeHost({
|
||||
mode,
|
||||
workspaceId,
|
||||
rootNodeId,
|
||||
activePickerItemKey,
|
||||
focusedDocumentId,
|
||||
inlineBootstrapActiveDocumentId,
|
||||
inlineInitialTreeHtml,
|
||||
inlineProjectionItems,
|
||||
allowRootPick,
|
||||
excludeIds,
|
||||
resolvedChannel,
|
||||
@@ -4590,6 +4617,12 @@ export function TreeShellIframeHost({
|
||||
onTreeMutation?.({
|
||||
type: message.type,
|
||||
documentId: message.documentId,
|
||||
...(message.parentId !== undefined ? { parentId: message.parentId } : {}),
|
||||
...(message.title !== undefined ? { title: message.title } : {}),
|
||||
...(message.sortOrder !== undefined ? { sortOrder: message.sortOrder } : {}),
|
||||
...(message.workspaceId !== undefined ? { workspaceId: message.workspaceId } : {}),
|
||||
...(message.updatedAt !== undefined ? { updatedAt: message.updatedAt } : {}),
|
||||
...(message.execution !== undefined ? { execution: message.execution } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type FileTreeShellDeleteSelectionPayload,
|
||||
type FileTreeShellExternalDropPayload,
|
||||
type FileTreeShellInternalDropPayload,
|
||||
type TreeShellMutationPayload,
|
||||
type TreeShellPickerCommand,
|
||||
type TreeRendererFamily,
|
||||
} from "@/components/sidebar/tree-shell-host";
|
||||
@@ -36,7 +37,7 @@ type SidebarPageTreeSurfaceProps = {
|
||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
|
||||
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
};
|
||||
|
||||
type SidebarFileTreeSurfaceProps = {
|
||||
@@ -74,7 +75,7 @@ type SidebarFileTreeSurfaceProps = {
|
||||
}) => void;
|
||||
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
|
||||
};
|
||||
|
||||
export type SidebarTreeSurfaceProps =
|
||||
|
||||
@@ -40,6 +40,7 @@ function Harness(props: {
|
||||
sidebarQueryData: SidebarInitialData | null;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||
treeStreamCursor?: string | null;
|
||||
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
|
||||
}) {
|
||||
const state = usePreferredSidebarSnapshot(props);
|
||||
@@ -145,6 +146,80 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("query 版本比已更新过的 stream 更新时,应优先 query,避免旧 stream 回压", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const refreshedQuery = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 C",
|
||||
updated_at: "2026-04-21T00:00:02.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={refreshedQuery}
|
||||
treeStreamData={staleTreeStream}
|
||||
treeStreamStatus="live"
|
||||
treeStreamCursor="cursor_stream_1"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "query",
|
||||
cursor: null,
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 C" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("stream 与 query 版本相同时,应优先 stream 并暴露 cursor", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const queryData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const treeStreamData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={queryData}
|
||||
treeStreamData={treeStreamData}
|
||||
treeStreamStatus="live"
|
||||
treeStreamCursor="cursor_stream_2"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "tree_stream",
|
||||
cursor: "cursor_stream_2",
|
||||
data: expect.objectContaining({
|
||||
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
||||
import { buildSidebarDataSyncKey, getSidebarDataFreshness } from "@/components/sidebar/sidebar-sync";
|
||||
|
||||
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
|
||||
|
||||
@@ -9,6 +9,7 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
sidebarQueryData: SidebarInitialData | null;
|
||||
treeStreamData: SidebarInitialData | null;
|
||||
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
|
||||
treeStreamCursor?: string | null;
|
||||
}) {
|
||||
const querySyncKey = useMemo(
|
||||
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
|
||||
@@ -19,29 +20,40 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
|
||||
const initialVersion = useMemo(() => getSidebarDataFreshness(input.initialData), [input.initialData]);
|
||||
const queryVersion = useMemo(
|
||||
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : null),
|
||||
[input.sidebarQueryData],
|
||||
);
|
||||
const treeStreamVersion = useMemo(
|
||||
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : null),
|
||||
[input.treeStreamData],
|
||||
);
|
||||
const streamIsPreferred = input.treeStreamStatus !== "fallback";
|
||||
const queryHasFreshSnapshot =
|
||||
input.sidebarQueryData != null &&
|
||||
querySyncKey != null &&
|
||||
querySyncKey !== initialSyncKey &&
|
||||
treeStreamSyncKey === initialSyncKey;
|
||||
|
||||
const preferredVersion = Math.max(
|
||||
initialVersion,
|
||||
queryVersion ?? 0,
|
||||
streamIsPreferred ? treeStreamVersion ?? 0 : 0,
|
||||
);
|
||||
const queryHasPreferredVersion = queryVersion != null && queryVersion >= preferredVersion;
|
||||
const treeStreamHasPreferredVersion =
|
||||
streamIsPreferred &&
|
||||
input.treeStreamData != null &&
|
||||
treeStreamVersion != null &&
|
||||
treeStreamVersion >= preferredVersion;
|
||||
|
||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||
if (queryHasFreshSnapshot) {
|
||||
return "query";
|
||||
}
|
||||
if (input.treeStreamData && streamIsPreferred) {
|
||||
if (treeStreamHasPreferredVersion) {
|
||||
return "tree_stream";
|
||||
}
|
||||
if (input.sidebarQueryData) {
|
||||
if (queryHasPreferredVersion) {
|
||||
return "query";
|
||||
}
|
||||
return "initial";
|
||||
}, [
|
||||
input.sidebarQueryData,
|
||||
input.treeStreamData,
|
||||
queryHasFreshSnapshot,
|
||||
streamIsPreferred,
|
||||
queryHasPreferredVersion,
|
||||
treeStreamHasPreferredVersion,
|
||||
]);
|
||||
|
||||
const data =
|
||||
@@ -56,13 +68,22 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
: source === "query"
|
||||
? querySyncKey ?? initialSyncKey
|
||||
: initialSyncKey;
|
||||
const version =
|
||||
source === "tree_stream"
|
||||
? treeStreamVersion ?? initialVersion
|
||||
: source === "query"
|
||||
? queryVersion ?? initialVersion
|
||||
: initialVersion;
|
||||
const cursor = source === "tree_stream" ? input.treeStreamCursor ?? null : null;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data,
|
||||
source,
|
||||
syncKey,
|
||||
version,
|
||||
cursor,
|
||||
}),
|
||||
[data, source, syncKey],
|
||||
[cursor, data, source, syncKey, version],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user