Archived
feat(view): JSON 折叠纯函数——括号栈扫描与可见性投影
- scanJsonFolds:括号栈单遍扫描(行尾 {/[ 开块、行首 }/] 收块),end=闭括号行(闭括号随块隐藏;LSP 文档的「闭括号前一行」是 0-based 记法的转述陷阱,注释已记录),单行块不产出,非法结构健壮跳过
- buildVisibleProjection:可见性投影,隐藏语义同 VS Code hidesLine(start < line ≤ end,折叠头可见),嵌套折叠区间覆盖天然正确
- findVisibleIndex:model 行号 → 可视索引反查
- clampLineToVisible:选区端点钳制到折叠头行(Monaco adjustSelections 语义)
- 新增 20 个纯函数单测(扫描规则/嵌套/非法结构/投影边界/钳制)
- 附调研文档 docs/JSON预览器代码折叠调研.md(VS Code/CM6 源码考证 + 交互惯例)
This commit is contained in:
@@ -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. **回归**:全量测试 + 新增折叠用例(重点:折叠后滚动、嵌套折叠不重复计数、补偿无跳变)
|
||||||
@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildJsonLineSegments,
|
buildJsonLineSegments,
|
||||||
|
buildVisibleProjection,
|
||||||
|
clampLineToVisible,
|
||||||
computeGutterWidth,
|
computeGutterWidth,
|
||||||
computeVisibleRowWindow,
|
computeVisibleRowWindow,
|
||||||
|
findVisibleIndex,
|
||||||
formatJsonOrRawText,
|
formatJsonOrRawText,
|
||||||
JSON_VIEW_OVERSCAN_ROWS,
|
JSON_VIEW_OVERSCAN_ROWS,
|
||||||
JSON_VIEW_ROW_HEIGHT_PX,
|
JSON_VIEW_ROW_HEIGHT_PX,
|
||||||
|
scanJsonFolds,
|
||||||
splitTextIntoLines,
|
splitTextIntoLines,
|
||||||
} from './json-text-processor';
|
} from './json-text-processor';
|
||||||
|
|
||||||
@@ -127,3 +131,161 @@ describe('computeGutterWidth', () => {
|
|||||||
expect(computeGutterWidth(0)).toBe(1);
|
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 {
|
export function computeGutterWidth(totalRowCount: number): number {
|
||||||
return String(Math.max(totalRowCount, 1)).length;
|
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