fix(sidebar): 让文件树即时应用页面和资源变更
This commit is contained in:
+207
@@ -0,0 +1,207 @@
|
|||||||
|
# 4-26 [done][bug] Sidebar File Tree projection 在树/资产变更后不刷新 v1
|
||||||
|
|
||||||
|
> 更新时间:2026-05-14
|
||||||
|
>
|
||||||
|
> 分类归属:
|
||||||
|
> - `04-tree-domain/done`
|
||||||
|
> - 关联缺陷:`bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md`
|
||||||
|
> - 涉及边界:`05-editor-mainline/sidebar shell`、`06-mindmap/mindmap asset`
|
||||||
|
>
|
||||||
|
> 用户反馈:
|
||||||
|
> - “新建页面/删除页面必须得刷新浏览器才能出现新页面或看到页面删除。”
|
||||||
|
> - “包括新建思维导图,也是得刷新才能看到。”
|
||||||
|
> - “有点像是全局事件被禁用了或类似的。”
|
||||||
|
|
||||||
|
## 1. 问题定义
|
||||||
|
|
||||||
|
当前主页面 Sidebar / File Tree 在页面新建、页面删除、mindmap 新建后没有即时反映最新 projection。浏览器刷新后能看到正确结果,说明后端数据已经写入,问题集中在 `tree command / asset mutation -> sidebar projection -> File Tree UI` 的前端刷新和局部 apply 链路。
|
||||||
|
|
||||||
|
这不是单纯的“整页 reload 太慢”问题,而是 4-25 去掉强刷新后暴露出的新回归:普通命令不再强制刷新浏览器,但 File Tree projection 也没有被同步更新。
|
||||||
|
|
||||||
|
## 2. 真实现象
|
||||||
|
|
||||||
|
已确认现象:
|
||||||
|
|
||||||
|
1. 新建页面后,当前页面壳里的 File Tree / Sidebar 不能立即看到新页面。
|
||||||
|
2. 删除页面后,当前页面壳里的 File Tree / Sidebar 仍可能保留旧页面行。
|
||||||
|
3. 新建 mindmap 后,当前页面壳里的 File Tree 不能立即看到 `mindmap-<id>.json` 资源行。
|
||||||
|
4. 手动刷新浏览器后,页面或 mindmap 资源会按后端最新数据出现/消失。
|
||||||
|
|
||||||
|
期望结果:
|
||||||
|
|
||||||
|
1. `tree.node.create` 成功后,Page Tree 与 File Tree 都能在当前页面壳中即时出现新页面。
|
||||||
|
2. `tree.node.archive` / 删除成功后,Page Tree 与 File Tree 都能在当前页面壳中即时移除目标页面及子树。
|
||||||
|
3. `mindmaps.put(createOnly=true)` 或新 mindmap mount 成功后,File Tree 能即时出现对应 mindmap asset row。
|
||||||
|
4. 不依赖浏览器刷新作为普通成功路径;刷新只能作为 projection 无法局部 apply 时的显式 fallback。
|
||||||
|
|
||||||
|
## 3. 确认证据
|
||||||
|
|
||||||
|
### 3.1 主 Sidebar 的数据源仍来自 preferred snapshot
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:201` 到 `:209`:
|
||||||
|
|
||||||
|
- Sidebar 通过 `usePreferredSidebarSnapshot()` 在 `initialData`、`sidebarQuery.data`、`treeStream.data` 之间选择数据源。
|
||||||
|
- 后续 File Tree projection 仍读取这个 `sidebarData`。
|
||||||
|
|
||||||
|
### 3.2 Page Tree 有本地 setTree,但 File Tree projection 没有同步局部更新
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:305` 到 `:316`:
|
||||||
|
|
||||||
|
- `sidebarData.kernelSidebarTree` 变化时会同步到本地 `tree` state。
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:1900` 到 `:1957`:
|
||||||
|
|
||||||
|
- `handleCreate()` 在 `createDocumentCommand()` 成功后只对页面树做本地 `setTree()`,然后 `router.push()`。
|
||||||
|
- 该路径没有同步更新 `sidebarData.kernelFileTreeProjection.items`,也没有广播 `emitDocumentsChanged()`。
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:2239` 到 `:2249`:
|
||||||
|
|
||||||
|
- `handleDelete()` 成功后调用 `removeDocumentsFromTree()` 和 `emitDocumentsChanged()`。
|
||||||
|
- 该本地删除只影响 Page Tree 的 `tree` state,不会同步移除 File Tree projection items。
|
||||||
|
|
||||||
|
### 3.3 File Tree shell 直接依赖 stale projection items
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:678` 到 `:687`:
|
||||||
|
|
||||||
|
- 非搜索状态下 `effectiveResourceTreeShellItems` 直接等于 `sidebarData.kernelFileTreeProjection.items`。
|
||||||
|
- 如果 preferred snapshot 没有变化,File Tree 行模型就不会包含新页面、删除结果或新 mindmap asset。
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:695` 到 `:702`:
|
||||||
|
|
||||||
|
- `buildFileTreeShellRowById()` 基于 `effectiveResourceTreeShellItems` 构建 row map。
|
||||||
|
- 即使 `assetById` 里有新 asset,只要 projection items 没有对应 row,File Tree 也无法显示这条资源。
|
||||||
|
|
||||||
|
### 3.4 全局事件监听存在,但 refetch 在 Convex live 模式下是空操作
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar-events.ts:17` 到 `:28`:
|
||||||
|
|
||||||
|
- `wolai:documents-changed` / `wolai:assets-changed` 会触发 `sidebarRefetch()`。
|
||||||
|
- 这说明全局事件监听并非完全禁用。
|
||||||
|
|
||||||
|
`wolai-frontend/src/hooks/use-sidebar-data.ts:93` 到 `:101`:
|
||||||
|
|
||||||
|
- `sidebarQuery.refetch()` 在 Convex live subscription 模式下转调 `convexSidebar.refetch()`。
|
||||||
|
|
||||||
|
`wolai-frontend/src/hooks/use-convex-sidebar-data.ts:90` 到 `:96`:
|
||||||
|
|
||||||
|
- `hasLiveSubscription = shouldFetch`。
|
||||||
|
- `refetch` 明确是空操作,注释为“Convex 会自动同步数据”。
|
||||||
|
|
||||||
|
因此,只要 Convex live query 没有及时推送或 preferred snapshot 仍选择旧 tree stream/query 数据,`emitDocumentsChanged()` / `emitAssetsChanged()` 对 File Tree projection 就不会强制拉取新 snapshot。
|
||||||
|
|
||||||
|
### 3.5 mindmap 新建只乐观补 asset 列表,不补 File Tree projection row
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx:1527` 到 `:1534`、`:1591` 到 `:1621`、`:1636` 到 `:1647`:
|
||||||
|
|
||||||
|
- mindmap 保存、初始同步、实例就绪都会调用 `emitAssetsChanged(docId, asset)`。
|
||||||
|
|
||||||
|
`wolai-frontend/src/components/sidebar/sidebar.tsx:506` 到 `:528`:
|
||||||
|
|
||||||
|
- Sidebar 收到带 `asset` 的事件后会把 mindmap 加入 `mindmapAssets` 本地 state。
|
||||||
|
- 但 File Tree 的 row 输入仍是 `sidebarData.kernelFileTreeProjection.items`,没有把新 mindmap asset materialize 成 projection item。
|
||||||
|
|
||||||
|
这解释了“新建思维导图也必须刷新浏览器才能看到”:asset 本地列表可能更新了,但 Rust-family File Tree shell 的可见行不来自这个列表本身,而来自 stale file projection。
|
||||||
|
|
||||||
|
## 4. 当前判断
|
||||||
|
|
||||||
|
该问题已通过代码路径确认,属于事件/刷新链路和 projection 局部 apply 缺口:
|
||||||
|
|
||||||
|
1. 事件监听存在,不是全局事件完全被禁用。
|
||||||
|
2. 文档变更事件在 create 路径缺失,在 delete 路径存在但只触发空 refetch。
|
||||||
|
3. asset 变更事件能把 mindmap 加进本地 asset state,但不能生成 File Tree projection row。
|
||||||
|
4. File Tree shell 以 `kernelFileTreeProjection.items` 为事实源,当前缺少对 create / archive / mindmap asset create 的局部 reducer 或强制 projection reload。
|
||||||
|
5. 浏览器刷新后能看到正确结果,进一步说明后端写入成功,前端 projection 没有及时刷新。
|
||||||
|
|
||||||
|
## 5. 建议修复方向
|
||||||
|
|
||||||
|
1. 将 `tree.node.create` / `tree.node.archive` 的 command result 或 stream delta 同步应用到 `kernelFileTreeProjection.items`,不要只更新 Page Tree state。
|
||||||
|
2. `emitDocumentsChanged()` 应能触发一个真实的 projection refresh:在 Convex live 模式下不能继续是空操作,至少需要 invalidate/fetch `/api/sidebar` 或 `/api/tree/projections/file`。
|
||||||
|
3. `emitAssetsChanged(asset)` 对 mindmap create 应能 materialize 对应 File Tree asset row,或触发 File Tree projection 重新拉取。
|
||||||
|
4. `usePreferredSidebarSnapshot()` 需要避免旧 tree stream snapshot 长时间压住更新后的 query/http projection。
|
||||||
|
5. 增加主页面 Sidebar smoke,覆盖真实 `/documents/<id>` 页面内的新建页面、删除页面、新建 mindmap,断言不刷新浏览器即可看到 UI 变化。
|
||||||
|
|
||||||
|
## 6. 修复记录
|
||||||
|
|
||||||
|
2026-05-14 已完成修复:
|
||||||
|
|
||||||
|
1. `wolai-frontend/src/components/sidebar/sidebar-local-projection.ts`
|
||||||
|
- 新增 Sidebar 本地 documents/file-tree projection helper。
|
||||||
|
- `tree.node.create` 成功后可将新页面 upsert 到本地 documents,并重建 File Tree projection。
|
||||||
|
- `tree.node.archive` / 删除成功后可从本地 documents 中移除目标页面及子树,并重建 File Tree projection。
|
||||||
|
- mindmap asset 进入本地 `mindmapAssets` 后,可 materialize 成 File Tree asset row。
|
||||||
|
2. `wolai-frontend/src/components/sidebar/sidebar.tsx`
|
||||||
|
- 新增本地 `documents` state,并随后端 sidebar snapshot 校准。
|
||||||
|
- File Tree 非搜索状态改为消费 `localFileTreeProjection.items`,不再只依赖 stale `sidebarData.kernelFileTreeProjection.items`。
|
||||||
|
- `handleCreate()` 成功后同步 upsert documents、广播 `emitDocumentsChanged(nextNode.id)`,Page Tree 与 File Tree 都可即时反映。
|
||||||
|
- `handleDelete()` / 文件树批量删除成功后同步移除 documents 子树,File Tree 不再保留旧 projection row。
|
||||||
|
- rename / move / Rust-family tree shell mutation 也同步更新本地 documents,避免 File Tree row 标题或父级滞后。
|
||||||
|
3. `wolai-frontend/src/hooks/use-sidebar-data.ts`
|
||||||
|
- Convex live 模式下的显式 `refetch()` 不再只是空操作;现在会拉取 `/api/sidebar?workspaceId=<id>` 作为手动 snapshot 补偿。
|
||||||
|
- 手动 snapshot 绑定 workspaceId,避免跨工作区污染。
|
||||||
|
4. `wolai-frontend/src/components/sidebar/sidebar-sync.ts`
|
||||||
|
- `buildSidebarDataSyncKey()` 纳入顶层 `documents`,避免只有 documents 变化时稳定缓存仍命中旧 sidebar data。
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`:
|
||||||
|
|
||||||
|
1. [x] 在 `http://127.0.0.1:3000/documents/<id>` 主页面壳中新建页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到新页面。代码证据:`handleCreate()` 本地 upsert documents,`localFileTreeProjection.items` 立即包含 `doc:<id>` / `index:<id>`;单测覆盖:`sidebar-local-projection.test.ts`。
|
||||||
|
2. [x] 在主页面壳中删除页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到目标页面消失。代码证据:`handleDelete()` / `handleDeleteResourceSelection()` 同步 `removeSidebarDocumentRecords()`,并移除子树;单测覆盖:`sidebar-local-projection.test.ts` 与 `sidebar-delete-preflight-source.test.ts`。
|
||||||
|
3. [x] 在主页面壳中新建 mindmap 后,不刷新浏览器即可在 File Tree 中看到对应 `mindmap-<id>.json` 行。代码证据:`emitAssetsChanged(asset)` 已进入本地 `mindmapAssets`,`localFileTreeProjection` 用本地 mindmap assets 重建 asset row;单测覆盖:`sidebar-local-projection.test.ts`。
|
||||||
|
4. [x] 上述三条路径均不得依赖 `window.location.reload()` 或顶层 navigation。代码证据:本轮没有新增 reload;create/delete 以本地 state/projection apply 为主,显式 refetch 仅作 snapshot 校准。
|
||||||
|
5. [x] smoke 需要记录 `documents-changed` / `assets-changed` 事件、`/api/tree/events` 或 projection refetch 是否发生,以及最终 DOM 稳定耗时。已补真实浏览器 smoke:`tmp/task426-sidebar-main-no-reload-smoke/result.json` 记录 `createPageVisibleMs=341`、`mindmapVisibleMs=293`、`deleteDetachedMs=1570`;事件包含 `wolai:documents-changed`、`wolai:assets-changed`、删除后的 `wolai:documents-changed`;网络包含 `/api/tree/events`、`POST /api/tree/commands`、`POST /api/mindmap/...` 与显式 `/api/sidebar?workspaceId=...` refetch。代码级测试同时覆盖:`sidebar-delete-preflight-source.test.ts` 锁定 create/delete 事件与本地 documents apply;`use-sidebar-data.test.tsx` 锁定 Convex live 显式 refetch 会真实拉 `/api/sidebar`。
|
||||||
|
6. [x] 修复后补测试,防止 `sidebarQuery.refetch()` 在 Convex live 模式下继续作为空操作吞掉显式刷新请求。测试覆盖:`use-sidebar-data.test.tsx`。
|
||||||
|
|
||||||
|
## 8. 验证记录
|
||||||
|
|
||||||
|
已通过:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm test -- src/components/sidebar/sidebar-delete-preflight-source.test.ts src/components/sidebar/sidebar-local-projection.test.ts src/hooks/use-sidebar-data.test.tsx src/components/sidebar/sidebar-sync.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:`113 passed`,`465 passed`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm exec eslint src/components/sidebar/sidebar-sync.ts src/components/sidebar/sidebar-sync.test.ts src/components/sidebar/sidebar-local-projection.ts src/components/sidebar/sidebar-local-projection.test.ts src/hooks/use-sidebar-data.ts src/hooks/use-sidebar-data.test.tsx src/components/sidebar/sidebar.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:无 error;`sidebar.tsx` 保留既有 warning。
|
||||||
|
|
||||||
|
真实浏览器 smoke 已通过:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
MNOTE_UI_BASE_URL=http://127.0.0.1:3001 node <task426-sidebar-main-no-reload-smoke>
|
||||||
|
```
|
||||||
|
|
||||||
|
结果文件:`tmp/task426-sidebar-main-no-reload-smoke/result.json`。
|
||||||
|
|
||||||
|
关键结果:
|
||||||
|
|
||||||
|
- `ok=true`
|
||||||
|
- `createPageVisibleMs=341`
|
||||||
|
- `mindmapVisibleMs=293`
|
||||||
|
- `deleteDetachedMs=1570`
|
||||||
|
- 删除确认文案:`确认删除选中的 1 个页面(删除到垃圾桶) 吗?`
|
||||||
|
- `beforeDeleteState.filetreeRows` 包含 `doc:50151ef9-e0d5-48b9-a10d-3338b417f81f`、`index:50151ef9-e0d5-48b9-a10d-3338b417f81f`、`asset:mind_task426_1778712496830`
|
||||||
|
- `finalState.filetreeRows` 不再包含上述 document / mindmap asset row
|
||||||
|
- `finalState.eventLog` 记录 `wolai:documents-changed`、`wolai:assets-changed`、删除后的 `wolai:documents-changed`
|
||||||
|
- `requests` 记录 `/api/tree/events`、`POST /api/tree/commands` create/archive、`POST /api/mindmap/...` createOnly、`GET /api/sidebar?workspaceId=...`
|
||||||
|
|
||||||
|
已尝试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm exec tsc --noEmit --pretty false
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:失败,但失败集中在既有 Convex / AI / OnlyOffice / 测试类型问题,不是本轮新增文件或本轮修改路径的专属错误。
|
||||||
|
|
||||||
|
## 9. 流转条件
|
||||||
|
|
||||||
|
当前状态:`done`
|
||||||
|
|
||||||
|
本缺陷已完成代码修复与相关测试覆盖,按 bugs 目录规则迁移到 `bugs/04-tree-domain/done/`。
|
||||||
@@ -51,9 +51,13 @@ describe("sidebar file tree delete preflight source", () => {
|
|||||||
const deleteBody = source.slice(deleteStart, deleteEnd);
|
const deleteBody = source.slice(deleteStart, deleteEnd);
|
||||||
|
|
||||||
expect(createBody).not.toContain("await refreshTree();");
|
expect(createBody).not.toContain("await refreshTree();");
|
||||||
|
expect(createBody).toContain("upsertSidebarDocumentRecord(");
|
||||||
|
expect(createBody).toContain("emitDocumentsChanged(nextNode.id);");
|
||||||
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
|
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
|
||||||
|
expect(deleteSelectionBody).toContain("removeSidebarDocumentRecords(prev, docIds)");
|
||||||
expect(deleteSelectionBody).not.toContain("await refreshTree();");
|
expect(deleteSelectionBody).not.toContain("await refreshTree();");
|
||||||
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
|
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
|
||||||
|
expect(deleteBody).toContain("removeSidebarDocumentRecords(prev, [documentId])");
|
||||||
expect(deleteBody).not.toContain("await refreshTree();");
|
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 { 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 { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { MediaAsset } from "@/types/media";
|
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", () => {
|
describe("sidebar-sync", () => {
|
||||||
it("相同侧边栏树内容应产生相同 sync key", () => {
|
it("相同侧边栏树内容应产生相同 sync key", () => {
|
||||||
const left = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
|
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));
|
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);
|
const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection);
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
activeWorkspaceId: data.activeWorkspaceId,
|
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),
|
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
|
||||||
fileTree: {
|
fileTree: {
|
||||||
projectionId: fileTreeProjection.projectionId,
|
projectionId: fileTreeProjection.projectionId,
|
||||||
@@ -110,8 +122,6 @@ 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 ?? []),
|
||||||
@@ -126,7 +136,6 @@ export function getSidebarDataFreshness(data: SidebarInitialData): number {
|
|||||||
0,
|
0,
|
||||||
...documentTimes,
|
...documentTimes,
|
||||||
...treeTimes,
|
...treeTimes,
|
||||||
...fileTreeTimes,
|
|
||||||
...assetTimes,
|
...assetTimes,
|
||||||
...trashedDocumentTimes,
|
...trashedDocumentTimes,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ import {
|
|||||||
buildMediaAssetListSyncKey,
|
buildMediaAssetListSyncKey,
|
||||||
buildSidebarTreeSyncKey,
|
buildSidebarTreeSyncKey,
|
||||||
} from "@/components/sidebar/sidebar-sync";
|
} 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 { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
|
||||||
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
|
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
|
||||||
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
|
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 [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||||
|
const [documents, setDocuments] = useState(() => sidebarData.documents ?? []);
|
||||||
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
|
||||||
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
|
||||||
const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
|
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 [searchFileTreeProjectionKey, setSearchFileTreeProjectionKey] = useState<string | null>(null);
|
||||||
const [sidebarHydrated, setSidebarHydrated] = useState(false);
|
const [sidebarHydrated, setSidebarHydrated] = useState(false);
|
||||||
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
|
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
|
||||||
|
const documentSyncKeyRef = useRef<string>(buildDocumentListSyncKey(sidebarData.documents ?? []));
|
||||||
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
||||||
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
||||||
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
||||||
@@ -315,6 +326,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
});
|
});
|
||||||
}, [sidebarData.kernelSidebarTree]);
|
}, [sidebarData.kernelSidebarTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextDocuments = sidebarData.documents ?? [];
|
||||||
|
const nextSyncKey = buildDocumentListSyncKey(nextDocuments);
|
||||||
|
if (documentSyncKeyRef.current === nextSyncKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
documentSyncKeyRef.current = nextSyncKey;
|
||||||
|
setDocuments(nextDocuments);
|
||||||
|
}, [sidebarData.documents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
@@ -657,6 +678,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId
|
normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId
|
||||||
? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}`
|
? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}`
|
||||||
: null;
|
: 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(
|
const resourceTreeShellItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
expectedSearchFileTreeProjectionKey &&
|
expectedSearchFileTreeProjectionKey &&
|
||||||
@@ -679,11 +719,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
() =>
|
() =>
|
||||||
normalizedFileTreeSearchQuery.length > 0
|
normalizedFileTreeSearchQuery.length > 0
|
||||||
? (resourceTreeShellItems ?? [])
|
? (resourceTreeShellItems ?? [])
|
||||||
: sidebarData.kernelFileTreeProjection.items,
|
: localFileTreeProjection.items,
|
||||||
[
|
[
|
||||||
normalizedFileTreeSearchQuery,
|
normalizedFileTreeSearchQuery,
|
||||||
resourceTreeShellItems,
|
resourceTreeShellItems,
|
||||||
sidebarData.kernelFileTreeProjection.items,
|
localFileTreeProjection.items,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -749,32 +789,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const docParentById = useMemo(
|
const docParentById = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildParentById(
|
buildParentById(
|
||||||
(sidebarData.documents ?? []).map((doc) => ({
|
documents.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
parentId: doc.parent_id ?? null,
|
parentId: doc.parent_id ?? null,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
[sidebarData.documents],
|
[documents],
|
||||||
);
|
);
|
||||||
const documentWorkspaceById = useMemo(
|
const documentWorkspaceById = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
(sidebarData.documents ?? []).map((doc) => [
|
documents.map((doc) => [
|
||||||
doc.id,
|
doc.id,
|
||||||
typeof doc.workspace_id === "string" ? doc.workspace_id : null,
|
typeof doc.workspace_id === "string" ? doc.workspace_id : null,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
[sidebarData.documents],
|
[documents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const childrenCountByParentId = useMemo(() => {
|
const childrenCountByParentId = useMemo(() => {
|
||||||
const map = new Map<string | null, number>();
|
const map = new Map<string | null, number>();
|
||||||
(sidebarData.documents ?? []).forEach((doc) => {
|
documents.forEach((doc) => {
|
||||||
const parentId = doc.parent_id ?? null;
|
const parentId = doc.parent_id ?? null;
|
||||||
map.set(parentId, (map.get(parentId) ?? 0) + 1);
|
map.set(parentId, (map.get(parentId) ?? 0) + 1);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [sidebarData.documents]);
|
}, [documents]);
|
||||||
|
|
||||||
const activeWorkspace =
|
const activeWorkspace =
|
||||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||||
@@ -1241,6 +1281,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
if (nodeById.has(documentId)) return prev;
|
if (nodeById.has(documentId)) return prev;
|
||||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||||
});
|
});
|
||||||
|
setDocuments((prev) =>
|
||||||
|
upsertSidebarDocumentRecord(
|
||||||
|
prev,
|
||||||
|
sidebarTreeNodeToDocumentRecord(nextNode, sidebarData.activeWorkspaceId || ""),
|
||||||
|
),
|
||||||
|
);
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (parentId) next.add(parentId);
|
if (parentId) next.add(parentId);
|
||||||
@@ -1257,6 +1303,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
|
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
|
||||||
|
setDocuments((prev) => renameSidebarDocumentRecord(prev, documentId, title, payload.updatedAt ?? null));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1267,6 +1314,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
? payload.sortOrder
|
? payload.sortOrder
|
||||||
: Number.MAX_SAFE_INTEGER;
|
: Number.MAX_SAFE_INTEGER;
|
||||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
|
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
|
||||||
|
setDocuments((prev) => moveSidebarDocumentRecord(prev, documentId, parentId, sortOrder));
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
setExpanded((prev) => new Set(prev).add(parentId));
|
setExpanded((prev) => new Set(prev).add(parentId));
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1508,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
resourceSelection.focusedRowId,
|
resourceSelection.focusedRowId,
|
||||||
resourceSelection.selectedRowIds,
|
resourceSelection.selectedRowIds,
|
||||||
resourceShellVisibleRowIds,
|
resourceShellVisibleRowIds,
|
||||||
|
sidebarData.activeWorkspaceId,
|
||||||
sidebarQuery,
|
sidebarQuery,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1836,6 +1885,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeDocumentsFromTree(docIds);
|
removeDocumentsFromTree(docIds);
|
||||||
|
setDocuments((prev) => removeSidebarDocumentRecords(prev, docIds));
|
||||||
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
|
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
|
||||||
if (activeId && docIds.includes(activeId)) {
|
if (activeId && docIds.includes(activeId)) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
@@ -1944,6 +1994,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setDocuments((prev) =>
|
||||||
|
upsertSidebarDocumentRecord(
|
||||||
|
prev,
|
||||||
|
sidebarTreeNodeToDocumentRecord(nextNode, sidebarData.activeWorkspaceId || ""),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
emitDocumentsChanged(nextNode.id);
|
||||||
|
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (nextNode.workspace_id) {
|
if (nextNode.workspace_id) {
|
||||||
@@ -1961,7 +2018,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router],
|
[router, sidebarData.activeWorkspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
@@ -1980,14 +2037,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
|
window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试");
|
||||||
return;
|
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(
|
const handleMove = useCallback(
|
||||||
async (documentId: string, parentId: string | null, index: number) => {
|
async (documentId: string, parentId: string | null, index: number) => {
|
||||||
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
|
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
|
||||||
|
setDocuments((prev) => moveSidebarDocumentRecord(prev, documentId, parentId, index));
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
setExpanded((prev) => new Set(prev).add(parentId));
|
setExpanded((prev) => new Set(prev).add(parentId));
|
||||||
}
|
}
|
||||||
@@ -2002,7 +2062,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
await refreshTree();
|
await refreshTree();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshTree();
|
emitDocumentsChanged(documentId);
|
||||||
},
|
},
|
||||||
[moveLocalNode, refreshTree, setExpanded],
|
[moveLocalNode, refreshTree, setExpanded],
|
||||||
);
|
);
|
||||||
@@ -2178,6 +2238,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
});
|
});
|
||||||
return next;
|
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));
|
setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2243,6 +2310,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||||
});
|
});
|
||||||
removeDocumentsFromTree([documentId]);
|
removeDocumentsFromTree([documentId]);
|
||||||
|
setDocuments((prev) => removeSidebarDocumentRecords(prev, [documentId]));
|
||||||
emitDocumentsChanged(documentId);
|
emitDocumentsChanged(documentId);
|
||||||
if (activeId === documentId) {
|
if (activeId === documentId) {
|
||||||
router.push("/");
|
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 () => {
|
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" })]);
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export function usePreferredSidebarSnapshot(input: {
|
|||||||
treeStreamVersion >= preferredVersion;
|
treeStreamVersion >= preferredVersion;
|
||||||
|
|
||||||
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
|
||||||
|
if (
|
||||||
|
queryHasPreferredVersion &&
|
||||||
|
(!treeStreamHasPreferredVersion || querySyncKey !== treeStreamSyncKey)
|
||||||
|
) {
|
||||||
|
return "query";
|
||||||
|
}
|
||||||
if (treeStreamHasPreferredVersion) {
|
if (treeStreamHasPreferredVersion) {
|
||||||
return "tree_stream";
|
return "tree_stream";
|
||||||
}
|
}
|
||||||
@@ -53,7 +59,9 @@ export function usePreferredSidebarSnapshot(input: {
|
|||||||
return "initial";
|
return "initial";
|
||||||
}, [
|
}, [
|
||||||
queryHasPreferredVersion,
|
queryHasPreferredVersion,
|
||||||
|
querySyncKey,
|
||||||
treeStreamHasPreferredVersion,
|
treeStreamHasPreferredVersion,
|
||||||
|
treeStreamSyncKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
|
|||||||
@@ -123,8 +123,29 @@ describe("useSidebarData", () => {
|
|||||||
expect(secondState).toBe(firstState);
|
expect(secondState).toBe(firstState);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Convex live 模式下手动 refetch 不应再走 HTTP snapshot 补偿链", async () => {
|
it("Convex live 模式下手动 refetch 应拉取 HTTP snapshot 补偿显式刷新", async () => {
|
||||||
const initialData = buildInitialData();
|
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 () => {
|
await act(async () => {
|
||||||
root.render(<Harness initialData={initialData} onState={onState} />);
|
root.render(<Harness initialData={initialData} onState={onState} />);
|
||||||
@@ -132,14 +153,22 @@ describe("useSidebarData", () => {
|
|||||||
|
|
||||||
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
|
||||||
stableRefetch.mockClear();
|
stableRefetch.mockClear();
|
||||||
|
let refetchResult: unknown = null;
|
||||||
await act(async () => {
|
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(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>;
|
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 () => {
|
it("无 live 订阅且允许 HTTP fallback 时应继续走 HTTP refetch", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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 { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -48,6 +51,10 @@ async function requestSidebarData(workspaceId: string): Promise<SidebarInitialDa
|
|||||||
export function useSidebarData(initialData: SidebarInitialData): SidebarDataResult {
|
export function useSidebarData(initialData: SidebarInitialData): SidebarDataResult {
|
||||||
const workspaceId = initialData.activeWorkspaceId;
|
const workspaceId = initialData.activeWorkspaceId;
|
||||||
const convexSidebar = useConvexSidebarData(workspaceId);
|
const convexSidebar = useConvexSidebarData(workspaceId);
|
||||||
|
const [manualSnapshot, setManualSnapshot] = useState<{
|
||||||
|
workspaceId: string;
|
||||||
|
data: SidebarInitialData;
|
||||||
|
} | null>(null);
|
||||||
const shouldUseHttpFallback =
|
const shouldUseHttpFallback =
|
||||||
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
|
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
|
||||||
|
|
||||||
@@ -59,7 +66,28 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
|||||||
enabled: shouldUseHttpFallback,
|
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 liveData = baseLiveData;
|
||||||
const isLoading =
|
const isLoading =
|
||||||
convexSidebar.hasLiveSubscription
|
convexSidebar.hasLiveSubscription
|
||||||
@@ -93,7 +121,9 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
|||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
if (convexSidebar.hasLiveSubscription) {
|
if (convexSidebar.hasLiveSubscription) {
|
||||||
await convexRefetchRef.current();
|
await convexRefetchRef.current();
|
||||||
return liveDataRef.current;
|
const snapshot = await requestSidebarData(workspaceId);
|
||||||
|
setManualSnapshot({ workspaceId, data: snapshot });
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
if (shouldUseHttpFallback) {
|
if (shouldUseHttpFallback) {
|
||||||
return httpRefetchRef.current();
|
return httpRefetchRef.current();
|
||||||
|
|||||||
Reference in New Issue
Block a user