✨ feat(vscode): 代码阅读把推导式换成按读的顺序重排的遍历小框

This commit is contained in:
cruldra committed 2026-09-26 16:15:09 +08:00
1 parent 677fb7da1f
commit 6416cdf714
16 files changed
+641 -91

No files matched your search

@@ -1,7 +1,8 @@
import type { ReactElement } from 'react'
import type { PropertyItem, PropertyRow, Segment } from './types'
import type { Inline, PropertyItem, PropertyRow } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
import { TextPreview } from './TextPreview'
import { propertyItemId, propertyRowId } from './viewState'
@@ -20,15 +21,11 @@ interface Folding {
onToggleFold: (id: string) => void
}
function segmentsText(segments: Segment[]): string {
return segments.map(segment => segment.text).join('')
}
/**
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
*/
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[], foldable: boolean, folding: Folding }): ReactElement {
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], foldable: boolean, folding: Folding }): ReactElement {
if (!foldable) {
return (
<div className="flex min-w-0 gap-1">
@@ -45,7 +42,7 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[]
<button
type="button"
className="group flex min-w-0 items-start gap-1 rounded-sm text-left"
aria-label={`${folded ? '展开' : '折叠'}${segmentsText(cell)}`}
aria-label={`${folded ? '展开' : '折叠'}${inlineText(cell)}`}
aria-expanded={!folded}
onClick={() => folding.onToggleFold(id)}
>
@@ -1,27 +1,48 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import type { Segment } from './types'
import type { GroupStyle, Inline, SegmentGroup } from './types'
import { Repeat } from 'lucide-react'
import { useContext } from 'react'
import { segmentOccurrence } from './occurrences'
import { HighlightContext } from './useOccurrences'
/**
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。框起来的一组词画成小框,框里的词同样处理。
*/
export function SegmentText({ segments }: { segments: Segment[] }): ReactElement {
export function SegmentText({ segments }: { segments: Inline[] }): ReactElement {
const highlight = useContext(HighlightContext)
return (
<>
{segments.map((segment, index) => (
<span
key={index}
className={`cr-role-${segment.role}`}
data-offset={segment.offset}
data-occurrence={highlight && segmentOccurrence(segment, highlight.occurrences)}
>
{segment.text}
</span>
))}
{segments.map((piece, index) => 'role' in piece
? (
<span
key={index}
className={`cr-role-${piece.role}`}
data-offset={piece.offset}
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
>
{piece.text}
</span>
)
: <GroupBox key={index} group={piece} />)}
</>
)
}
/** 框的样式 → 框头图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */
const GROUP_ICONS: Record<GroupStyle, LucideIcon> = { iteration: Repeat }
/** 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。 */
function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
const Icon = GROUP_ICONS[group.style]
return (
<span className="cr-group" data-style={group.style}>
<span className="cr-group-head">
<Icon size={12} strokeWidth={2} aria-hidden />
{group.label}
</span>
<SegmentText segments={group.segments} />
</span>
)
}
@@ -278,6 +278,38 @@ body.vscode-high-contrast-light {
white-space: nowrap;
}
/*
* 框起来的一组词(写在表达式里的遍历):嵌在译文里的小框,框头是图标和 label,边框、底色、框头取框的样式色(遍历同「循环」块)。
* 整个框放不下时挪到下一行,比一行还长时占满一行、在框里换行。上下边框算在行高里:框和前后文字顶部对齐、基线对齐,不把这一行撑高;
* 框里再套的框行高再减 2px,同样不撑高外面那个框的行
*/
.cr-group[data-style="iteration"] { --group: var(--cr-kind-loop); }
.cr-group {
display: inline-block;
max-width: 100%;
padding: 0 5px;
line-height: 18px;
vertical-align: top;
border: 1px solid color-mix(in srgb, var(--group) 45%, transparent);
border-radius: 4px;
background: color-mix(in srgb, var(--group) 14%, transparent);
}
.cr-group .cr-group {
line-height: 16px;
}
.cr-group-head {
display: inline-flex;
align-items: center;
gap: 3px;
margin-right: 6px;
vertical-align: top;
font-family: var(--vscode-font-family, system-ui, sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-size: 11px;
font-weight: 500;
color: var(--group);
white-space: nowrap;
}
/* 编辑:透明输入框垫在底下接收输入和选区,着色层盖在上面只负责显示,高度由着色层撑开 */
.cr-editor {
position: relative;
@@ -1,5 +1,5 @@
import type { Click, Highlight } from './occurrences'
import type { BlockView, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
@@ -205,12 +205,17 @@ describe('occurrences:高亮的状态', () => {
})
})
/** 框拆开,框里的词接在前后的词中间:SegmentText 同样一段段画框里的词。 */
function inlineSegments(pieces: Inline[]): Segment[] {
return pieces.flatMap(piece => 'role' in piece ? [piece] : inlineSegments(piece.segments))
}
function textSegments(text: PreviewText): Segment[] {
return 'markdown' in text ? [] : text
return 'markdown' in text ? [] : inlineSegments(text)
}
function itemSegments(items: PropertyItem[] | undefined): Segment[] {
return (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)])
return (items ?? []).flatMap(item => [...inlineSegments(item.cells.flat()), ...itemSegments(item.items)])
}
/** 预览里画出来的所有段,按页面上从上到下的顺序:每块的属性表或译文,再是它的子块。 */
@@ -1,6 +1,6 @@
import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract'
import type { RevealPlan } from './reveal'
import type { BlockKind, BlockView, PropertyItem, PropertyRow, Segment } from './types'
import type { BlockKind, BlockView, Inline, PropertyItem, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
@@ -19,7 +19,7 @@ function word(text: string): Segment {
return { text, role: 'keyword' }
}
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView {
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation }
}
@@ -56,6 +56,15 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('名字在框里(套了几层都算)也是显示着它', () => {
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] }
const blocks: BlockView[] = [
textBlock('0', 'return', 0, [word('返回'), { style: 'iteration', label: '列表', segments: [word('遍历'), name('grid', 20), word('取'), inner] }]),
]
expect(revealPlan(blocks, 5, 40)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 5, 20)).toEqual({ id: '0', expand: [] })
})
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } },
@@ -235,6 +244,11 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
expect(await at('i in range')).toEqual({ plan: { id: '1.1', expand: ['1'] }, flashed: 'for i in range(3):' })
})
it('python:推导式的变量只在框里显示:闪框所在的那块', async () => {
const source = ['def ids(users):', ' return [u.id for u in users]'].join('\n')
expect(await jump(py, 'python', pythonZhTranslator, source, 'u in users')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'return [u.id for u in users]' })
})
it('python:翻译插件没标 defines 时闪整块,名字在子行里照样展开那一行', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(py, 'python', withoutDefines(pythonZhTranslator), PY, needle)
expect(await at('phone: str')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'def mask_phone(phone: str) -> str:' })
+3 -2
View File
@@ -1,4 +1,5 @@
import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types'
import { showsOffset } from './segments'
import { propertyItemId, propertyRowId } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
@@ -30,9 +31,9 @@ function covers(range: SourceRange | undefined, offset: number): boolean {
return range !== undefined && range.startIndex <= offset && offset < range.endIndex
}
/** 文字里显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */
/** 文字里(连框里)显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */
function shows(text: PreviewText, offset: number): boolean {
return !('markdown' in text) && text.some(segment => segment.offset === offset)
return !('markdown' in text) && showsOffset(text, offset)
}
/**
+11 -1
View File
@@ -1,4 +1,4 @@
import type { Segment } from './types'
import type { Inline, Segment } from './types'
/** 取 segments 拼起来的文字里 [from, to) 这一段,每个字保留原来的词性。 */
export function sliceSegments(segments: Segment[], from: number, to: number): Segment[] {
@@ -37,3 +37,13 @@ export function recolor(tokens: Segment[], code: string, text: string): Segment[
...sliceSegments(tokens, code.length - tail, code.length),
]
}
/** 着色的词拼成的文字(给折叠按钮的说明用);框写成框头加框里的文字。 */
export function inlineText(pieces: Inline[]): string {
return pieces.map(piece => 'role' in piece ? piece.text : `${piece.label} ${inlineText(piece.segments)}`).join('')
}
/** 文字里有没有 offset 处的那个名字,框里的也算。 */
export function showsOffset(pieces: Inline[], offset: number): boolean {
return pieces.some(piece => 'role' in piece ? piece.offset === offset : showsOffset(piece.segments, offset))
}
+15 -2
View File
@@ -17,11 +17,24 @@ export type TokenRole
/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */
export interface Segment { text: string, role: TokenRole, offset?: number }
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块 */
export type GroupStyle = 'iteration'
/** 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label;框里的词照样带源码下标,还能再套框 */
export interface SegmentGroup {
style: GroupStyle
label: string
segments: Inline[]
}
/** 译文里的一段:着色的词,或框起来的一组词 */
export type Inline = Segment | SegmentGroup
/** 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块 */
export interface Markdown { markdown: string }
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明) */
export type PreviewText = Segment[] | Markdown
export type PreviewText = Inline[] | Markdown
/** 源码里一段的下标,end 为开区间 */
export interface SourceRange { startIndex: number, endIndex: number }
@@ -34,7 +47,7 @@ export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */
export interface PropertyItem extends Defines {
cells: Segment[][]
cells: Inline[][]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */
items?: PropertyItem[]
}