fix(sidebar): 让文件树即时应用页面和资源变更
This commit is contained in:
@@ -51,9 +51,13 @@ describe("sidebar file tree delete preflight source", () => {
|
||||
const deleteBody = source.slice(deleteStart, deleteEnd);
|
||||
|
||||
expect(createBody).not.toContain("await refreshTree();");
|
||||
expect(createBody).toContain("upsertSidebarDocumentRecord(");
|
||||
expect(createBody).toContain("emitDocumentsChanged(nextNode.id);");
|
||||
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
|
||||
expect(deleteSelectionBody).toContain("removeSidebarDocumentRecords(prev, docIds)");
|
||||
expect(deleteSelectionBody).not.toContain("await refreshTree();");
|
||||
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
|
||||
expect(deleteBody).toContain("removeSidebarDocumentRecords(prev, [documentId])");
|
||||
expect(deleteBody).not.toContain("await refreshTree();");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import {
|
||||
buildSidebarLocalFileTreeProjection,
|
||||
moveSidebarDocumentRecord,
|
||||
removeSidebarDocumentRecords,
|
||||
sidebarTreeNodeToDocumentRecord,
|
||||
upsertSidebarDocumentRecord,
|
||||
} from "./sidebar-local-projection";
|
||||
|
||||
function documentRecord(input: Partial<DocumentRecord> & { id: string }): DocumentRecord {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: input.id,
|
||||
workspace_id: input.workspace_id ?? "ws_1",
|
||||
title: input.title ?? input.id,
|
||||
parent_id: input.parent_id ?? null,
|
||||
sort_order: input.sort_order ?? 0,
|
||||
is_starred: input.is_starred ?? false,
|
||||
is_template: input.is_template ?? false,
|
||||
created_at: input.created_at ?? "2026-05-14T00:00:00.000Z",
|
||||
updated_at: input.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mindmapAsset(input: Partial<MediaAsset> & { id: string; document_id: string }): MediaAsset {
|
||||
return {
|
||||
id: input.id,
|
||||
workspace_id: input.workspace_id ?? "ws_1",
|
||||
document_id: input.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: input.file_url ?? `/documents/${input.document_id}/mindmap-${input.id}.json`,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: input.file_name ?? `mindmap-${input.id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: input.created_at ?? "2026-05-14T00:00:00.000Z",
|
||||
updated_at: input.updated_at ?? "2026-05-14T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar-local-projection", () => {
|
||||
it("新建页面本地 upsert 后应能生成 File Tree document/index 行", () => {
|
||||
const baseProjection = buildKernelFileTreeProjection({
|
||||
documents: [documentRecord({ id: "root", sort_order: 0 })],
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
});
|
||||
const created = sidebarTreeNodeToDocumentRecord(
|
||||
{
|
||||
id: "created",
|
||||
workspace_id: "ws_1",
|
||||
title: "新页面",
|
||||
parent_id: "root",
|
||||
sort_order: 1,
|
||||
access_scope: "private",
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-05-14T00:01:00.000Z",
|
||||
updated_at: "2026-05-14T00:01:00.000Z",
|
||||
children: [],
|
||||
},
|
||||
"ws_1",
|
||||
);
|
||||
const documents = upsertSidebarDocumentRecord([documentRecord({ id: "root", sort_order: 0 })], created);
|
||||
const projection = buildSidebarLocalFileTreeProjection({
|
||||
baseProjection,
|
||||
documents,
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
});
|
||||
|
||||
expect(projection.items.map((item) => item.rowId)).toContain("doc:created");
|
||||
expect(projection.items.map((item) => item.rowId)).toContain("index:created");
|
||||
});
|
||||
|
||||
it("删除页面本地移除后应同时移除其子树 File Tree 行", () => {
|
||||
const root = documentRecord({ id: "root" });
|
||||
const child = documentRecord({ id: "child", parent_id: "root" });
|
||||
const grandchild = documentRecord({ id: "grandchild", parent_id: "child" });
|
||||
const documents = removeSidebarDocumentRecords([root, child, grandchild], ["child"]);
|
||||
const projection = buildSidebarLocalFileTreeProjection({
|
||||
baseProjection: buildKernelFileTreeProjection({
|
||||
documents: [root, child, grandchild],
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
}),
|
||||
documents,
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
});
|
||||
|
||||
const rowIds = projection.items.map((item) => item.rowId);
|
||||
expect(rowIds).toContain("doc:root");
|
||||
expect(rowIds).not.toContain("doc:child");
|
||||
expect(rowIds).not.toContain("index:grandchild");
|
||||
});
|
||||
|
||||
it("新建 mindmap asset 后应能生成 File Tree asset 行", () => {
|
||||
const root = documentRecord({ id: "root" });
|
||||
const projection = buildSidebarLocalFileTreeProjection({
|
||||
baseProjection: buildKernelFileTreeProjection({
|
||||
documents: [root],
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
}),
|
||||
documents: [root],
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [mindmapAsset({ id: "mind_1", document_id: "root" })],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
});
|
||||
|
||||
const assetRow = projection.items.find((item) => item.resourceMeta.assetId === "mind_1");
|
||||
expect(assetRow?.rowId).toBe("asset:mind_1");
|
||||
expect(assetRow?.resourceMeta.assetKind).toBe("mindmap");
|
||||
});
|
||||
|
||||
it("移动页面本地更新后 File Tree row 的父级应同步变化", () => {
|
||||
const root = documentRecord({ id: "root", sort_order: 0 });
|
||||
const target = documentRecord({ id: "target", parent_id: null, sort_order: 1 });
|
||||
const moved = moveSidebarDocumentRecord([root, target], "target", "root", 0);
|
||||
const projection = buildSidebarLocalFileTreeProjection({
|
||||
baseProjection: buildKernelFileTreeProjection({
|
||||
documents: [root, target],
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
}),
|
||||
documents: moved,
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
});
|
||||
|
||||
const targetRow = projection.items.find((item) => item.rowId === "doc:target");
|
||||
expect(targetRow?.parentNodeId).toBe("root");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelFileTreeProjection,
|
||||
type KernelFileTreeProjection,
|
||||
} from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export function buildDocumentListSyncKey(documents: readonly DocumentRecord[]): string {
|
||||
return JSON.stringify(
|
||||
documents.map((document) => ({
|
||||
id: document.id,
|
||||
workspaceId: document.workspace_id,
|
||||
title: document.title ?? null,
|
||||
parentId: document.parent_id ?? null,
|
||||
sortOrder: document.sort_order ?? null,
|
||||
accessScope: document.access_scope,
|
||||
isStarred: document.is_starred ?? null,
|
||||
isTemplate: document.is_template,
|
||||
createdAt: document.created_at,
|
||||
updatedAt: document.updated_at ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarTreeNodeToDocumentRecord(
|
||||
node: SidebarTreeNode,
|
||||
fallbackWorkspaceId: string,
|
||||
): DocumentRecord {
|
||||
return {
|
||||
access_scope: node.access_scope ?? "private",
|
||||
id: node.id,
|
||||
workspace_id: node.workspace_id || fallbackWorkspaceId,
|
||||
title: node.title ?? "无标题",
|
||||
parent_id: node.parent_id ?? null,
|
||||
sort_order: node.sort_order ?? null,
|
||||
is_starred: node.is_starred ?? false,
|
||||
is_template: node.is_template ?? false,
|
||||
created_at: node.created_at || "",
|
||||
updated_at: node.updated_at ?? node.created_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertSidebarDocumentRecord(
|
||||
documents: readonly DocumentRecord[],
|
||||
document: DocumentRecord,
|
||||
): DocumentRecord[] {
|
||||
let found = false;
|
||||
const next = documents.map((item) => {
|
||||
if (item.id !== document.id) {
|
||||
return item;
|
||||
}
|
||||
found = true;
|
||||
return document;
|
||||
});
|
||||
if (!found) {
|
||||
next.push(document);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function renameSidebarDocumentRecord(
|
||||
documents: readonly DocumentRecord[],
|
||||
documentId: string,
|
||||
title: string,
|
||||
updatedAt: string | null,
|
||||
): DocumentRecord[] {
|
||||
let changed = false;
|
||||
const next = documents.map((document) => {
|
||||
if (document.id !== documentId) {
|
||||
return document;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...document,
|
||||
title,
|
||||
updated_at: updatedAt ?? document.updated_at,
|
||||
};
|
||||
});
|
||||
return changed ? next : [...documents];
|
||||
}
|
||||
|
||||
export function moveSidebarDocumentRecord(
|
||||
documents: readonly DocumentRecord[],
|
||||
documentId: string,
|
||||
parentId: string | null,
|
||||
sortOrder: number | null,
|
||||
): DocumentRecord[] {
|
||||
let changed = false;
|
||||
const next = documents.map((document) => {
|
||||
if (document.id !== documentId) {
|
||||
return document;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...document,
|
||||
parent_id: parentId,
|
||||
sort_order: sortOrder,
|
||||
};
|
||||
});
|
||||
return changed ? next : [...documents];
|
||||
}
|
||||
|
||||
export function removeSidebarDocumentRecords(
|
||||
documents: readonly DocumentRecord[],
|
||||
documentIds: readonly string[],
|
||||
): DocumentRecord[] {
|
||||
const requestedIds = new Set(documentIds.map((id) => id.trim()).filter(Boolean));
|
||||
if (requestedIds.size === 0) {
|
||||
return [...documents];
|
||||
}
|
||||
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
documents.forEach((document) => {
|
||||
const parentId = document.parent_id?.trim();
|
||||
if (!parentId) {
|
||||
return;
|
||||
}
|
||||
const bucket = childrenByParentId.get(parentId) ?? [];
|
||||
bucket.push(document.id);
|
||||
childrenByParentId.set(parentId, bucket);
|
||||
});
|
||||
|
||||
const idsToRemove = new Set<string>();
|
||||
const visit = (documentId: string) => {
|
||||
if (idsToRemove.has(documentId)) {
|
||||
return;
|
||||
}
|
||||
idsToRemove.add(documentId);
|
||||
(childrenByParentId.get(documentId) ?? []).forEach(visit);
|
||||
};
|
||||
requestedIds.forEach(visit);
|
||||
|
||||
return documents.filter((document) => !idsToRemove.has(document.id));
|
||||
}
|
||||
|
||||
export function buildSidebarLocalFileTreeProjection(input: {
|
||||
baseProjection: KernelFileTreeProjection;
|
||||
documents: readonly DocumentRecord[];
|
||||
mediaAssets?: readonly MediaAsset[] | null;
|
||||
mindmapAssets?: readonly MediaAsset[] | null;
|
||||
tableAssets?: readonly MediaAsset[] | null;
|
||||
mindmapAssetChildren?: SidebarInitialData["mindmapAssetChildren"];
|
||||
}): KernelFileTreeProjection {
|
||||
return buildKernelFileTreeProjection({
|
||||
documents: [...input.documents],
|
||||
mediaAssets: [...(input.mediaAssets ?? [])],
|
||||
mindmapAssets: [...(input.mindmapAssets ?? [])],
|
||||
tableAssets: [...(input.tableAssets ?? [])],
|
||||
mindmapAssetChildren: input.mindmapAssetChildren ?? {},
|
||||
rootNodeId: input.baseProjection.rootNodeId,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSidebarTreeSyncKey, buildMediaAssetListSyncKey } from "./sidebar-sync";
|
||||
import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
import {
|
||||
buildSidebarDataSyncKey,
|
||||
buildSidebarTreeSyncKey,
|
||||
buildMediaAssetListSyncKey,
|
||||
getSidebarDataFreshness,
|
||||
} from "./sidebar-sync";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
@@ -51,6 +59,54 @@ function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "标题",
|
||||
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",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
|
||||
return {
|
||||
activeWorkspaceId: "ws-1",
|
||||
workspaces: [],
|
||||
documents,
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
kernelFileTreeProjection: buildKernelFileTreeProjection({
|
||||
documents: [],
|
||||
mediaAssets: [],
|
||||
mindmapAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
}),
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
trashedTableAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapDocs: [],
|
||||
mindmapAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
mediaAssets: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar-sync", () => {
|
||||
it("相同侧边栏树内容应产生相同 sync key", () => {
|
||||
const left = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
|
||||
@@ -79,4 +135,53 @@ describe("sidebar-sync", () => {
|
||||
|
||||
expect(buildMediaAssetListSyncKey(left)).not.toBe(buildMediaAssetListSyncKey(right));
|
||||
});
|
||||
|
||||
it("顶层 documents 变化应影响 sidebar data sync key", () => {
|
||||
const left = buildSidebarData([]);
|
||||
const right = buildSidebarData([buildDocument({ id: "doc-live", title: "显式刷新标题" })]);
|
||||
|
||||
expect(buildSidebarDataSyncKey(left)).not.toBe(buildSidebarDataSyncKey(right));
|
||||
});
|
||||
|
||||
it("file tree position 不应被当作 freshness 时间戳压住新 snapshot", () => {
|
||||
const stale = buildSidebarData([buildDocument({
|
||||
id: "old",
|
||||
created_at: "2026-05-13T00:00:00.000Z",
|
||||
updated_at: "2026-05-13T00:00:00.000Z",
|
||||
})]);
|
||||
stale.kernelFileTreeProjection = {
|
||||
...stale.kernelFileTreeProjection,
|
||||
items: [
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "doc:old",
|
||||
rowKind: "document",
|
||||
nodeId: "old",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
title: "旧页面",
|
||||
depth: 0,
|
||||
position: Number.MAX_SAFE_INTEGER,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "old",
|
||||
workspaceId: "ws-1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
};
|
||||
const fresh = buildSidebarData([buildDocument({
|
||||
id: "new",
|
||||
created_at: "2026-05-14T00:00:00.000Z",
|
||||
updated_at: "2026-05-14T00:00:00.000Z",
|
||||
})]);
|
||||
|
||||
expect(getSidebarDataFreshness(fresh)).toBeGreaterThan(getSidebarDataFreshness(stale));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,18 @@ export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
||||
const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||
return JSON.stringify({
|
||||
activeWorkspaceId: data.activeWorkspaceId,
|
||||
documents: data.documents.map((item) => ({
|
||||
id: item.id,
|
||||
workspaceId: item.workspace_id,
|
||||
title: item.title ?? null,
|
||||
parentId: item.parent_id ?? null,
|
||||
sortOrder: item.sort_order ?? null,
|
||||
accessScope: item.access_scope,
|
||||
isStarred: item.is_starred ?? null,
|
||||
isTemplate: item.is_template,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at ?? null,
|
||||
})),
|
||||
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
|
||||
fileTree: {
|
||||
projectionId: fileTreeProjection.projectionId,
|
||||
@@ -110,8 +122,6 @@ export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
|
||||
export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
||||
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 fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||
const fileTreeTimes = fileTreeProjection.items.map((item) => item.position ?? 0);
|
||||
const assetTimes = [
|
||||
...(data.mediaAssets ?? []),
|
||||
...(data.mindmapAssets ?? []),
|
||||
@@ -126,7 +136,6 @@ export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
||||
0,
|
||||
...documentTimes,
|
||||
...treeTimes,
|
||||
...fileTreeTimes,
|
||||
...assetTimes,
|
||||
...trashedDocumentTimes,
|
||||
);
|
||||
|
||||
@@ -47,6 +47,15 @@ import {
|
||||
buildMediaAssetListSyncKey,
|
||||
buildSidebarTreeSyncKey,
|
||||
} from "@/components/sidebar/sidebar-sync";
|
||||
import {
|
||||
buildDocumentListSyncKey,
|
||||
buildSidebarLocalFileTreeProjection,
|
||||
moveSidebarDocumentRecord,
|
||||
removeSidebarDocumentRecords,
|
||||
renameSidebarDocumentRecord,
|
||||
sidebarTreeNodeToDocumentRecord,
|
||||
upsertSidebarDocumentRecord,
|
||||
} from "@/components/sidebar/sidebar-local-projection";
|
||||
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
|
||||
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
|
||||
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
@@ -272,6 +281,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||
const [documents, setDocuments] = useState(() => sidebarData.documents ?? []);
|
||||
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
||||
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
||||
const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
|
||||
@@ -289,6 +299,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const [searchFileTreeProjectionKey, setSearchFileTreeProjectionKey] = useState<string | null>(null);
|
||||
const [sidebarHydrated, setSidebarHydrated] = useState(false);
|
||||
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
|
||||
const documentSyncKeyRef = useRef<string>(buildDocumentListSyncKey(sidebarData.documents ?? []));
|
||||
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
||||
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
||||
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
||||
@@ -315,6 +326,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
});
|
||||
}, [sidebarData.kernelSidebarTree]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextDocuments = sidebarData.documents ?? [];
|
||||
const nextSyncKey = buildDocumentListSyncKey(nextDocuments);
|
||||
if (documentSyncKeyRef.current === nextSyncKey) {
|
||||
return;
|
||||
}
|
||||
documentSyncKeyRef.current = nextSyncKey;
|
||||
setDocuments(nextDocuments);
|
||||
}, [sidebarData.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
||||
}, [activeId]);
|
||||
@@ -657,6 +678,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId
|
||||
? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}`
|
||||
: null;
|
||||
const localFileTreeProjection = useMemo(
|
||||
() =>
|
||||
buildSidebarLocalFileTreeProjection({
|
||||
baseProjection: sidebarData.kernelFileTreeProjection,
|
||||
documents,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
tableAssets,
|
||||
mindmapAssetChildren: sidebarData.mindmapAssetChildren,
|
||||
}),
|
||||
[
|
||||
documents,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
sidebarData.kernelFileTreeProjection,
|
||||
sidebarData.mindmapAssetChildren,
|
||||
tableAssets,
|
||||
],
|
||||
);
|
||||
const resourceTreeShellItems = useMemo(
|
||||
() =>
|
||||
expectedSearchFileTreeProjectionKey &&
|
||||
@@ -679,11 +719,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
() =>
|
||||
normalizedFileTreeSearchQuery.length > 0
|
||||
? (resourceTreeShellItems ?? [])
|
||||
: sidebarData.kernelFileTreeProjection.items,
|
||||
: localFileTreeProjection.items,
|
||||
[
|
||||
normalizedFileTreeSearchQuery,
|
||||
resourceTreeShellItems,
|
||||
sidebarData.kernelFileTreeProjection.items,
|
||||
localFileTreeProjection.items,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -749,32 +789,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const docParentById = useMemo(
|
||||
() =>
|
||||
buildParentById(
|
||||
(sidebarData.documents ?? []).map((doc) => ({
|
||||
documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
parentId: doc.parent_id ?? null,
|
||||
})),
|
||||
),
|
||||
[sidebarData.documents],
|
||||
[documents],
|
||||
);
|
||||
const documentWorkspaceById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(sidebarData.documents ?? []).map((doc) => [
|
||||
documents.map((doc) => [
|
||||
doc.id,
|
||||
typeof doc.workspace_id === "string" ? doc.workspace_id : null,
|
||||
]),
|
||||
),
|
||||
[sidebarData.documents],
|
||||
[documents],
|
||||
);
|
||||
|
||||
const childrenCountByParentId = useMemo(() => {
|
||||
const map = new Map<string | null, number>();
|
||||
(sidebarData.documents ?? []).forEach((doc) => {
|
||||
documents.forEach((doc) => {
|
||||
const parentId = doc.parent_id ?? null;
|
||||
map.set(parentId, (map.get(parentId) ?? 0) + 1);
|
||||
});
|
||||
return map;
|
||||
}, [sidebarData.documents]);
|
||||
}, [documents]);
|
||||
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
@@ -1241,6 +1281,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
if (nodeById.has(documentId)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setDocuments((prev) =>
|
||||
upsertSidebarDocumentRecord(
|
||||
prev,
|
||||
sidebarTreeNodeToDocumentRecord(nextNode, sidebarData.activeWorkspaceId || ""),
|
||||
),
|
||||
);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) next.add(parentId);
|
||||
@@ -1257,6 +1303,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
|
||||
setDocuments((prev) => renameSidebarDocumentRecord(prev, documentId, title, payload.updatedAt ?? null));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1267,6 +1314,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
? payload.sortOrder
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
|
||||
setDocuments((prev) => moveSidebarDocumentRecord(prev, documentId, parentId, sortOrder));
|
||||
if (parentId) {
|
||||
setExpanded((prev) => new Set(prev).add(parentId));
|
||||
}
|
||||
@@ -1460,6 +1508,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
resourceSelection.focusedRowId,
|
||||
resourceSelection.selectedRowIds,
|
||||
resourceShellVisibleRowIds,
|
||||
sidebarData.activeWorkspaceId,
|
||||
sidebarQuery,
|
||||
]);
|
||||
|
||||
@@ -1836,6 +1885,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
}
|
||||
|
||||
removeDocumentsFromTree(docIds);
|
||||
setDocuments((prev) => removeSidebarDocumentRecords(prev, docIds));
|
||||
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
|
||||
if (activeId && docIds.includes(activeId)) {
|
||||
router.push("/");
|
||||
@@ -1944,6 +1994,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setDocuments((prev) =>
|
||||
upsertSidebarDocumentRecord(
|
||||
prev,
|
||||
sidebarTreeNodeToDocumentRecord(nextNode, sidebarData.activeWorkspaceId || ""),
|
||||
),
|
||||
);
|
||||
emitDocumentsChanged(nextNode.id);
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (nextNode.workspace_id) {
|
||||
@@ -1961,7 +2018,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
},
|
||||
[router],
|
||||
[router, sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -1980,14 +2037,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
setTree((prev) => renameDocumentInTree(prev, documentId, title.trim(), null));
|
||||
setDocuments((prev) => renameSidebarDocumentRecord(prev, documentId, title.trim(), null));
|
||||
emitDocumentsChanged(documentId);
|
||||
},
|
||||
[refreshTree, sidebarData.activeWorkspaceId],
|
||||
[sidebarData.activeWorkspaceId],
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (documentId: string, parentId: string | null, index: number) => {
|
||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
|
||||
setDocuments((prev) => moveSidebarDocumentRecord(prev, documentId, parentId, index));
|
||||
if (parentId) {
|
||||
setExpanded((prev) => new Set(prev).add(parentId));
|
||||
}
|
||||
@@ -2002,7 +2062,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
await refreshTree();
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
emitDocumentsChanged(documentId);
|
||||
},
|
||||
[moveLocalNode, refreshTree, setExpanded],
|
||||
);
|
||||
@@ -2178,6 +2238,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setDocuments((prev) => {
|
||||
let next = prev;
|
||||
topLevelDocIds.forEach((id, offset) => {
|
||||
next = moveSidebarDocumentRecord(next, id, documentTransferPlan.targetParentId, baseIndex + offset);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId));
|
||||
|
||||
try {
|
||||
@@ -2243,6 +2310,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||
});
|
||||
removeDocumentsFromTree([documentId]);
|
||||
setDocuments((prev) => removeSidebarDocumentRecords(prev, [documentId]));
|
||||
emitDocumentsChanged(documentId);
|
||||
if (activeId === documentId) {
|
||||
router.push("/");
|
||||
|
||||
@@ -220,6 +220,62 @@ describe("usePreferredSidebarSnapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stream 与 query freshness 相同但内容不同时,应优先 query 避免资源行被旧 stream 压住", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const treeStreamData = buildSidebarData([
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
]);
|
||||
const queryData = buildSidebarInitialData({
|
||||
activeWorkspaceId: "ws-1",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
buildDocument({
|
||||
title: "标题 B",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
trashedDocuments: [],
|
||||
mindmaps: [
|
||||
{
|
||||
mindmap_id: "mind-1",
|
||||
document_id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
created_at: "2026-04-21T00:00:01.000Z",
|
||||
updated_at: "2026-04-21T00:00:01.000Z",
|
||||
},
|
||||
],
|
||||
mediaAssets: [],
|
||||
trashedMediaAssets: [],
|
||||
tables: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Harness
|
||||
initialData={initialData}
|
||||
sidebarQueryData={queryData}
|
||||
treeStreamData={treeStreamData}
|
||||
treeStreamStatus="live"
|
||||
treeStreamCursor="cursor_stream_3"
|
||||
onState={onState}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onState.mock.lastCall?.[0]).toMatchObject({
|
||||
source: "query",
|
||||
cursor: null,
|
||||
});
|
||||
expect(
|
||||
(onState.mock.lastCall?.[0] as ReturnType<typeof usePreferredSidebarSnapshot>).data.kernelFileTreeProjection.items.some(
|
||||
(item) => item.resourceMeta.assetId === "mind-1",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
|
||||
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
|
||||
|
||||
@@ -44,6 +44,12 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
treeStreamVersion >= preferredVersion;
|
||||
|
||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||
if (
|
||||
queryHasPreferredVersion &&
|
||||
(!treeStreamHasPreferredVersion || querySyncKey !== treeStreamSyncKey)
|
||||
) {
|
||||
return "query";
|
||||
}
|
||||
if (treeStreamHasPreferredVersion) {
|
||||
return "tree_stream";
|
||||
}
|
||||
@@ -53,7 +59,9 @@ export function usePreferredSidebarSnapshot(input: {
|
||||
return "initial";
|
||||
}, [
|
||||
queryHasPreferredVersion,
|
||||
querySyncKey,
|
||||
treeStreamHasPreferredVersion,
|
||||
treeStreamSyncKey,
|
||||
]);
|
||||
|
||||
const data =
|
||||
|
||||
@@ -123,8 +123,29 @@ describe("useSidebarData", () => {
|
||||
expect(secondState).toBe(firstState);
|
||||
});
|
||||
|
||||
it("Convex live 模式下手动 refetch 不应再走 HTTP snapshot 补偿链", async () => {
|
||||
it("Convex live 模式下手动 refetch 应拉取 HTTP snapshot 补偿显式刷新", async () => {
|
||||
const initialData = buildInitialData();
|
||||
const manualSnapshot: SidebarInitialData = {
|
||||
...buildInitialData(),
|
||||
documents: [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "doc-live",
|
||||
workspace_id: "ws_1",
|
||||
title: "显式刷新标题",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-05-14T00:00:00.000Z",
|
||||
updated_at: "2026-05-14T00:01:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
(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} />);
|
||||
@@ -132,14 +153,22 @@ describe("useSidebarData", () => {
|
||||
|
||||
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||
stableRefetch.mockClear();
|
||||
let refetchResult: unknown = null;
|
||||
await act(async () => {
|
||||
await state.refetch();
|
||||
refetchResult = await state.refetch();
|
||||
});
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/sidebar?workspaceId=ws_1", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
expect(stableRefetch).toHaveBeenCalledTimes(1);
|
||||
expect(refetchResult).toStrictEqual(manualSnapshot);
|
||||
await act(async () => {
|
||||
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||
});
|
||||
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||
expect(refreshedState.data).toStrictEqual(initialData);
|
||||
expect(refreshedState.data).toStrictEqual(manualSnapshot);
|
||||
});
|
||||
|
||||
it("无 live 订阅且允许 HTTP fallback 时应继续走 HTTP refetch", async () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
||||
import {
|
||||
buildSidebarDataSyncKey,
|
||||
getSidebarDataFreshness,
|
||||
} from "@/components/sidebar/sidebar-sync";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||
|
||||
@@ -48,6 +51,10 @@ async function requestSidebarData(workspaceId: string): Promise<SidebarInitialDa
|
||||
export function useSidebarData(initialData: SidebarInitialData): SidebarDataResult {
|
||||
const workspaceId = initialData.activeWorkspaceId;
|
||||
const convexSidebar = useConvexSidebarData(workspaceId);
|
||||
const [manualSnapshot, setManualSnapshot] = useState<{
|
||||
workspaceId: string;
|
||||
data: SidebarInitialData;
|
||||
} | null>(null);
|
||||
const shouldUseHttpFallback =
|
||||
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
|
||||
|
||||
@@ -59,7 +66,28 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
||||
enabled: shouldUseHttpFallback,
|
||||
});
|
||||
|
||||
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
|
||||
const baseLiveData = useMemo(() => {
|
||||
const currentManualSnapshot = manualSnapshot?.workspaceId === workspaceId
|
||||
? manualSnapshot.data
|
||||
: null;
|
||||
const candidates = [
|
||||
initialData,
|
||||
httpQuery.data,
|
||||
convexSidebar.data,
|
||||
currentManualSnapshot,
|
||||
].filter((item): item is SidebarInitialData => Boolean(item));
|
||||
return candidates.reduce((selected, candidate) => {
|
||||
const selectedFreshness = getSidebarDataFreshness(selected);
|
||||
const candidateFreshness = getSidebarDataFreshness(candidate);
|
||||
return candidateFreshness >= selectedFreshness ? candidate : selected;
|
||||
}, initialData);
|
||||
}, [
|
||||
convexSidebar.data,
|
||||
httpQuery.data,
|
||||
initialData,
|
||||
manualSnapshot,
|
||||
workspaceId,
|
||||
]);
|
||||
const liveData = baseLiveData;
|
||||
const isLoading =
|
||||
convexSidebar.hasLiveSubscription
|
||||
@@ -93,7 +121,9 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
||||
const refetch = useCallback(async () => {
|
||||
if (convexSidebar.hasLiveSubscription) {
|
||||
await convexRefetchRef.current();
|
||||
return liveDataRef.current;
|
||||
const snapshot = await requestSidebarData(workspaceId);
|
||||
setManualSnapshot({ workspaceId, data: snapshot });
|
||||
return snapshot;
|
||||
}
|
||||
if (shouldUseHttpFallback) {
|
||||
return httpRefetchRef.current();
|
||||
|
||||
Reference in New Issue
Block a user