移植自本地 main(含 3536689..e670f6f 的最终状态)。
基于 origin/main 而非 rebase 本地 —— 本地那条链上的 6 个提交
(0a9287b/d52be3f/74aacc3/646cb3f/6e198d2/010c8ec)是远端已推内容的等价改写,
重复移植只会制造冲突;远端另有一条线上的成果(main.cjs/preload.cjs/
index.css/main.tsx/TitleBar.tsx/run-all.mjs 与 4 份 docs)本地没有,
**不能覆盖**,故从 origin/main 起只取本次真正改动的 5 个文件。
## 对话树:换成画布式真树
用户连续三轮反馈「这不还是老版的对话树」,最后明确要求:
「直接用你之前写的那个**可拖动缩放的对话树界面**替换现在这个对话树界面」。
我前两轮改错了方向 —— 一直在**缩进列表**上打补丁(改排序 → 改竖线 →
整份重写),而缩进列表**表达不了分支**:实测最深的线索 205 封、59 层,
多个兄弟追问并排时缩进线断成一片;画布上每条边都是独立的一条线。
现在:可拖动平移 / 滚轮缩放(20%–250%,锚定光标)/ 点节点看正文 /
分层 Reingold–Tilford 布局(不用力导向:位置会变、点不准)。
## 两个线上 bug(用户实测报告,我第一轮没看出来)
① **页面没撑满**:打开后 zoom=140%、pan.x=-9393,内容包围盒 11340×7193,
屏幕上只看得到左上角。而手动点「适应」是对的(0.1217)——
同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
逐层实测揪出三个连环坑:`nodes` 是**根数组**(只 1 个根)、
自动 fit 的 effect 没在数据到达后重跑、`fittedFor` 守卫置位太早把自己挡掉。
另:`MailView` 的 `threadOf` 分支裸返回 `<ThreadView/>`,缺
`flex-1 min-w-0 min-h-0` ⇒ 高度链断。
② **一缩放就崩**:`onWheel` 直接解引用 `e.currentTarget`,
组件卸载后滚轮事件仍在飞 ⇒ `TypeError: Cannot read properties of null`。
实测放大 20 次后面板消失。改成先取可空引用 + 空值守卫。
真机复测:200 节点 / 55 层,打开即 fit 到 12%,放大 20 次 + 缩小 25 次
全部 0 崩溃 0 错误。
## 日历:翻月卡死
用户实测「点击切换月份的按钮,直接从10月跳到八月卡死」。
CDP 复现:
第1跳起:opacity="0",transform="translateX(-94.8px)",元素 x = -15
右侧工具栏(导入/导出/新建)整块消失
根因:注释写着「用**声明式** key + 类」,实现却是两个 ref ——
**ref 改动不触发重渲染** ⇒ slideClass 只能用上一次的方向,且 class 名永不变
⇒ 动画只播一次不再重放。而 `animation-fill-mode: both` 把元素留在帧之间,
动画一旦没跑到终点就永久停在 `translateX(-12%) + opacity:0`。
修:ref → `useState<{dir,seq}>`;`seq` 进 key 让动画真重放;
加 260ms 自清理兜底(最坏只是没动画,不会卡死)。
复测:12 次连点,每次都是「class 已摘掉 OK」,侧栏/网格/工具栏全好。
## 判据
`thread-view-tree.test.mjs` 15 格 + `calendar-slide.test.mjs` 6 格,
每个 bug 都有对应的**可复现变异**(退回 ref / 去掉清理 / 去掉 seq …),
实测全部转红。
## 教训
两轮都只跑 `tsc` + 判据就宣布完成,然后请用户去看 ——
**「类型没错」不等于「页面能用」**。日历那一轮我更是盲巡检了十几轮
(还把「看起来被切」误判成 bug,最后自己量出 scrollHeight==clientHeight 才撤回)。
**先问清楚,比继续猜便宜得多。**
202 lines
12 KiB
JavaScript
202 lines
12 KiB
JavaScript
/*
|
||
对话树 = 画布式真树(可拖动 / 可缩放 / 点节点看正文)的判据(2026-10-04)。
|
||
|
||
★ 演进(用户连续几轮反馈「还是老版」):
|
||
① 原实现是缩进列表 → 我改排序/竖线/缩进上限,仍像平铺(缩进列表
|
||
**表达不了分支**:59 层里多个兄弟并排时线断成一片)
|
||
② 整份重写为缩进列表的改良版 → 仍不是用户要的东西
|
||
③ **本轮**:用户明确「直接用你之前写的那个可拖动缩放的对话树界面替换
|
||
现在这个对话树界面」⇒ ThreadView 改成画布树。
|
||
|
||
★ 本文件盯画布树的三个能力 + 一条禁止退回的守卫。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { code, prose } from './lib/read.mjs';
|
||
|
||
const src = p => code(`src/${p}`);
|
||
|
||
test('会话视图保持平铺(范围纪律,用户 2026-10-04)', () => {
|
||
const view = src('components/MailView.tsx');
|
||
assert.match(view, /currentSessionMails\.map\(/, '会话视图不该变成树');
|
||
});
|
||
|
||
test('★ 可拖动:指针拖拽改平移,且绑在容器上', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /const onPointerDown = useCallback/, '没有 onPointerDown 实现');
|
||
// ★ 必须查**绑定**而不只是字面量:变异把 JSX 上的 onPointerDown 换成注释时,
|
||
// 早期版本判据全绿(函数还在,只是没接上)。
|
||
assert.match(tv, /onPointerDown=\{onPointerDown\}/, 'onPointerDown 没绑到容器 ⇒ 拖不动');
|
||
assert.match(tv, /onPointerMove=\{onPointerMove\}/, 'onPointerMove 没绑 ⇒ 拖不动');
|
||
assert.match(tv, /setPointerCapture/, '未捕获指针 —— 拖出元素会中断');
|
||
assert.match(tv, /cursor-grab/, '缺拖动光标提示');
|
||
// 点在节点上时不启动平移(否则点一下会把整棵树拖走)
|
||
assert.match(tv, /closest\('\[data-node\]'\)/, '点节点也会启动平移');
|
||
});
|
||
|
||
test('★ 可缩放:滚轮缩放且锚定光标', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /const onWheel = useCallback/, '没有 onWheel 实现');
|
||
assert.match(tv, /onWheel=\{onWheel\}/, '★ onWheel 没绑 ⇒ 滚轮不缩放');
|
||
// ★ 必须断言**结果里真的用上了光标坐标**,而不只是「代码里出现过它」。
|
||
// 变异「把 setPan 改成原样返回(坐标照算但不用)」时,
|
||
// `clientX - rect.left` 仍在源码里 ⇒ 只查字面量会假绿。
|
||
assert.match(tv, /setPan\(p =>\s*\(\{\s*\n?\s*x: cx -/,
|
||
'★ 缩放结果没有按光标锚定 —— 缩放时内容会从光标下「跳」走');
|
||
assert.match(tv, /MIN_ZOOM/, '没有缩放下限');
|
||
assert.match(tv, /适应/, '没有「适应」按钮(60% 缩放下 205 封的树没法看)');
|
||
});
|
||
|
||
test('★ 可点击看正文', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /onClick=\{\(\) => pick\(n\)\}/, '节点不可点击');
|
||
assert.match(tv, /setDetail\(n\)/, '点了没有正文');
|
||
assert.match(tv, /body_preview \|\| detail\.body/, '正文没有渲染');
|
||
assert.match(tv, /openMailByID\(detail\.mail_id\)/, '没有「打开完整正文」的出口');
|
||
});
|
||
|
||
test('★ 布局是分层画布,不是缩进列表', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /function layout\(nodes: ThreadNode\[\]\): Placed\[\]/, '没有画布布局');
|
||
assert.match(tv, /x: number/, '节点没有坐标 ⇒ 不是画布');
|
||
assert.match(tv, /nodes - Math\.max|calc\(c\)/, '没有按子树宽度分配列');
|
||
// ⚠️ 力导向:位置每次变 ⇒ 点不准、两次打开画成不同样
|
||
assert.doesNotMatch(tv, /simulation|velocity|forceLayout/,
|
||
'用了力导向 —— 节点位置不稳定,点不准');
|
||
// ★ 禁止退回缩进列表
|
||
assert.doesNotMatch(tv, /paddingLeft: indent|Math\.min\(Math\.max\(node\.depth/,
|
||
'★ 又退回缩进列表了 —— 那表达不了分支,正是「看起来还是平铺」的原因');
|
||
});
|
||
|
||
test('数据源是邮件线索(不是会话列表)', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /getMailThread/, '没有读邮件线索');
|
||
assert.doesNotMatch(tv, /listSessionTree|listContacts/,
|
||
'用了会话级数据 —— 那是 198 个会话,不是这条线索的 205 封邮件');
|
||
const m = prose('src/components/ThreadView.tsx').match(/PAGE_SIZE = (\d+)/);
|
||
assert.ok(Number(m[1]) >= 200,
|
||
`首屏 ${m[1]} 封 —— 实测最深的线索有 205 封,60 会把后半截藏起来`);
|
||
});
|
||
|
||
test('parent_mail_id 外部可写 ⇒ 防环 + 不丢信', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /onPath\.has\(n\.mail_id\)/,
|
||
'无环保护 —— 成环会递归不死(205 封的面板直接白屏)');
|
||
assert.match(tv, /p\.mail_id !== n\.mail_id/, '没排除自引用');
|
||
});
|
||
|
||
test('★ 打开就 fit(否则 200 节点只露左上角 = 没撑满)', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /const run = \(\) => \{ fitTo\(\); setFittedFor\(mailID\); \};/,
|
||
'没有自动 fit 的入口');
|
||
// ★ 实测两条死路:load 里直调 → 首帧 rect 未完成 ⇒ 算出 1.4 上限值;
|
||
// load 里套 rAF → 仍被初始 state 的重渲染盖掉。
|
||
// 正确位置是「nodes 落地后、且只做一次」的 effect。
|
||
assert.match(tv, /if \(!nodes\.length \|\| fittedFor === mailID\) return;/,
|
||
'★ 自动 fit 没有只做一次的守卫 —— ResizeObserver 会反复重置用户拖好的视口');
|
||
assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/,
|
||
'★ 没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的');
|
||
// ★ 关键回归:首帧 wrapRef 还是 null,量不到尺寸就 return ⇒ fit 永远不跑
|
||
assert.match(tv, /if \(!rect \|\| !rect\.width \|\| !rect\.height\) return;/,
|
||
'量不到尺寸时没有安全返回');
|
||
assert.match(tv, /new ResizeObserver/, '没有容器尺寸变化时的重算');
|
||
assert.match(tv, /new ResizeObserver/, '没有监听容器尺寸变化');
|
||
assert.match(tv, /ro\.observe\(wrapRef\.current\)/, 'ResizeObserver 没有观察画布容器');
|
||
assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/,
|
||
'没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的');
|
||
// fit 要同时按宽高算,只看宽会被 59 层(高 7000+)拖成极小
|
||
const m = tv.match(/rect\.height - 48\) \/ Math\.max\(maxY, 1\)/);
|
||
assert.ok(m, 'fit 只按宽度算 ⇒ 59 层的树被压成一条');
|
||
});
|
||
|
||
test('★ 滚轮不崩:不能解引用可能为 null 的元素', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
// 用户实测:放大后 TypeError: Cannot read properties of null
|
||
assert.match(tv, /const el = e\.currentTarget as HTMLElement \| null;/,
|
||
'没有先取一次可空引用');
|
||
assert.match(tv, /if \(!el\) return;/, '★ 没做空值守卫 —— 卸载后滚轮会抛异常');
|
||
assert.doesNotMatch(tv, /\(e\.currentTarget as HTMLElement\)\.getBoundingClientRect\(\)/,
|
||
'★ 直接解引用 currentTarget —— 卸载后即为 null');
|
||
});
|
||
|
||
/*
|
||
★ 两个线上症状的判据(用户 2026-10-04 实测报告,我第一轮没看出来)。
|
||
两轮都只跑了 tsc + 判据就宣布完成 —— 「类型没错」不等于「页面能用」。
|
||
*/
|
||
|
||
test('★ 撑满:ThreadView 必须被 flex 容器包住', () => {
|
||
const view = src('components/MailView.tsx');
|
||
// 症状:「页面没有撑满」。threadOf 分支是**裸返回**,
|
||
// 不像正常分支那样带 flex-1 min-w-0 min-h-0 ⇒ 高度链断、内容塌陷。
|
||
const m = view.match(/if \(threadOf\)\s*\{[\s\S]{0,400}?return \(([\s\S]{0,400}?)\);/);
|
||
assert.ok(m, '找不到 threadOf 分支');
|
||
assert.match(m[1], /flex-1/,
|
||
'★ threadOf 分支裸返回 ThreadView,没有 flex-1 ⇒ 面板不撑满(用户实测「页面没有撑满」)');
|
||
assert.match(m[1], /min-h-0/, '没有 min-h-0 ⇒ 高度链断');
|
||
assert.match(m[1], /min-w-0/, '没有 min-w-0 ⇒ 宽屏下被内容撑出横向滚动');
|
||
});
|
||
|
||
test('★ 缩放不崩:边不能画在 width={1} 的 overflow-visible svg 里', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
// 症状:「一缩放还崩溃」。<svg width={1} height={1} overflow-visible> 会让浏览器
|
||
// 按**内容包围盒**光栅化:205 节点缩放后包围盒可达 10^5 px,一次滚轮就抽干纹理内存。
|
||
assert.doesNotMatch(tv, /<svg[^>]*overflow-visible/,
|
||
'★ 用了 overflow-visible 的溢出 svg —— 缩放时渲染进程会崩(用户实测)');
|
||
assert.doesNotMatch(tv, /className="absolute overflow-visible[^"]*"\s*width=\{1\}/,
|
||
'★ 1×1 的溢出 svg(同上)');
|
||
// 每条边要有自己的包围盒,svg 才能拿到真实尺寸
|
||
assert.match(tv, /viewBox=\{`0 0 /, '边没有独立包围盒 ⇒ svg 尺寸仍是 1×1');
|
||
assert.match(tv, /dx: number; dy: number;/, 'Edge 没有包围盒字段');
|
||
});
|
||
|
||
test('画布有真实尺寸(绝对定位的子元素需要参照)', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /const bounds = useMemo\(\(\) => boundsOf\(flat\)/,
|
||
'没有算内容包围盒');
|
||
assert.match(tv, /position: 'absolute',[\s\S]{0,200}?width: bounds\.w/,
|
||
'画布没设尺寸 —— 绝对定位的节点没有参照物');
|
||
});
|
||
|
||
/*
|
||
★ 这一格盯的是本轮最贵的两个坑(用户 2026-10-04 实测发现「页面没撑满」)。
|
||
|
||
实测现象:200 节点 / 55 层的树,打开后 zoom=140%、pan.x=-9393,
|
||
内容包围盒 11340x7193 —— 屏幕上只看得到左上角一小块。
|
||
而**手动点「适应」按钮算出来的却是正确的 0.1217**。
|
||
同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
|
||
*/
|
||
|
||
test('★ fit 必须读**扁平**节点,不是根节点数组', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
// ★ nodes 是「根节点数组」(这棵树上只有 1 个根),
|
||
// 直接喂给 fit ⇒ span 260 ⇒ z 顶到上限 1.4 ⇒ 只露左上角。
|
||
assert.match(tv, /nodesRef\.current = flat;/,
|
||
'★ ref 存的是 nodes(根数组)而不是 flat —— fit 只看得到根,缩放算错');
|
||
const m = tv.match(/const flat = useMemo\(\(\) => flatten\(nodes\), \[nodes\]\);[\s\S]{0,200}?nodesRef\.current = flat;/);
|
||
assert.ok(m, 'nodesRef.current = flat 不在 flat 定义之后 —— 顺序反了会拿到上一轮的值');
|
||
// fitTo 不接受参数,只读 ref ⇒ 闭包不会捕获旧数组
|
||
assert.match(tv, /const fitTo = useCallback\(\(\) => \{/, 'fitTo 又开始接收参数了');
|
||
assert.match(tv, /const flat = nodesRef\.current;/, 'fitTo 没有读 ref');
|
||
});
|
||
|
||
test('★ 自动 fit 的 effect 必须依赖 nodes(数据到了要重跑)', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
// 实测:effect 只在 nodes=0 时跑了一次就 return,之后数据到了不再触发
|
||
// ⇒ 永远停在初始 zoom。setFittedFor('') 与 setNodes 同批次会被 React bail out,
|
||
// 所以依赖里必须有 nodes。
|
||
const m = tv.match(/useEffect\(\(\) => \{\s*\n?\s*if \(!nodes\.length \|\| fittedFor === mailID\) return;[\s\S]*?\}, \[([^\]]*)\]\);/);
|
||
assert.ok(m, '找不到自动 fit 的 effect');
|
||
assert.match(m[1], /nodes/, '★ effect 依赖里没有 nodes —— 数据到达后不会重跑(实测卡在初始 zoom)');
|
||
});
|
||
|
||
test('用户拖/缩过之后不再自动重置视口', () => {
|
||
const tv = src('components/ThreadView.tsx');
|
||
assert.match(tv, /const touched = useRef\(false\)/, '没有「用户动过」标记');
|
||
assert.match(tv, /if \(touched\.current\) return;/,
|
||
'ResizeObserver 会把用户拖好的视口反复重置');
|
||
// 手动「适应」要清标记
|
||
assert.match(tv, /touched\.current = false;\s*\n\s*fitTo\(\);/,
|
||
'手动「适应」后没有清标记 ⇒ 之后再也不能自动重算');
|
||
});
|