Archived
Compare commits
3
Commits
8d6a842055
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f7d93d67 | ||
|
|
a77cd71888 | ||
|
|
b3ad8a5a8e |
@@ -35,7 +35,7 @@ bun run build # type-check + vite build(dist/)
|
||||
|
||||
## 使用
|
||||
|
||||
1. 左侧选择接口(6 个,平铺不分组)→ 右侧开 tab
|
||||
1. 左侧选择接口(9 个,平铺不分组)→ 右侧开 tab
|
||||
2. tab 内展开「注意事项」看该接口的实证调研坑文档
|
||||
3. 填 sk-key(每 tab 独立、仅存内存、刷新即重置,不落盘;切换 tab 输入保留)
|
||||
4. 点「发送请求」→ 响应区显示元信息(状态/耗时/字符数)+ 虚拟滚动高亮 JSON
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 给 JSON 预览器加上代码折叠:一次"投影层"的扩展实践
|
||||
|
||||
> 这篇博客记录 bench 项目 JSON 预览组件(`json-text-view.vue`)新增代码块折叠的完整过程:为什么折叠在虚拟滚动上不是"再渲染一个树"的问题,VS Code 和 CodeMirror 6 的折叠机制长什么样,以及把折叠嫁接到"等高行虚拟滚动 + model 层选区"架构上的具体改法。是前一篇《给两万行 JSON 做一个不卡、可选中、还能拖拽选行的预览器》的续篇,目标读者不变——想理解"编辑器类组件"设计思路的工程师(也就是我自己的学习笔记)。
|
||||
|
||||
## 一、问题:两万行的 JSON,少一半是"看不见的括号"
|
||||
|
||||
前一篇的预览器解决了"卡"和"选不中",但 `/api/log/token` 两万行的响应里,真正有信息量的键值对可能只占一半——剩下的一半是结构性的括号行和被嵌套展开的深层字段。读数据时人脑做的事情和折叠功能完全一致:**盯着顶层 key,跳过暂时不关心的子树**。
|
||||
|
||||
所以目标是 VS Code 式的代码块折叠:点击折叠箭头,一个对象/数组收成一行 `{…} N 行`;再点开,原样展开。听起来像"给列表加个树形控件",但对虚拟滚动组件来说,这个需求击中了架构的一个隐藏假设——
|
||||
|
||||
**现有架构里,"可视坐标 = model 坐标"是一个 1:1 的隐含等式。** 行号 gutter 显示的数字、指针几何换算出的行号、选区存储的行号、滚动高度公式里的行数,全部是同一个数。折叠一旦介入,这个等式就断了:model 里第 100 行,可能在屏幕上第 80 行的位置(前面 20 行被折叠藏掉了)。折叠功能的全部工作,就是把这个隐含等式改造成显式映射,并且不能改坏任何一处旧换算。
|
||||
|
||||
## 二、先看标准答案:VS Code 与 CodeMirror 6 怎么做
|
||||
|
||||
动手前先考证了两家主流编辑器的源码级实现,结论是它们共享同一个模式,只是实现层不同。
|
||||
|
||||
**VS Code / Monaco 的折叠是"隐藏行模型"。** 折叠范围就是 `(startLine, endLine)` 行号对;`HiddenRangeModel` 从折叠状态派生出隐藏区间,view model 把隐藏行**从视图流里移除**——不是 CSS 藏起来,是真的不渲染。语义上有一个关键细节:折叠头的 start 行永远可见,隐藏的是 `start+1 .. end`。折叠后 gutter 仍然显示 model 行号(跳号显示),折叠箭头画在首行行号槽,首行行尾追加 `…`。折叠视口上方的块时,`StableEditorScrollState` 会先记下"视口顶部是哪一行(model 行号) + 视口内像素偏移",折叠重排后反推新的 scrollTop——所以你折叠眼前的块,内容纹丝不动。
|
||||
|
||||
**CodeMirror 6 的折叠是"替换装饰"。** 折叠集合是 editor state 的一部分(`StateField<DecorationSet>`),`foldEffect/unfoldEffect` 事务驱动;被折叠的区间用 `Decoration.replace` 收成一个占位 widget(默认 `…`,可自定义)。它和 CM6 自带的 viewport 虚拟渲染天然兼容:替换后可视行数变少,viewport 按新高度算就是了。还有个有趣的细节:**光标移进折叠区时,该折叠自动清除**——编辑器必须保证光标永远落在可见文档上。
|
||||
|
||||
**两者的公共抽象,正好就是我们要的:** 折叠范围(model 坐标)+ 折叠位集合 + 可见性投影(隐藏行不进视图流)。状态永远存 model 坐标,渲染永远是投影——和前一篇选区设计的原则完全同构。
|
||||
|
||||
JSON 查看器生态(Chrome DevTools、JSON Hero 之类)的交互惯例也顺带考证了:折叠箭头在键名处、折叠后行尾显示摘要、默认全展开、没有快捷键依赖。这些直接决定了我们的交互清单。
|
||||
|
||||
## 三、折叠范围怎么算:括号栈,别用缩进
|
||||
|
||||
VS Code 对通用文本用"缩进推导"算折叠范围(逐行比较缩进层级),但对 JSON 有专门的实现:官方 `vscode-json-languageservice` 的 `jsonFolding.ts` 用的是**词法扫描 + 括号配对栈**。
|
||||
|
||||
我们选括号栈,而且比它还简单。依据是 pretty-print JSON 的两条铁律:
|
||||
|
||||
1. **每行至多一个结构括号**(开括号在行尾,或整行就是闭括号)
|
||||
2. **字符串值不会跨行**(`JSON.stringify` 不产生多行字符串,字符串里的 `{` 被引号包裹)
|
||||
|
||||
于是逐行扫描只需判断两端:
|
||||
|
||||
```
|
||||
行尾(trimEnd 后)是 { 或 [ → 开块,压栈 {startLine, kind}
|
||||
行首(首个非空白)是 } 或 ] → 弹栈,产出 fold{ startLine, endLine: closeLine, depth, hiddenLineCount }
|
||||
```
|
||||
|
||||
单遍 O(n),两万行不到 1ms。三个细节值得记:
|
||||
|
||||
**end 是闭括号行本身,不是"闭括号前一行"。** LSP 的 `FoldingRange` 文档写的是"end line 是闭括号前一行",但那是 **0-based 记法**——0-based 的"闭括号前一行"换到 1-based 恰好就是闭括号行。我第一版按字面理解实现了 1-based 的"前一行",结果折叠后每个对象都留下一个孤儿 `}` 行,被自己写的测试当场抓住。混用 0/1-based 行号体系是编辑器领域的经典坑,值得写进注释。
|
||||
|
||||
**单行块不产出折叠。** `{"a": 1}` 占一行,折叠它没有任何意义;只有 `closeLine > startLine + 1` 才产出。
|
||||
|
||||
**非法结构不抛错。** 闭括号比开括号多时弹空栈,跳过该行继续扫。调用方(视图层)会用 `props.json === null` 把非 JSON 响应挡在扫描之外,这里是第二道保险。
|
||||
|
||||
## 四、嫁接到虚拟滚动:八个接入点
|
||||
|
||||
`visibleModel: number[]` 是新增的核心数据结构——**可见性投影**:未被折叠隐藏的 model 行号按序排列。无折叠时它就是 `[1..N]` 的恒等投影;折叠 20 行后,它比 lineList 短 20。有了它,原架构里所有"行数"的消费者逐个换算:
|
||||
|
||||
| 接入点 | 原实现 | 折叠后 |
|
||||
|---|---|---|
|
||||
| 可视窗口计算 | 输入 model 总行数 | 输入 `visibleModel.length`(输出变成可视索引区间) |
|
||||
| 窗口行渲染 | `lineList[index]`,行号 `index+1` | `visibleModel[index]` 反查 model 行号,再取原文 |
|
||||
| 撑高 spacer | model 行数 × 20px | **可视行数 × 20px**(滚动条随之缩短,这个细节很重要) |
|
||||
| gutter 宽度 | model 总行数 | **不变**——跳号显示,最大行号还是 model 的 |
|
||||
| 指针 → 行号 | 几何换算直出 | 先可视索引,再 `visibleModel` 反查(隐藏行不在投影里,天然不会被点到) |
|
||||
| 边缘自动滚动 | 同上 | 同上 |
|
||||
| v-for 的 key | model 行号 | **不变**(Vue 复用节点稳定) |
|
||||
| 选区高亮判断 | model 行号 | **不变**(隐藏行不渲染,高亮自动正确) |
|
||||
|
||||
这张表里最微妙的是 **gutter 宽度不动**:折叠后可见的最大行号没变(还是 model 最后一行),宽度按它算,折叠展开时行号列不会抖动。
|
||||
|
||||
而窗口计算、spacer 高度换用可视行数,意味着**滚动条的长短随折叠实时变化**——折叠一半内容,滚动条变长一倍。这正是 Chrome DevTools / VS Code 的手感。
|
||||
|
||||
## 五、三个真正的难点
|
||||
|
||||
### 5.1 折叠视口上方的块,屏幕为什么会跳
|
||||
|
||||
折叠让"锚点行之前"的可视行数变少,如果 scrollTop 不动,锚点行会向上瞬移。VS Code 的解法(StableEditorScrollState)在等高行体系下可以做得非常轻:
|
||||
|
||||
```
|
||||
折叠前:anchor = 视口顶部那行的 model 行号,delta = scrollTop − 可视索引 × 20
|
||||
折叠中:改折叠位 → 重建 visibleModel
|
||||
折叠后:scrollTop = 锚点的新可视索引 × 20 + delta
|
||||
```
|
||||
|
||||
锚点**必须是 model 行号**——折叠改变的是 model 行的可视索引,不是行本身。另有一个边角:锚点行恰好落在被折叠的区间里时,先把它钳制到折叠头行(它必然可见),补偿基准不失效。jsdom 测这条链路还顺带踩了两个环境坑:fake timers 下 `scrollTop` 是只读的(用 getter/setter 模拟),赋值不触发 scroll 事件(真实浏览器会,测试里手动补派发)。
|
||||
|
||||
### 5.2 选区划过隐藏区,存哪、停在谁身上
|
||||
|
||||
选区真相在 model 层(前一篇的设计),这一原则不变;变化的是**写入时的钳制**:拖拽把选区划进隐藏区,端点停在折叠头行上(Monaco `adjustSelections` 的语义:收缩,而不是自动展开折叠——那是编辑器的需求,查看器收缩更符合阅读直觉)。
|
||||
|
||||
顺带修掉一个存量隐患:原 `syncDomSelection` 里 model 行号和可视索引是混用的(无折叠时恰好等价,所以没暴露)。折叠上线后这个隐患变成必然错位,一并改为"钳制 → 反查可视索引 → 与窗口求交集"。
|
||||
|
||||
### 5.3 非 JSON 响应:折叠特性整体关门
|
||||
|
||||
`props.json === null` 时预览器降级为纯文本展示。raw 文本没有可靠的括号结构(HTML 里的 `{` 会骗过扫描器),所以折叠三件套(扫描、chevron、摘要)全部以 `hasFolds` 门控。这也是测试抓出来的:第一版 chevron 的占位 span 在纯文本行上也渲染了,行高虽不受影响,但"非 JSON 页面出现幽灵元素"不可接受。
|
||||
|
||||
## 六、交互层:抄谁,不抄谁
|
||||
|
||||
按生态惯例取舍后的清单:
|
||||
|
||||
- **抄 VS Code**:chevron 在 gutter 侧(hover 显示、已折叠常显)、折叠后行尾摘要、跳号显示 model 行号
|
||||
- **抄 JSON 工具系**:摘要带数量——但我们显示的是"隐藏行数"(`{…} 8 行`)而不是 keys 数。理由:查看器的对象可能巨大,"8 行"直接预告了展开后的篇幅,对"要不要展开"这个决策更实用
|
||||
- **不抄**:快捷键(查看器没有键盘编辑流)、hover 展开预览(无生态先例)、折叠态持久化(工具"刷新即重置"的哲学)、默认折叠(生态一致地默认全展开)
|
||||
- **自创一处**:"折叠全部"只收 depth=1 的顶层块——日志 JSON 最常见的诉求是"把每条日志收成一行",嵌套折叠位保留不动,展开顶层即可恢复原状
|
||||
|
||||
## 七、复盘:投影层的可扩展性,是被验证出来的
|
||||
|
||||
这次扩展让前一篇结尾的原则经受了一次实战检验:
|
||||
|
||||
**"真相只放一份,放在离 DOM 最远的那层"——但真相不止一个坐标系。**
|
||||
|
||||
model 坐标系(行号、内容、选区)和 view 坐标系(可视索引、scrollTop、spacer 高度)在无折叠时是同一个,折叠功能把它们拆开并用 `visibleModel` 显式连接。整个扩展过程没有动 model 层一行代码,所有改动集中在"投影的生成规则"和"两个坐标系的换算点"上——八个接入点里,四个是"把 model 行数换成可视行数"的一行改动,两个是反查方向的颠倒,真正的新逻辑只有折叠事务和钳制。
|
||||
|
||||
测试方面有个值得一提的节奏:第一版实现带着"end = 闭括号前一行"的语义偏差上线,**单测当场红了两条**;视图层第一版带着 SFC 里的非法 `continue` 编译失败,**vitest 的编译环节先于 type-check 报错**。纯函数先行、TDD 锁死语义,这套流程在这类"几何换算密集"的组件上回报极高——坐标系换算的 bug 靠肉眼 review 几乎不可能发现,但一个精确的期望值数组就能锁死它。
|
||||
|
||||
如果要继续往编辑器方向走,折叠是继选区之后第二块拼图。下一步的自然延伸:折叠态进 store 的序列化(Monaco/CM6 都支持持久化)、按 depth 分层折叠(实现已留好 `depth` 字段)、以及把"行号选区"升级为"(行,列) 坐标"——那时 model 层才真正需要为列而设计。
|
||||
|
||||
---
|
||||
|
||||
## 附:代码与测试索引
|
||||
|
||||
- `src/lib/json-text-processor.ts` — 纯函数层新增:`scanJsonFolds`(括号栈扫描)、`buildVisibleProjection`(可见性投影)、`findVisibleIndex`(行号反查)、`clampLineToVisible`(选区钳制)
|
||||
- `src/components/json-text-view.vue` — 视图层:投影接线(八接入点)、折叠事务(scrollTop 补偿)、chevron 与行尾摘要
|
||||
- `src/components/endpoint-panel.vue` — meta 行「折叠全部 / 展开全部」按钮
|
||||
- `docs/JSON预览器代码折叠调研.md` — 机制考证全文(VS Code/CM6 源码结论、交互惯例清单)
|
||||
- 测试:纯函数 20 用例 + 视图层 6 用例(折叠渲染、chevron 交互、选区隔离、补偿无跳变、状态重置、非 JSON 降级)
|
||||
@@ -0,0 +1,128 @@
|
||||
# JSON 预览器代码折叠调研
|
||||
|
||||
> 如何在 bench 的虚拟滚动 JSON 预览器里实现 VS Code 式的代码块折叠——主流实现机制考证 + 对现有架构的嫁接方案。
|
||||
>
|
||||
> 调研日期:2026-09-09 · 方法:VS Code / CodeMirror 6 源码级考证 + JSON 查看器生态惯例 + 现有代码精读
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与结论
|
||||
|
||||
`/api/log/token` 的响应 pretty-print 后 2~4MB、约两万行,折叠对象/数组能显著降低阅读成本。现有预览器(`json-text-view.vue` + `json-text-processor.ts`)架构:
|
||||
|
||||
- 纯函数 processor 切行 + 正则浅切词高亮(仅可视窗口行执行)
|
||||
- 等高行(20px)虚拟滚动:spacer 撑高 + translateY 窗口偏移 + overscan + trailing 节流
|
||||
- model 层行选区状态机(锚点行号,DOM Selection 只是同步目标)
|
||||
|
||||
**结论:完全可行,且现有架构条件优越。** 主流方案的本质是同一个模式——**折叠范围(model 行号对)+ 折叠位集合 + 可见性投影(隐藏行从视图流移除)**。现有代码"选区存 model 行号、渲染走投影"已经是 model/view 分离,加折叠只是把"目前可视坐标 = model 坐标的 1:1 映射"变成"折叠时跳过隐藏行的映射"。计算方式用**括号栈行级扫描**(VS Code 官方 JSON 语言服务同款),2 万行 O(n) 扫描不到 1ms。
|
||||
|
||||
## 2. 主流实现机制
|
||||
|
||||
### 2.1 VS Code / Monaco(源码考证)
|
||||
|
||||
| 关注点 | 机制 | 出处 |
|
||||
|--------|------|------|
|
||||
| 数据结构 | `FoldingRange` = `(startLine, endLine)` 行号对;`FoldingRegions` 平行数组(`Uint32Array` ×2)+ BitField 折叠位 | `foldingRanges.ts` |
|
||||
| 核心语义 | `hidesLine(line) = start < line ≤ end`——**折叠头行(start)永远可见**,隐藏 `start+1 .. end`(闭括号行也在隐藏区) | 同上 |
|
||||
| 视图投影 | `HiddenRangeModel` 派生隐藏区间,view model 把隐藏行**从视图流移除**(非 CSS 遮盖);视图行号重排,gutter 显示 **model 行号**(跳号显示) | `hiddenRangeModel.ts` |
|
||||
| 视觉呈现 | chevron 画在首行 gutter;首行行尾追加 `…` 摘要(`afterContentClassName: 'inline-folded'`) | `foldingDecorations.ts` |
|
||||
| gutter 交互 | 点击 chevron 图标本身才折叠/展开;控件默认 `mouseover`(悬停显示,已折叠区常显) | `folding.ts` |
|
||||
| 滚动稳定 | `StableEditorScrollState`:折叠前记录视口顶部锚点(**model 行号** + 视口内像素偏移),折叠后反推新 scrollTop——折叠视口上方的块时视口不跳变 | `stableEditorScroll.ts` |
|
||||
|
||||
### 2.2 CodeMirror 6(源码 + 官方文档)
|
||||
|
||||
- 折叠态是 editor state 的一部分:`foldState = StateField<DecorationSet>`,`foldEffect/unfoldEffect`(doc offset 区间)事务驱动
|
||||
- 折叠 = `Decoration.replace({widget})` 把区间收成一个占位 widget(默认 `…`,可自定义「N lines」);**点击占位符展开**
|
||||
- 与自身 viewport 虚拟渲染天然兼容:替换后可视行数减少,viewport 按行块计算自然适配
|
||||
- **选区保护**:`foldState.update` 末尾检查选区头——落进折叠区时该折叠自动清除(光标移入即展开)
|
||||
|
||||
### 2.3 JSON 查看器生态(Chrome DevTools / Firefox JSONView / JSON Hero)
|
||||
|
||||
- 折叠锚点都在**键名行**,摘要写在折叠行行尾(`▸ {…}` / `N keys` / `N items`)
|
||||
- 默认**全展开**;树形工具无行号槽、无快捷键;编辑器系才有快捷键(`Ctrl+K Ctrl+[` 系)
|
||||
- 无 hover 展开预览的先例(摘要数字已够)
|
||||
|
||||
## 3. 折叠范围计算:括号栈(选型定论)
|
||||
|
||||
| 路线 | 原理 | JSON pretty-print 适用性 |
|
||||
|------|------|--------------------------|
|
||||
| 缩进推导(VS Code 通用 fallback) | 逐行缩进层级栈式配对 | 需处理空行归属、offSide 语义,JSON 上是绕路 |
|
||||
| **括号栈行级扫描**(VS Code JSON 语言服务同款) | 行尾 `{`/`[` 开块压栈;行首 `}`/`]` 收栈 | **零误判且更便宜**(官方 `jsonFolding.ts` 同款) |
|
||||
|
||||
选括号栈。pretty-print 后两条铁律:**每行至多一个括号、缩进即结构**(`JSON.stringify` 产物中字符串值不会跨行,行尾判定安全)。规则:
|
||||
|
||||
- 开块行:行尾为 `{` 或 `[` → 记录 `start`,压栈
|
||||
- 闭块行:行首 token 为 `}` 或 `]` → 弹栈,产出 `{start, end: 闭括号行 − 1}`
|
||||
- **`end = 闭括号前一行`**(与 VS Code LSP `FoldingRange`、jsonFolding 惯例一致)
|
||||
- **单行块不产出折叠**(`closeLine > start + 1` 才产出,同 jsonFolding 的 `line > startLine + 1`)
|
||||
- 顺带记录 `depth`(顶层块 = 1)与 `kind`(object/array),供摘要与「折叠全部」用
|
||||
|
||||
## 4. 嫁接到现有架构:改动点映射
|
||||
|
||||
现有代码的隐藏前提是**可视坐标 = model 坐标**。加折叠 = 显式建立映射。逐点:
|
||||
|
||||
| 现有代码 | 现状 | 折叠后 |
|
||||
|----------|------|--------|
|
||||
| `lineList` | model 全量行数组 | 不变(model 层真相) |
|
||||
| `computeVisibleRowWindow(lineList.length, …)` | 输入 model 行数 | 输入 `visibleModel.length`;输出为**可视索引**窗口 |
|
||||
| `visibleLineList` 行号 `index + 1` | model 行号直取 | `visibleModel[visIdx]` 反查 model 行号;gutter 显示 model 行号(跳号,VS Code 惯例) |
|
||||
| `totalHeightPx` | `lineList.length × 20` | `visibleModel.length × 20` |
|
||||
| `gutterWidth` | model 总行数 | **仍用 model 总行数**(跳号显示最大行号不变) |
|
||||
| `resolveLineFromPointer` | 几何换算直出 model 行号 | 先出可视索引,反查 model 行号(选区保持 model 坐标;几何反查天然不会命中隐藏行) |
|
||||
| 边缘自动滚动 `lineAtEdge` | model 行号 | 同上反查 |
|
||||
| v-for `:key` | model 行号 | 不变(Vue 复用稳定) |
|
||||
| `isLineSelected` | model 行号判断 | 不变(隐藏行不渲染,高亮自动正确) |
|
||||
|
||||
**新增 model 层状态**:
|
||||
|
||||
1. `folds: JsonFoldRange[]`——扫描产物,按 start 升序天然嵌套
|
||||
2. `collapsedSet: Set<startLine>`——已折叠集合(`isCollapsed` O(1))
|
||||
3. `visibleModel: number[]`——投影数组:未被隐藏的 model 行号按序排列(2 万行 ≈ 80KB;全量重建 <1ms,先全量,不做局部增量)
|
||||
|
||||
## 5. 三个关键难点与解法
|
||||
|
||||
### 5.1 scrollTop 跳变补偿
|
||||
|
||||
VS Code `StableEditorScrollState` 的等高行等价实现:
|
||||
|
||||
```
|
||||
折叠事务前:anchorModel = visibleModel[floor(scrollTop / 20)]
|
||||
deltaPx = scrollTop − visIdx × 20 (scrollTop = 0 时跳过)
|
||||
折叠事务中:改 collapsedSet → 全量重建 visibleModel
|
||||
折叠事务后:newScrollTop = findVisibleIndex(anchorModel) × 20 + deltaPx
|
||||
```
|
||||
|
||||
锚点必须是 **model 行号**——折叠只改变 model 行的可视索引,不改变行本身。
|
||||
|
||||
### 5.2 选区与隐藏行冲突
|
||||
|
||||
采用 **Monaco 语义(收缩)** 而非 CM6 语义(自动展开)——查看器无编辑,收缩更符合阅读直觉:
|
||||
|
||||
- 选区端点落入隐藏区(`start < line ≤ end`)→ 钳制到折叠头行 `start`
|
||||
- 复制语义不受损:选区提取按 model 行号在 `lineList` 纯数据操作,折叠区内内容依然完整可复制
|
||||
- `syncDomSelection` 需改为:选区端点先钳制到可见,再转可视索引,与可视窗口求交集后索引 DOM(原实现 model/可视坐标混用,折叠后会错位)
|
||||
|
||||
### 5.3 非 JSON 响应降级与状态重置
|
||||
|
||||
- `props.json === null`(非 JSON 降级为纯文本)→ **不扫描、无 chevron、无摘要**:raw 文本无可靠括号结构(HTML 里的 `{` 会误判),与现有降级链路一致
|
||||
- **切换响应(`props.json` 变化)时必须重置 `collapsedSet`**——新数据的 folds 是新扫描结果,旧行号语义已失效
|
||||
|
||||
## 6. 交互清单(按生态惯例取舍)
|
||||
|
||||
| 项 | 决策 | 依据 |
|
||||
|----|------|------|
|
||||
| gutter chevron(hover 显 / 已折叠常显) | ✅ | VS Code `mouseover` 控件惯例 |
|
||||
| 行尾摘要 `{…} N 行`,点击展开 | ✅ | 编辑器系 `…` + JSON 工具系行数计数的并存;行数可预判展开长度,适合日志大 JSON |
|
||||
| 全部折叠(收 depth=1 顶层)/ 全部展开,放响应 meta 行 | ✅ | 2 万行场景最高频操作 |
|
||||
| 默认全展开 | ✅ | 生态一致,不自作主张 |
|
||||
| 快捷键 `Ctrl+K Ctrl+[` 系 | ❌ 暂缓 | 查看器无键盘编辑流 |
|
||||
| hover 展开预览 | ❌ | 生态无先例 |
|
||||
| 分层折叠(折叠到 depth N) | 🕐 后议 | 可用「折叠到第 N 层」一键替代 |
|
||||
| 折叠态持久化 | ❌ | bench「无持久化、刷新即重置」哲学 |
|
||||
|
||||
## 7. 实施拆分
|
||||
|
||||
1. **纯函数先行**(`json-text-processor.ts` + 单测):`scanJsonFolds` / `buildVisibleProjection` / `findVisibleIndex` / `clampLineToVisible`
|
||||
2. **视图接线**(`json-text-view.vue`):投影层改造上表 8 接入点 + chevron + 行尾摘要 + scrollTop 补偿事务 + `defineExpose`(collapseAll/expandAll/hasFolds)
|
||||
3. **meta 行按钮**(`endpoint-panel.vue`):折叠全部 / 展开全部
|
||||
4. **回归**:全量测试 + 新增折叠用例(重点:折叠后滚动、嵌套折叠不重复计数、补偿无跳变)
|
||||
+13
-10
@@ -8,21 +8,24 @@
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 6 个接口,服务 karpool 拼车项目的接口调试与换算验证。
|
||||
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 9 个接口,服务 karpool 拼车项目的接口调试与换算验证。
|
||||
|
||||
**接口清单**(数据源:`docs/NewAPI-sk-key接口文档.md`,按此顺序展示,不分组):
|
||||
|
||||
| 序 | 路径 | 鉴权 | 响应特征 |
|
||||
|---|------|------|---------|
|
||||
| 0 | `GET /api/status` | 无(完全公开) | 站点配置与计费参数:`quota_per_unit`(换算比率权威来源)、`quota_display_type`、汇率等 |
|
||||
| 1 | `GET /v1/dashboard/billing/subscription` | TokenAuth(严格) | 小 JSON,美元额度(无限额时 1 亿占位) |
|
||||
| 2 | `GET /v1/dashboard/billing/usage` | TokenAuth(严格) | 小 JSON,累计美分 |
|
||||
| 3 | `GET /v1/models` | TokenAuth(严格) | 中等 JSON,模型数组 |
|
||||
| 4 | `GET /api/usage/token` | TokenAuthReadOnly(宽松) | 小 JSON,累计 quota |
|
||||
| 5 | `GET /api/log/token` | TokenAuthReadOnly(宽松) | **大响应**:最近 1000 条日志,pretty-print 2~4MB |
|
||||
| 0 | `GET /api/user/groups` | 无(完全公开) | 用户分组与倍率列表;未启用分组时为空对象 |
|
||||
| 1 | `GET /api/perf-metrics/summary` | 公开门控(默认无需凭证) | 全站模型服务质量:延迟/成功率/TPS/小时级序列;`hours` 参数 |
|
||||
| 2 | `GET /api/rankings` | 公开门控(默认无需凭证) | 全站模型用量排行:名次/份额/环比增长;`period` 参数 |
|
||||
| 3 | `GET /api/status` | 无(完全公开) | 站点配置与计费参数:`quota_per_unit`(换算比率权威来源)、`quota_display_type`、汇率等 |
|
||||
| 4 | `GET /v1/dashboard/billing/subscription` | TokenAuth(严格) | 小 JSON,美元额度(无限额时 1 亿占位) |
|
||||
| 5 | `GET /v1/dashboard/billing/usage` | TokenAuth(严格) | 小 JSON,累计美分 |
|
||||
| 6 | `GET /v1/models` | TokenAuth(严格) | 中等 JSON,模型数组 |
|
||||
| 7 | `GET /api/usage/token` | TokenAuthReadOnly(宽松) | 小 JSON,累计 quota |
|
||||
| 8 | `GET /api/log/token` | TokenAuthReadOnly(宽松) | **大响应**:最近 1000 条日志,pretty-print 2~4MB |
|
||||
|
||||
> 0 完全公开(无需任何凭证,面板隐藏 sk-key 输入行);4、5 宽松鉴权(key 过期/耗尽仍可查);1~3 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
|
||||
> 4 与 2 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。0 是换算比率的权威来源(quota_per_unit 可被管理员修改,500000 只是默认值)。
|
||||
> 0~3 完全公开或默认公开(面板隐藏 sk-key 输入行);7、8 宽松鉴权(key 过期/耗尽仍可查);4~6 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
|
||||
> 7 与 5 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。3 是换算比率的权威来源(quota_per_unit 可被管理员修改,500000 只是默认值)。1、2 是「全站视角」数据——单个 sk-key 日志看不到的服务质量与用量排行,服务模型健康度判断。
|
||||
|
||||
---
|
||||
|
||||
@@ -53,7 +56,7 @@
|
||||
无分组无编号,按清单顺序)
|
||||
```
|
||||
|
||||
- **左 sider**(~300px):接口列表,**不分组、不编号**,按 §1 清单顺序平铺 6 个路径;选中态高亮
|
||||
- **左 sider**(~300px):接口列表,**不分组、不编号**,按 §1 清单顺序平铺 9 个路径;选中态高亮
|
||||
- **右主区**:`t-tabs` 顶部,激活 tab 内容 = 请求行(method + 路径 + 鉴权徽标)→ 调试行(sk-key 输入 + 发送按钮)→ 响应区(元信息 + 虚拟滚动 JSON)
|
||||
- 暗色默认(TDesign dark theme + CSS 变量)
|
||||
|
||||
|
||||
@@ -3,17 +3,20 @@ import { describe, expect, it } from 'vitest';
|
||||
import { endpointList, findEndpointByPath } from './endpoints';
|
||||
|
||||
describe('endpointList 数据完整性', () => {
|
||||
it('恰好 6 个接口(设计文档 §1 定案的对接清单)', () => {
|
||||
expect(endpointList).toHaveLength(6);
|
||||
it('恰好 9 个接口(设计文档 §1 定案的对接清单)', () => {
|
||||
expect(endpointList).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
|
||||
const pathList = endpointList.map((endpoint) => endpoint.path);
|
||||
expect(new Set(pathList).size).toBe(6);
|
||||
expect(new Set(pathList).size).toBe(9);
|
||||
});
|
||||
|
||||
it('顺序与用户指定清单一致', () => {
|
||||
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
|
||||
'/api/user/groups',
|
||||
'/api/perf-metrics/summary',
|
||||
'/api/rankings',
|
||||
'/api/status',
|
||||
'/v1/dashboard/billing/subscription',
|
||||
'/v1/dashboard/billing/usage',
|
||||
@@ -31,8 +34,11 @@ describe('endpointList 数据完整性', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('鉴权模式分布正确(公开 1 + 严格 3 + 宽松 2)', () => {
|
||||
it('鉴权模式分布正确(公开 4 + 严格 3 + 宽松 2)', () => {
|
||||
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
|
||||
'none',
|
||||
'none',
|
||||
'none',
|
||||
'none',
|
||||
'strict',
|
||||
'strict',
|
||||
|
||||
+80
-1
@@ -2,6 +2,9 @@
|
||||
* 对接接口的静态元数据
|
||||
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
|
||||
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
|
||||
*
|
||||
* 鉴权三态:none = 完全公开(无需任何凭证)→ strict = TokenAuth → lenient = TokenAuthReadOnly
|
||||
* 公开接口组(/api/user/groups、/api/perf-metrics/summary、/api/rankings、/api/status)排在清单最前
|
||||
*/
|
||||
|
||||
export type AuthMode = 'strict' | 'lenient' | 'none';
|
||||
@@ -24,6 +27,82 @@ export interface EndpointDefinition {
|
||||
}
|
||||
|
||||
export const endpointList: readonly EndpointDefinition[] = [
|
||||
{
|
||||
path: '/api/user/groups',
|
||||
method: 'GET',
|
||||
authMode: 'none',
|
||||
authMiddlewareName: '无(公开接口)',
|
||||
responseSummary: '用户分组与倍率列表(未启用分组时为空对象)',
|
||||
notes: [
|
||||
'完全公开:无任何鉴权中间件(本工具对接清单中三个免凭证接口之一)',
|
||||
'返回站点配置的用户分组(default / vip 等)及其倍率;站点未启用分组时 data 为空对象',
|
||||
'用途:解释「同一模型为什么计费不同」——分组倍率会影响同一模型的实际消耗',
|
||||
'与 /api/pricing 的 group_ratio 字段互补:本接口是分组清单,pricing 是模型定价',
|
||||
],
|
||||
sampleResponse: { success: true, message: '', data: {} },
|
||||
},
|
||||
{
|
||||
path: '/api/perf-metrics/summary',
|
||||
method: 'GET',
|
||||
authMode: 'none',
|
||||
authMiddlewareName: 'HeaderNavModulePublicOrUserAuth(默认公开)',
|
||||
responseSummary: '全站模型服务质量汇总(延迟/成功率/TPS)',
|
||||
notes: [
|
||||
'公开接口:管理员未在 HeaderNav 中开启 pricing 模块的 requireAuth 时无需任何凭证',
|
||||
'query 参数 hours:统计窗口小时数(默认 24)',
|
||||
'返回每个模型的 avg_latency_ms(平均延迟)、success_rate(成功率 %)、avg_tps(每秒 token 数)、recent_success_series(按小时采样的成功率时间序列)',
|
||||
'对拼车工具的意义:单个 sk-key 日志看不到全局服务质量,本接口补上「模型当前健康度」视角——伙伴反馈「慢/报错多」时用它定位是模型问题还是自身问题',
|
||||
'明细版 /api/perf-metrics?model=xxx 需 query 参数(model 必填,另支持 group/hours),面板暂不支持 query 输入,可用上方注意事项了解参数后经代理手动拼接',
|
||||
],
|
||||
sampleResponse: {
|
||||
success: true,
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
model_name: 'gpt-5.6-sol',
|
||||
avg_latency_ms: 16823,
|
||||
success_rate: 94.73,
|
||||
avg_tps: 25.81,
|
||||
recent_success_series: [
|
||||
{ ts: 1788872400, success_rate: 100 },
|
||||
{ ts: 1788876000, success_rate: 99.16 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/rankings',
|
||||
method: 'GET',
|
||||
authMode: 'none',
|
||||
authMiddlewareName: 'HeaderNavModulePublicOrUserAuth(默认公开)',
|
||||
responseSummary: '全站模型用量排行(名次/份额/增长)',
|
||||
notes: [
|
||||
'公开接口:同 perf-metrics,受 HeaderNav 模块开关控制,默认无需凭证',
|
||||
'query 参数 period:统计周期(默认 week)',
|
||||
'返回每个模型的 rank(名次)、previous_rank(上期名次)、total_tokens(总用量)、share(份额)、growth_pct(环比增长 %)',
|
||||
'对拼车工具的意义:看全站都在用什么模型——车长选「主推模型」(便宜且稳定)的数据依据;配合 perf-metrics 的成功率可识别衰退模型',
|
||||
],
|
||||
sampleResponse: {
|
||||
success: true,
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
rank: 1,
|
||||
previous_rank: 2,
|
||||
model_name: 'gpt-5.6-sol',
|
||||
vendor: 'OpenAI',
|
||||
vendor_icon: 'OpenAI',
|
||||
category: 'all',
|
||||
total_tokens: 1583845480,
|
||||
share: 0.6549,
|
||||
growth_pct: 60.4253,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/status',
|
||||
method: 'GET',
|
||||
@@ -31,7 +110,7 @@ export const endpointList: readonly EndpointDefinition[] = [
|
||||
authMiddlewareName: '无(公开接口)',
|
||||
responseSummary: '站点配置与计费参数(含 quota_per_unit)',
|
||||
notes: [
|
||||
'完全公开:无任何鉴权,不持有 sk-key 也能访问(本工具对接清单中唯一)',
|
||||
'完全公开:无任何鉴权,不持有 sk-key 也能访问(对接清单公开接口组之一)',
|
||||
'quota_per_unit 是 QuotaPerUnit 当前生效值(默认 500000,NewAPI 以 $0.002 / 1K tokens 的基准定价为锚点设计)——quota ÷ 该值 = 美元,换算比率的唯一权威来源,管理员可改,不要硬编码',
|
||||
'想用 billing 接口(subscription / usage)反推换算比率不可靠:它们内部就是拿 quota ÷ QuotaPerUnit 换算的,站点改了配置它们会跟着变,永远「吻合」——比率唯一权威只有本接口的 quota_per_unit',
|
||||
'quota_display_type 影响 /v1/dashboard/billing/subscription 与 /v1/dashboard/billing/usage 的语义:USD 直接是美元;CNY 先 ÷QuotaPerUnit 再 ×usd_exchange_rate;TOKENS 是原始 token 数',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { findEndpointByPath } from '@/api/endpoints';
|
||||
import { useManualEndpointQuery } from '@/api/endpoint-queries';
|
||||
@@ -58,6 +58,28 @@ function handleSend(): void {
|
||||
sendRequest();
|
||||
}
|
||||
|
||||
/* ---------- 折叠全部 / 展开全部(JsonTextView defineExpose 通道) ---------- */
|
||||
|
||||
const jsonTextViewRef = ref<InstanceType<typeof JsonTextView> | null>(null);
|
||||
const foldable = ref(false);
|
||||
|
||||
// hasFolds 是 JsonTextView 内部的 computed,经 watch 桥接为面板侧响应式标志
|
||||
watch(
|
||||
() => jsonTextViewRef.value?.hasFolds,
|
||||
(value) => {
|
||||
foldable.value = value === true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function collapseAllFolds(): void {
|
||||
jsonTextViewRef.value?.collapseAll();
|
||||
}
|
||||
|
||||
function expandAllFolds(): void {
|
||||
jsonTextViewRef.value?.expandAll();
|
||||
}
|
||||
|
||||
/* ---------- 响应区操作:复制 / 下载 ---------- */
|
||||
|
||||
const {
|
||||
@@ -150,6 +172,12 @@ function downloadResponseAsFile(): void {
|
||||
{{ responseData.networkError }}
|
||||
</span>
|
||||
<span class="meta-spacer"></span>
|
||||
<t-button v-if="foldable" size="small" variant="outline" @click="collapseAllFolds">
|
||||
折叠全部
|
||||
</t-button>
|
||||
<t-button v-if="foldable" size="small" variant="outline" @click="expandAllFolds">
|
||||
展开全部
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="clipboardSupported"
|
||||
size="small"
|
||||
@@ -160,7 +188,7 @@ function downloadResponseAsFile(): void {
|
||||
</t-button>
|
||||
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
|
||||
</div>
|
||||
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
|
||||
<JsonTextView ref="jsonTextViewRef" :json="responseData.json" :raw-text="responseData.text" />
|
||||
</section>
|
||||
<section v-else-if="!hasSent" class="panel-section response-placeholder">
|
||||
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
|
||||
|
||||
@@ -165,3 +165,158 @@ describe('json-text-view 虚拟滚动', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- 代码块折叠(实施计划 §6 视图层测试) ---------- */
|
||||
|
||||
describe('json-text-view 代码块折叠', () => {
|
||||
/** 9 行单条目 JSON(含嵌套对象):
|
||||
* 1 { / 2 "data": [ / 3 { / 4 "id": 1 / 5 "model_name": "m" / 6 "quota": 2500 / 7 } / 8 ] / 9 }
|
||||
* folds: start=3/end=7(depth=3)、start=2/end=8(depth=2)、start=1/end=9(depth=1)
|
||||
*/
|
||||
const singleEntryJson = { data: [{ id: 1, model_name: 'm', quota: 2500 }] };
|
||||
|
||||
it('全部折叠:spacer 高度缩短为折叠头行数 × 20px,gutter 跳号显示 model 行号,折叠头行带摘要', async () => {
|
||||
const wrapper = mount(JsonTextView, {
|
||||
props: { json: singleEntryJson, rawText: '' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 180px'); // 9 行
|
||||
|
||||
await (wrapper.vm as unknown as { collapseAll: () => void }).collapseAll();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// depth=1 顶层块折叠后:可视行只剩折叠头(行 1),其余全部隐藏
|
||||
const lines = wrapper.findAll('.line');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 20px');
|
||||
expect(lines[0]!.find('.gutter').text()).toBe('1'); // model 行号
|
||||
// 折叠头行尾摘要:对象块 → {…} + 隐藏行数 8
|
||||
expect(lines[0]!.find('.fold-summary').text()).toContain('{…}');
|
||||
expect(lines[0]!.find('.fold-summary').text()).toContain('8 行');
|
||||
// chevron 已折叠态常显
|
||||
expect(lines[0]!.find('.fold-chevron').classes()).toContain('fold-chevron-collapsed');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('点击 chevron 折叠中间块:隐藏区间行从 DOM 消失、折叠头保留、再点击展开恢复', async () => {
|
||||
const wrapper = mount(JsonTextView, {
|
||||
props: { json: singleEntryJson, rawText: '' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const findLineByGutter = (no: string) =>
|
||||
wrapper.findAll('.line').find((line) => line.find('.gutter').text() === no);
|
||||
|
||||
// 行 2 是数组折叠头("data": [),点击其 chevron 折叠 2..8
|
||||
const line2 = findLineByGutter('2')!;
|
||||
expect(line2.find('.fold-chevron').exists()).toBe(true);
|
||||
await line2.find('.fold-chevron').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const gutters = wrapper.findAll('.line').map((line) => line.find('.gutter').text());
|
||||
expect(gutters).toEqual(['1', '2', '9']); // (2, 8] 全部隐藏(含行 8 闭括号)
|
||||
expect(findLineByGutter('2')!.find('.fold-summary').text()).toContain('[…]');
|
||||
|
||||
// 再点击 chevron 展开:恢复全量 9 行
|
||||
await findLineByGutter('2')!.find('.fold-chevron').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.findAll('.line')).toHaveLength(9);
|
||||
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 180px');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('chevron / 摘要点击不触发 gutter 选区(mousedown.stop 隔离)', async () => {
|
||||
const wrapper = mount(JsonTextView, {
|
||||
props: { json: singleEntryJson, rawText: '' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const line2 = wrapper.findAll('.line').find((line) => line.find('.gutter').text() === '2')!;
|
||||
|
||||
await line2.find('.fold-chevron').trigger('click');
|
||||
await document.body.dispatchEvent(new MouseEvent('mouseup'));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// 无任何行进入选中态
|
||||
expect(wrapper.findAll('.line-selected')).toHaveLength(0);
|
||||
expect(window.getSelection()!.toString()).toBe('');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('滚动位置折叠补偿:折叠视口上方的块后,视口顶部锚点行的内容不变(无跳变)', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// 60 条目 ≈ 308 行(4 行外壳 + 60×5 行/条目 + 1),滚动目标有充足纵深
|
||||
const text = makeJsonText(60);
|
||||
const wrapper = mount(JsonTextView, {
|
||||
props: { json: JSON.parse(text), rawText: text },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const container = wrapper.find('.json-text-view');
|
||||
|
||||
// 滚到 2000px(可视行 100 起)——深处条目中部
|
||||
// fake timers 下 jsdom 不派发布局滚动,scrollTop 需用可写 getter/setter 模拟真实行为
|
||||
let currentScrollTop = 0;
|
||||
Object.defineProperty(container.element, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => currentScrollTop,
|
||||
set: (value: number) => {
|
||||
currentScrollTop = value;
|
||||
},
|
||||
});
|
||||
container.element.scrollTop = 2000;
|
||||
await container.trigger('scroll');
|
||||
vi.advanceTimersByTime(34); // 节流补执行
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const firstGutterBefore = wrapper.findAll('.line')[0]!.find('.gutter').text();
|
||||
expect(Number(firstGutterBefore)).toBeGreaterThan(95); // 前提:锚点在深处
|
||||
|
||||
// 折叠一个视口上方的顶层块:第二条目 { 在行 8(depth=1),8 ≤ 100 在视口上方
|
||||
await (wrapper.vm as unknown as { toggleFold: (line: number) => Promise<void> }).toggleFold(
|
||||
8,
|
||||
);
|
||||
// 补偿把 scrollTop 写为 1920;真实浏览器会因 scrollTop 变化派发 scroll 事件,
|
||||
// 这里手动补派发 + 推进节流 trailing,让 scrollTopPx 跟随到补偿后的位置
|
||||
await container.trigger('scroll');
|
||||
vi.advanceTimersByTime(34);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// 补偿断言:视口顶部仍停在锚点行(model 100,可视索引 96)——内容不跳变
|
||||
const firstGutterAfter = wrapper.findAll('.line')[0]!.find('.gutter').text();
|
||||
expect(firstGutterAfter).toBe(firstGutterBefore);
|
||||
expect(container.element.scrollTop).toBe(1920); // 96 × 20px(隐藏 4 行后的锚点位置)
|
||||
wrapper.unmount();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('props.json 变化:折叠态清空,恢复全量渲染', async () => {
|
||||
const wrapper = mount(JsonTextView, {
|
||||
props: { json: singleEntryJson, rawText: '' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await (wrapper.vm as unknown as { collapseAll: () => void }).collapseAll();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.findAll('.line')).toHaveLength(1); // 折叠生效
|
||||
|
||||
await wrapper.setProps({ json: { alpha: 1 }, rawText: '' });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// 新数据(3 行)不应继承旧折叠位:全量渲染
|
||||
const lines = wrapper.findAll('.line');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 60px');
|
||||
expect(lines.map((line) => line.find('.gutter').text())).toEqual(['1', '2', '3']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('非 JSON 响应:无 chevron、无摘要(折叠特性整体关闭)', () => {
|
||||
const wrapper = mount(JsonTextView, {
|
||||
props: { json: null, rawText: '<html>502</html>' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
expect(wrapper.findAll('.fold-chevron')).toHaveLength(0);
|
||||
expect(wrapper.findAll('.fold-summary')).toHaveLength(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,19 +8,26 @@
|
||||
* 实现要点:
|
||||
* - DOM 恒定:只渲染可视窗口 ± overscan 的行(json-text-processor.computeVisibleRowWindow)
|
||||
* - 懒高亮:仅对窗口内行做 token 切词(结构化 span),转义与着色在模板层,无 v-html
|
||||
* - 滚动条:撑高度用总行数 × 行高的占位 div,窗口偏移用 translateY
|
||||
* - 滚动条:撑高度用「可视行数 × 行高」的占位 div,窗口偏移用 translateY
|
||||
* - 代码块折叠:括号栈扫描 fold + collapsedSet 投影剔除隐藏行;折叠事务带 scrollTop 补偿
|
||||
* (锚点 = 视口顶部 model 行号,折叠视口上方块时视口不跳变);选区端点写前钳制
|
||||
*/
|
||||
|
||||
import { useElementSize, useThrottleFn } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
buildJsonLineSegments,
|
||||
buildVisibleProjection,
|
||||
clampLineToVisible,
|
||||
computeGutterWidth,
|
||||
computeVisibleRowWindow,
|
||||
findVisibleIndex,
|
||||
formatJsonOrRawText,
|
||||
JSON_VIEW_ROW_HEIGHT_PX,
|
||||
scanJsonFolds,
|
||||
splitTextIntoLines,
|
||||
type JsonFoldRange,
|
||||
} from '@/lib/json-text-processor';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -35,6 +42,92 @@ const props = defineProps<{
|
||||
const formattedText = computed(() => formatJsonOrRawText(props.json, props.rawText));
|
||||
const lineList = computed(() => splitTextIntoLines(formattedText.value));
|
||||
|
||||
/* ---------- 代码块折叠(model 层状态,调研见 docs/JSON预览器代码折叠调研.md) ----------
|
||||
*
|
||||
* 三层模型:folds(扫描产物)+ collapsedSet(折叠位)+ visibleModel(可见性投影)。
|
||||
* 隐藏语义同 VS Code hidesLine:start < line ≤ end(折叠头行可见、闭括号随块隐藏)。
|
||||
* 非 JSON 响应(props.json === null)不扫描——raw 文本无可靠括号结构。
|
||||
*/
|
||||
const foldList = computed(() => (props.json === null ? [] : scanJsonFolds(lineList.value)));
|
||||
const collapsedStartLines = ref<ReadonlySet<number>>(new Set());
|
||||
|
||||
/** 折叠头行 → fold 查找表(chevron / 行尾摘要渲染用) */
|
||||
const foldByStartLine = computed(
|
||||
() => new Map(foldList.value.map((fold) => [fold.startLine, fold])),
|
||||
);
|
||||
|
||||
/** 可见性投影:可视槽位 → model 行号(1-based)。无折叠时为恒等投影。 */
|
||||
const visibleModel = computed(() =>
|
||||
buildVisibleProjection(lineList.value.length, foldList.value, collapsedStartLines.value),
|
||||
);
|
||||
|
||||
const hasFolds = computed(() => foldList.value.length > 0);
|
||||
|
||||
/**
|
||||
* 折叠事务:改折叠位 + scrollTop 补偿(VS Code StableEditorScrollState 等价实现)。
|
||||
* 锚点是 model 行号——折叠只改变 model 行的可视索引,不改变行本身;
|
||||
* 锚点行被本次折叠藏住时,先钳制到折叠头行,保证补偿基准仍可见。
|
||||
*/
|
||||
async function toggleFold(startLine: number): Promise<void> {
|
||||
const container = scrollContainerRef.value;
|
||||
const nextCollapsed = new Set(collapsedStartLines.value);
|
||||
if (nextCollapsed.has(startLine)) {
|
||||
nextCollapsed.delete(startLine);
|
||||
} else {
|
||||
nextCollapsed.add(startLine);
|
||||
}
|
||||
|
||||
// 折叠前捕获锚点:视口顶部可视行(model 行号)+ 视口内像素偏移
|
||||
let anchorModelLine: number | null = null;
|
||||
let deltaPx = 0;
|
||||
if (container && container.scrollTop > 0) {
|
||||
const topVisIdx = Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX);
|
||||
const rawLine = visibleModel.value[topVisIdx];
|
||||
if (rawLine !== undefined) {
|
||||
anchorModelLine = clampLineToVisible(rawLine, foldList.value, nextCollapsed);
|
||||
deltaPx = container.scrollTop - topVisIdx * JSON_VIEW_ROW_HEIGHT_PX;
|
||||
}
|
||||
}
|
||||
|
||||
collapsedStartLines.value = nextCollapsed;
|
||||
|
||||
// DOM 更新后按新投影反查锚点可视索引,恢复滚动位置
|
||||
await nextTick();
|
||||
if (container && anchorModelLine !== null) {
|
||||
const newVisIdx = findVisibleIndex(visibleModel.value, anchorModelLine);
|
||||
if (newVisIdx >= 0) {
|
||||
container.scrollTop = newVisIdx * JSON_VIEW_ROW_HEIGHT_PX + deltaPx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 选区端点写入前的钳制包装:划进隐藏区的行停在折叠头行(Monaco adjustSelections 语义) */
|
||||
function clampSelectionLine(modelLine: number): number {
|
||||
return clampLineToVisible(modelLine, foldList.value, collapsedStartLines.value);
|
||||
}
|
||||
|
||||
/** 全部折叠:收起所有顶层块(depth = 1)——嵌套块随父块隐藏,符合直觉 */
|
||||
function collapseAll(): void {
|
||||
collapsedStartLines.value = new Set(
|
||||
foldList.value.filter((fold) => fold.depth === 1).map((fold) => fold.startLine),
|
||||
);
|
||||
}
|
||||
|
||||
/** 全部展开 */
|
||||
function expandAll(): void {
|
||||
collapsedStartLines.value = new Set();
|
||||
}
|
||||
|
||||
/** 切换响应(props.json 变化)时重置折叠态:新数据的 folds 语义已变,旧折叠位失效 */
|
||||
watch(
|
||||
() => props.json,
|
||||
() => {
|
||||
collapsedStartLines.value = new Set();
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({ collapseAll, expandAll, hasFolds, toggleFold });
|
||||
|
||||
/* ---------- 容器尺寸与滚动位置(VueUse) ---------- */
|
||||
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null);
|
||||
@@ -55,13 +148,17 @@ const handleScroll = useThrottleFn(
|
||||
/* ---------- 可视窗口(纯函数,阶段二已单测锁死) ---------- */
|
||||
|
||||
const visibleWindow = computed(() =>
|
||||
computeVisibleRowWindow(lineList.value.length, scrollTopPx.value, containerHeightPx.value),
|
||||
computeVisibleRowWindow(visibleModel.value.length, scrollTopPx.value, containerHeightPx.value),
|
||||
);
|
||||
|
||||
/** 窗口内行的渲染模型(行号 + 完整片段序列:token 与 plain 交替,括号不丢) */
|
||||
/** 窗口内行的渲染模型(行号 = model 行号,折叠后 gutter 跳号显示;完整片段序列:token 与 plain 交替,括号不丢) */
|
||||
interface RenderableLine {
|
||||
lineNumber: number;
|
||||
segments: ReturnType<typeof buildJsonLineSegments>;
|
||||
/** 该行是否为某个 fold 的折叠头(渲染 chevron 与行尾摘要) */
|
||||
foldAtLine: JsonFoldRange | null;
|
||||
/** 折叠头行当前是否处于折叠态(决定 chevron 方向与摘要显隐) */
|
||||
isFoldCollapsed: boolean;
|
||||
}
|
||||
|
||||
const visibleLineList = computed<RenderableLine[]>(() => {
|
||||
@@ -71,14 +168,21 @@ const visibleLineList = computed<RenderableLine[]>(() => {
|
||||
index <= visibleWindow.value.endIndex;
|
||||
index += 1
|
||||
) {
|
||||
const lineText = lineList.value[index];
|
||||
if (lineText === undefined) continue;
|
||||
lines.push({ lineNumber: index + 1, segments: buildJsonLineSegments(lineText) });
|
||||
const modelLine = visibleModel.value[index];
|
||||
const lineText = modelLine === undefined ? undefined : lineList.value[modelLine - 1];
|
||||
if (modelLine === undefined || lineText === undefined) continue;
|
||||
const foldAtLine = foldByStartLine.value.get(modelLine) ?? null;
|
||||
lines.push({
|
||||
lineNumber: modelLine,
|
||||
segments: buildJsonLineSegments(lineText),
|
||||
foldAtLine,
|
||||
isFoldCollapsed: foldAtLine !== null && collapsedStartLines.value.has(foldAtLine.startLine),
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
|
||||
const totalHeightPx = computed(() => lineList.value.length * JSON_VIEW_ROW_HEIGHT_PX);
|
||||
const totalHeightPx = computed(() => visibleModel.value.length * JSON_VIEW_ROW_HEIGHT_PX);
|
||||
const gutterWidth = computed(() => computeGutterWidth(lineList.value.length));
|
||||
|
||||
/* ---------- 行选区(model 层锚点,编辑器 model/view 分离的微缩版) ----------
|
||||
@@ -113,24 +217,25 @@ function isLineSelected(lineNumber: number): boolean {
|
||||
}
|
||||
|
||||
/** 同步真实 DOM Selection:让 Ctrl+C 直接可用。
|
||||
* anchor/active 行可能不在窗口内(DOM 不存在),用窗口内实际渲染的边缘行近似。
|
||||
* 选区端点是 model 行号:先钳制到可见(划进隐藏区停在折叠头行),再转可视索引,
|
||||
* 与可视窗口求交集后索引窗口内实际渲染的边缘行(anchor/active 行可能不在窗口内)。
|
||||
*/
|
||||
function syncDomSelection(): void {
|
||||
const range = selectedLineRange.value;
|
||||
const container = scrollContainerRef.value;
|
||||
if (!range || !container) return;
|
||||
const [start, end] = range;
|
||||
const clampedStart = clampSelectionLine(start);
|
||||
const clampedEnd = clampSelectionLine(end);
|
||||
const { startIndex, endIndex } = visibleWindow.value;
|
||||
// 选区与可视窗口的交集边缘(1-based 行号 → 渲染索引)
|
||||
const viewStartLine = startIndex + 1;
|
||||
const viewEndLine = endIndex + 1;
|
||||
const selStartLine = Math.max(start, viewStartLine);
|
||||
const selEndLine = Math.min(end, viewEndLine);
|
||||
if (selStartLine > selEndLine) return;
|
||||
// 可视索引(0-based)与可视窗口的交集(窗口起止也是可视索引)
|
||||
const selStartVisIdx = Math.max(findVisibleIndex(visibleModel.value, clampedStart), startIndex);
|
||||
const selEndVisIdx = Math.min(findVisibleIndex(visibleModel.value, clampedEnd), endIndex);
|
||||
if (selStartVisIdx > selEndVisIdx) return;
|
||||
|
||||
const lineElements = container.querySelectorAll<HTMLElement>('.line');
|
||||
const startEl = lineElements[selStartLine - viewStartLine];
|
||||
const endEl = lineElements[selEndLine - viewStartLine];
|
||||
const startEl = lineElements[selStartVisIdx - startIndex];
|
||||
const endEl = lineElements[selEndVisIdx - startIndex];
|
||||
if (!startEl || !endEl) return;
|
||||
|
||||
const domSelection = window.getSelection();
|
||||
@@ -148,8 +253,8 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
|
||||
// 只响应主键,避免与右键菜单/中键冲突
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); // 防止浏览器默认文本拖选与我们的状态机冲突
|
||||
selectionAnchorLine.value = lineNumber;
|
||||
selectionActiveLine.value = lineNumber;
|
||||
selectionAnchorLine.value = clampSelectionLine(lineNumber);
|
||||
selectionActiveLine.value = clampSelectionLine(lineNumber);
|
||||
syncDomSelection();
|
||||
|
||||
const finish = (): void => {
|
||||
@@ -162,7 +267,7 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
|
||||
const onMove = (moveEvent: MouseEvent): void => {
|
||||
const lineAtPointer = resolveLineFromPointer(moveEvent.clientY);
|
||||
if (lineAtPointer === null) return;
|
||||
selectionActiveLine.value = lineAtPointer;
|
||||
selectionActiveLine.value = clampSelectionLine(lineAtPointer);
|
||||
syncDomSelection();
|
||||
updateEdgeAutoScroll(moveEvent.clientY);
|
||||
};
|
||||
@@ -170,15 +275,18 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
|
||||
document.addEventListener('mouseup', finish, { once: true });
|
||||
}
|
||||
|
||||
/** 指针 y 坐标 → 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点) */
|
||||
/** 指针 y 坐标 → model 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点)。
|
||||
* 几何换算先得可视索引,再经投影反查 model 行号——隐藏行不在投影里,天然不会被命中。
|
||||
*/
|
||||
function resolveLineFromPointer(clientY: number): number | null {
|
||||
const container = scrollContainerRef.value;
|
||||
if (!container) return null;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const offsetY = clientY - rect.top + container.scrollTop;
|
||||
const lineIndex = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX);
|
||||
if (lineIndex < 0 || lineIndex >= lineList.value.length) return null;
|
||||
return lineIndex + 1;
|
||||
const visIdx = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX);
|
||||
if (visIdx < 0 || visIdx >= visibleModel.value.length) return null;
|
||||
const modelLine = visibleModel.value[visIdx];
|
||||
return modelLine === undefined ? null : modelLine;
|
||||
}
|
||||
|
||||
/* ---------- 拖到容器边缘的自动滚动(编辑器标配) ---------- */
|
||||
@@ -236,17 +344,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
||||
}
|
||||
container.scrollTop += speed;
|
||||
// 自动滚动后按新的 scrollTop 推进选区(与滚轮/滚动条共用 scrollTopPx 同源链路)
|
||||
const lineAtEdge =
|
||||
speed < 0
|
||||
? Math.max(1, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX) + 1)
|
||||
: Math.min(
|
||||
lineList.value.length,
|
||||
Math.floor(
|
||||
(container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX,
|
||||
) + 1,
|
||||
);
|
||||
selectionActiveLine.value = lineAtEdge;
|
||||
syncDomSelection();
|
||||
const topVisIdx = Math.max(0, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX));
|
||||
const bottomVisIdx = Math.min(
|
||||
visibleModel.value.length - 1,
|
||||
Math.floor((container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX),
|
||||
);
|
||||
const lineAtEdge = speed < 0 ? visibleModel.value[topVisIdx] : visibleModel.value[bottomVisIdx];
|
||||
if (lineAtEdge !== undefined) {
|
||||
selectionActiveLine.value = clampSelectionLine(lineAtEdge);
|
||||
syncDomSelection();
|
||||
}
|
||||
edgeScrollRafId = requestAnimationFrame(step);
|
||||
};
|
||||
edgeScrollRafId = requestAnimationFrame(step);
|
||||
@@ -273,6 +380,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
||||
@mousedown="handleGutterMouseDown(line.lineNumber, $event)"
|
||||
>{{ line.lineNumber }}</span
|
||||
>
|
||||
<span
|
||||
v-if="hasFolds && line.foldAtLine !== null"
|
||||
class="fold-chevron"
|
||||
:class="{ 'fold-chevron-collapsed': line.isFoldCollapsed }"
|
||||
:title="line.isFoldCollapsed ? '展开' : '折叠'"
|
||||
@mousedown.stop
|
||||
@click.stop="toggleFold(line.foldAtLine.startLine)"
|
||||
>{{ line.isFoldCollapsed ? '›' : '⌄' }}</span
|
||||
>
|
||||
<span v-else-if="hasFolds" class="fold-chevron fold-chevron-placeholder"></span>
|
||||
<span class="line-content">
|
||||
<template v-for="(segment, segmentIndex) in line.segments" :key="segmentIndex">
|
||||
<span v-if="segment.kind !== 'plain'" class="token" :class="segment.kind">{{
|
||||
@@ -280,6 +397,15 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
||||
}}</span>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<span
|
||||
v-if="line.foldAtLine !== null && line.isFoldCollapsed"
|
||||
class="fold-summary"
|
||||
:title="`点击展开(${line.foldAtLine.hiddenLineCount} 行)`"
|
||||
@mousedown.stop
|
||||
@click.stop="toggleFold(line.foldAtLine.startLine)"
|
||||
>{{ line.foldAtLine.kind === 'object' ? '{…}' : '[…]' }}
|
||||
{{ line.foldAtLine.hiddenLineCount }} 行</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,6 +437,44 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 折叠 chevron 列:占位透明保持行号与内容的间距恒定;可折叠行 hover 显示、已折叠常显 */
|
||||
.fold-chevron {
|
||||
display: inline-block;
|
||||
width: 1.5ch;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
color: var(--td-text-color-placeholder);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.fold-chevron-placeholder {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.line:hover > .fold-chevron {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.fold-chevron-collapsed {
|
||||
visibility: visible;
|
||||
color: var(--td-brand-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 折叠头行行尾摘要:hover 提示可点击展开 */
|
||||
.fold-summary {
|
||||
margin-left: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fold-summary:hover {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
/* model 层选区高亮:重渲染不丢(DOM ::selection 会随虚拟滚动窗口变化失效) */
|
||||
.line.line-selected {
|
||||
background: var(--td-brand-color-light-focus, rgba(0, 82, 217, 0.18));
|
||||
|
||||
@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildJsonLineSegments,
|
||||
buildVisibleProjection,
|
||||
clampLineToVisible,
|
||||
computeGutterWidth,
|
||||
computeVisibleRowWindow,
|
||||
findVisibleIndex,
|
||||
formatJsonOrRawText,
|
||||
JSON_VIEW_OVERSCAN_ROWS,
|
||||
JSON_VIEW_ROW_HEIGHT_PX,
|
||||
scanJsonFolds,
|
||||
splitTextIntoLines,
|
||||
} from './json-text-processor';
|
||||
|
||||
@@ -127,3 +131,161 @@ describe('computeGutterWidth', () => {
|
||||
expect(computeGutterWidth(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- 代码块折叠(实施计划 §3 用例清单) ---------- */
|
||||
|
||||
describe('scanJsonFolds 扫描规则', () => {
|
||||
it('顶层多行对象:1 个 fold,end = 闭括号行,depth = 1', () => {
|
||||
const lines = ['{', ' "a": 1', ' "b": 2', '}'];
|
||||
expect(scanJsonFolds(lines)).toEqual([
|
||||
{ startLine: 1, endLine: 4, depth: 1, kind: 'object', hiddenLineCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('嵌套对象:内外两个 fold,内层 depth = 3,各自 endLine 正确', () => {
|
||||
const lines = [
|
||||
'{', // 1
|
||||
' "outer": {', // 2
|
||||
' "inner": {', // 3
|
||||
' "x": 1', // 4
|
||||
' }', // 5
|
||||
' }', // 6
|
||||
'}', // 7
|
||||
];
|
||||
expect(scanJsonFolds(lines)).toEqual([
|
||||
{ startLine: 3, endLine: 5, depth: 3, kind: 'object', hiddenLineCount: 2 },
|
||||
{ startLine: 2, endLine: 6, depth: 2, kind: 'object', hiddenLineCount: 4 },
|
||||
{ startLine: 1, endLine: 7, depth: 1, kind: 'object', hiddenLineCount: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('顶层数组:kind = array', () => {
|
||||
const lines = ['[', ' 1', ' 2', ']'];
|
||||
expect(scanJsonFolds(lines)).toEqual([
|
||||
{ startLine: 1, endLine: 4, depth: 1, kind: 'array', hiddenLineCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('单行对象/数组不产出折叠', () => {
|
||||
expect(scanJsonFolds(['{"a": 1}'])).toEqual([]);
|
||||
expect(scanJsonFolds(['[1, 2]'])).toEqual([]);
|
||||
expect(scanJsonFolds(['{', ' "a": {"x": 1}', '}'])).toEqual([
|
||||
{ startLine: 1, endLine: 3, depth: 1, kind: 'object', hiddenLineCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('非法结构(无开括号的闭括号)健壮跳过,不抛错', () => {
|
||||
expect(scanJsonFolds(['}'])).toEqual([]);
|
||||
expect(scanJsonFolds([' ]'])).toEqual([]);
|
||||
expect(() => scanJsonFolds(['}', ']', ' }'])).not.toThrow();
|
||||
});
|
||||
|
||||
it('行尾含空白/换行的开括号行正常识别(trimEnd 生效)', () => {
|
||||
const lines = ['{ ', ' "a": 1', '}', '\n'];
|
||||
expect(scanJsonFolds(lines)).toEqual([
|
||||
{ startLine: 1, endLine: 3, depth: 1, kind: 'object', hiddenLineCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('非 JSON 文本不抛错(调用方须先确认是 JSON,此处只保证健壮性)', () => {
|
||||
expect(() => scanJsonFolds(['function f() {', ' return 1;', '}'])).not.toThrow();
|
||||
expect(scanJsonFolds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildVisibleProjection 隐藏语义', () => {
|
||||
const folds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', '}']);
|
||||
const fold = folds[0]!;
|
||||
|
||||
it('空折叠集:恒等投影', () => {
|
||||
expect(buildVisibleProjection(4, folds, new Set())).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('折叠中间块:start 可见、(start, end] 全部消失(含闭括号行)、end+1 起恢复', () => {
|
||||
const middleFolds = scanJsonFolds(['{', ' "x": {', ' "y": 1', ' }', '}']);
|
||||
// middleFolds: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
|
||||
const projection = buildVisibleProjection(5, middleFolds, new Set([2]));
|
||||
expect(projection).toEqual([1, 2, 5]);
|
||||
});
|
||||
|
||||
it('投影长度 = 总行数 − 隐藏行数', () => {
|
||||
const projection = buildVisibleProjection(4, [fold], new Set([fold.startLine]));
|
||||
expect(projection).toHaveLength(4 - fold.hiddenLineCount);
|
||||
});
|
||||
|
||||
it('嵌套两块同时折叠:区间合并隐藏、无重复行号', () => {
|
||||
const nested = scanJsonFolds([
|
||||
'{', // 1
|
||||
' "a": {', // 2
|
||||
' "b": 1', // 3
|
||||
' }', // 4
|
||||
'}', // 5
|
||||
]);
|
||||
// nested: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
|
||||
// 同时折叠外层(start=1)与内层(start=2):外层区间已覆盖全部,投影只剩折叠头
|
||||
const projection = buildVisibleProjection(5, nested, new Set([1, 2]));
|
||||
expect(projection).toEqual([1]);
|
||||
});
|
||||
|
||||
it('折叠位含不存在的 startLine:忽略不生效', () => {
|
||||
expect(buildVisibleProjection(4, folds, new Set([999]))).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('零总行数返回空投影', () => {
|
||||
expect(buildVisibleProjection(0, folds, new Set([1]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findVisibleIndex 反查', () => {
|
||||
const folds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', '}']);
|
||||
// 折叠顶层对象(start=1/end=4,闭括号行随块隐藏):投影只剩折叠头
|
||||
const projection = buildVisibleProjection(4, folds, new Set([1]));
|
||||
|
||||
it('可见行命中正确可视索引', () => {
|
||||
expect(findVisibleIndex(projection, 1)).toBe(0);
|
||||
const halfFolds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', ' "c": 3', '}']);
|
||||
const halfProjection = buildVisibleProjection(5, halfFolds, new Set([1]));
|
||||
expect(halfProjection).toEqual([1]); // 折叠整个对象后仍只有折叠头可见
|
||||
expect(findVisibleIndex(halfProjection, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('隐藏行(含闭括号行)返回 −1', () => {
|
||||
expect(findVisibleIndex(projection, 2)).toBe(-1);
|
||||
expect(findVisibleIndex(projection, 3)).toBe(-1);
|
||||
expect(findVisibleIndex(projection, 4)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampLineToVisible 选区钳制', () => {
|
||||
const folds = scanJsonFolds([
|
||||
'{', // 1
|
||||
' "outer": {', // 2
|
||||
' "x": 1', // 3
|
||||
' }', // 4
|
||||
'}', // 5
|
||||
]);
|
||||
// folds: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
|
||||
|
||||
it('区间内隐藏行(含闭括号行)收缩到折叠头行 start', () => {
|
||||
const collapsed = new Set([1]);
|
||||
expect(clampLineToVisible(2, folds, collapsed)).toBe(1);
|
||||
expect(clampLineToVisible(4, folds, collapsed)).toBe(1);
|
||||
expect(clampLineToVisible(5, folds, collapsed)).toBe(1); // 行 5 是闭括号,在隐藏区
|
||||
});
|
||||
|
||||
it('start 本身、折叠头之后的可见行不收缩', () => {
|
||||
const collapsed = new Set([2]); // 只折叠内层(2..4)
|
||||
expect(clampLineToVisible(2, folds, collapsed)).toBe(2);
|
||||
expect(clampLineToVisible(5, folds, collapsed)).toBe(5); // 外层闭括号在折叠区外
|
||||
});
|
||||
|
||||
it('未折叠的 fold 不参与钳制', () => {
|
||||
expect(clampLineToVisible(3, folds, new Set([2]))).toBe(2);
|
||||
expect(clampLineToVisible(3, folds, new Set())).toBe(3);
|
||||
});
|
||||
|
||||
it('嵌套区间同时折叠:命中最外层折叠头', () => {
|
||||
const collapsed = new Set([1, 2]);
|
||||
expect(clampLineToVisible(3, folds, collapsed)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,3 +112,131 @@ export function buildJsonLineSegments(line: string): JsonLineSegment[] {
|
||||
export function computeGutterWidth(totalRowCount: number): number {
|
||||
return String(Math.max(totalRowCount, 1)).length;
|
||||
}
|
||||
|
||||
/* ---------- 代码块折叠(调研与实施计划见 docs/JSON预览器代码折叠调研.md / 实施计划.md) ----------
|
||||
*
|
||||
* 三层模型(对齐 VS Code/Monaco 与 CodeMirror 6 的共同结构):
|
||||
* 1. folds:括号栈扫描产出的折叠范围(model 行号对,end = 闭括号前一行)
|
||||
* 2. collapsedSet:已折叠集合(fold 的 start 行号)
|
||||
* 3. visibleModel:可见性投影——未被隐藏的 model 行号按序排列;
|
||||
* 隐藏语义同 VS Code hidesLine:start < line ≤ end(折叠头行永远可见)
|
||||
*/
|
||||
|
||||
/** 单个折叠范围(1-based model 行号;start = 开括号行,end = 闭括号行) */
|
||||
export interface JsonFoldRange {
|
||||
readonly startLine: number;
|
||||
/** 闭括号行(注意:不是闭括号前一行——LSP 的「闭括号前一行」是 0-based 记法,
|
||||
* 转 1-based 后即闭括号行本身;VS Code 折叠对象时闭括号行随块隐藏,行尾摘要附在折叠头行) */
|
||||
readonly endLine: number;
|
||||
/** 嵌套深度:顶层块 = 1(供「折叠全部到第 N 层」扩展位与调试) */
|
||||
readonly depth: number;
|
||||
/** 块类型(行尾是 { 还是 [) */
|
||||
readonly kind: 'object' | 'array';
|
||||
/** 该折叠隐藏的行数(endLine − startLine,不含折叠头行) */
|
||||
readonly hiddenLineCount: number;
|
||||
}
|
||||
|
||||
/** 从行数组扫描 JSON 折叠范围(括号栈行级扫描,VS Code jsonFolding 同款规则)。
|
||||
* 仅当行尾为 { / [(后随空白)时开块、行首(首个非空白)为 } / ] 时收块——
|
||||
* pretty-print 的 JSON 保证每行至多一个括号,字符串值不会以裸括号结尾,零误判。
|
||||
* 单行块(闭括号紧随开括号行)不产出折叠。非 JSON 文本可能产出错误范围,调用方须先确认是 JSON。
|
||||
*/
|
||||
export function scanJsonFolds(lineList: readonly string[]): JsonFoldRange[] {
|
||||
const folds: JsonFoldRange[] = [];
|
||||
// 栈元素:开括号行(1-based)与块类型
|
||||
const openStack: Array<{ startLine: number; kind: 'object' | 'array' }> = [];
|
||||
|
||||
for (let index = 0; index < lineList.length; index += 1) {
|
||||
const line = lineList[index]!;
|
||||
const lineNo = index + 1;
|
||||
const trimmedEnd = line.trimEnd();
|
||||
|
||||
if (trimmedEnd.endsWith('{') || trimmedEnd.endsWith('[')) {
|
||||
openStack.push({
|
||||
startLine: lineNo,
|
||||
kind: trimmedEnd.endsWith('{') ? 'object' : 'array',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstChar = trimmedEnd.trimStart().charAt(0);
|
||||
if (firstChar === '}' || firstChar === ']') {
|
||||
const opened = openStack.pop();
|
||||
if (!opened) continue; // 非法结构(不完整 JSON 等):跳过,保持扫描健壮
|
||||
// end = 闭括号行(闭括号随块隐藏);单行块不产出(closeLine > start + 1 才有隐藏行)
|
||||
const closeLine = lineNo;
|
||||
if (closeLine > opened.startLine + 1) {
|
||||
folds.push({
|
||||
startLine: opened.startLine,
|
||||
endLine: closeLine,
|
||||
depth: openStack.length + 1,
|
||||
kind: opened.kind,
|
||||
hiddenLineCount: closeLine - opened.startLine,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return folds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见性投影:从全量 model 行号中剔除被折叠区间隐藏的行(start < line ≤ end)。
|
||||
* 折叠区间按 startLine 升序判定;嵌套折叠通过区间覆盖判定天然正确——
|
||||
* 一个行号落在多个已折叠区间内只输出一次。
|
||||
*/
|
||||
export function buildVisibleProjection(
|
||||
totalLineCount: number,
|
||||
folds: readonly JsonFoldRange[],
|
||||
collapsedStartLines: ReadonlySet<number>,
|
||||
): number[] {
|
||||
if (totalLineCount <= 0) return [];
|
||||
|
||||
const identityProjection = (): number[] =>
|
||||
Array.from({ length: totalLineCount }, (_, index) => index + 1);
|
||||
|
||||
if (collapsedStartLines.size === 0) return identityProjection();
|
||||
|
||||
const collapsedRanges = folds
|
||||
.filter((fold) => collapsedStartLines.has(fold.startLine))
|
||||
.sort((a, b) => a.startLine - b.startLine);
|
||||
|
||||
if (collapsedRanges.length === 0) return identityProjection();
|
||||
|
||||
const visible: number[] = [];
|
||||
for (let line = 1; line <= totalLineCount; line += 1) {
|
||||
let isHidden = false;
|
||||
for (const range of collapsedRanges) {
|
||||
if (range.startLine >= line) break; // 区间按 start 升序,后面的不会覆盖当前行
|
||||
if (line <= range.endLine) {
|
||||
isHidden = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isHidden) visible.push(line);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** model 行号 → 可视索引(0-based,投影数组中的位置);行被隐藏或未命中时返回 −1 */
|
||||
export function findVisibleIndex(visibleModel: readonly number[], modelLine: number): number {
|
||||
return visibleModel.indexOf(modelLine);
|
||||
}
|
||||
|
||||
/** 选区端点钳制:model 行落在某已折叠区间的隐藏部分(start < line ≤ end)时收缩到折叠头行。
|
||||
* Monaco HiddenRangeModel.adjustSelections 同款语义——选区端点只收缩、不展开折叠。
|
||||
*/
|
||||
export function clampLineToVisible(
|
||||
modelLine: number,
|
||||
folds: readonly JsonFoldRange[],
|
||||
collapsedStartLines: ReadonlySet<number>,
|
||||
): number {
|
||||
let clamped = modelLine;
|
||||
for (const fold of folds) {
|
||||
if (!collapsedStartLines.has(fold.startLine)) continue;
|
||||
if (fold.startLine < clamped && clamped <= fold.endLine) {
|
||||
clamped = fold.startLine;
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user