✨ feat(vscode): 代码阅读预览问 AI 的回答里代码位置写成链接、点了能跳:提示词要求写 [名字](路径#符号) 或 [文件名:起-止](路径#L起-L止) 并给出仓库根,仓库链接认 #L 行号(注释里同一套),旧回答里整段是「路径:行」的行内代码也当链接,带行号的在预览里展开、闪包住那几行的块,不支持预览的用文本编辑器选中

This commit is contained in:
cruldra committed 2026-10-14 06:30:30 +11:00
1 parent ceeaad91a7
commit e401e4aec3
22 files changed
+516 -90

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ export function App(): ReactElement | null {
}))
return
case 'reveal':
page.update({ reveal: { line: msg.line, definition: msg.definition } })
page.update({ reveal: { line: msg.line, definition: msg.definition, endLine: msg.endLine } })
return
case 'displayNames':
// 查到的是页面上这个版本的才换上,迟到的旧版本的不理
@@ -75,6 +75,11 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent.owner_id">owner 字段</a></p>\n')
})
it('带几行的仓库链接也能点,注释和问 AI 的回答里一样', () => {
expect(renderMarkdown('[service.py:357-364](backend/app/service.py#L357-L364)', 'answer')).toBe('<p><a href="backend/app/service.py#L357-L364">service.py:357-364</a></p>\n')
expect(renderMarkdown('[第 3 行](backend/app/service.py#L3)')).toBe('<p><a href="backend/app/service.py#L3">第 3 行</a></p>\n')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
@@ -148,3 +153,17 @@ describe('mermaid 代码块', () => {
expect(renderMarkdownOnce(`\`\`\`mermaid\n${source}\`\`\``)).toBe(codeBlock)
})
})
describe('问 AI 的回答里写成行内代码的代码位置', () => {
it('整段是代码位置的行内代码包成仓库链接,逐字长着的回答也一样', () => {
expect(renderMarkdown('见 `backend/app/routes.py:96-135`。', 'answer')).toBe('<p>见 <a href="backend/app/routes.py#L96-L135"><code>backend/app/routes.py:96-135</code></a>。</p>\n')
expect(renderMarkdown('`web/src/a.ts`', 'answer')).toBe('<p><a href="web/src/a.ts"><code>web/src/a.ts</code></a></p>\n')
expect(renderMarkdownOnce('见 `backend/app/routes.py:96`')).toBe('<p>见 <a href="backend/app/routes.py#L96"><code>backend/app/routes.py:96</code></a></p>\n')
})
it('不像代码位置的、已经在链接里的不包;注释里的不包', () => {
expect(renderMarkdown('`12:30` 和 `a:b`、`token`', 'answer')).toBe('<p><code>12:30</code> 和 <code>a:b</code>、<code>token</code></p>\n')
expect(renderMarkdown('[`backend/app/routes.py:96`](backend/app/routes.py#L96)', 'answer')).toBe('<p><a href="backend/app/routes.py#L96"><code>backend/app/routes.py:96</code></a></p>\n')
expect(renderMarkdown('见 `backend/app/routes.py:96-135`')).toBe('<p>见 <code>backend/app/routes.py:96-135</code></p>\n')
})
})
+34 -6
View File
@@ -1,24 +1,26 @@
import type { StateInline } from 'markdown-it'
import MarkdownIt from 'markdown-it'
import { isRepoImageFile, repoImagePlaceholder } from './repoImage'
import { parseRepoLink, symbolHref } from './repoLink'
import { codeLocationHref, parseRepoLink, symbolHref } from './repoLink'
/**
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员);
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名、#类名.成员 或 #L起-L止);
* 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。
* 图片只认仓库里的图片文件:先显示路径,问到文件在了再按原图比例显示。http / https 图片不显示。
* 注释(不是问 AI 的回答)里 KDoc 写法的不带路径的引用 `[Name]`、`[Class.member]` 也是链接,见 symbolReference。
* 注释(不是问 AI 的回答)里 KDoc 写法的不带路径的引用 `[Name]`、`[Class.member]` 也是链接,见 symbolReference;
* 问 AI 的回答里整段写着代码位置的行内代码也是仓库链接,见 code_location_link。
*/
const md = new MarkdownIt({ html: false, linkify: true })
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
/**
* 渲染时带的环境:symbolLinks 为真时认不带路径的引用(注释的 Markdown 才认,问 AI 的回答不认);
* codeLinks 为真时整段写着代码位置的行内代码当仓库链接(问 AI 的回答才认);
* diagrams 为真时 ```mermaid 代码块出图的占位(源码完整、不夹增删记号的才出)。
* markdown-it 自己用 env.references 记参考式链接的定义,不能占这个名字。
*/
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean, diagrams?: boolean }
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean, codeLinks?: boolean, diagrams?: boolean }
/** 方括号里的标识符(字母、数字、下划线),点分开的几段:`Name`、`Class.member`。 */
const REFERENCE = /^\[([A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*)\]/i
@@ -59,7 +61,7 @@ md.renderer.rules.image = (tokens, idx) => {
const src = typeof rawSrc === 'string' ? rawSrc : ''
const alt = token?.content ?? ''
const link = parseRepoLink(src)
if (link !== undefined && link.symbol === undefined && isRepoImageFile(link.path))
if (link !== undefined && link.symbol === undefined && link.lines === undefined && isRepoImageFile(link.path))
return repoImagePlaceholder(link.path, alt)
const esc = md.utils.escapeHtml
return `!<a href="${esc(src)}">${esc(alt)}</a>`
@@ -93,6 +95,32 @@ md.core.ruler.push('link_title_tooltip', (state) => {
}
})
/**
* 问 AI 的回答里整段写着一处代码位置的行内代码(`backend/app/routes.py:96-135`,见 [codeLocationHref])包进仓库链接,
* 和写成链接的一样能点、问不到文件时标坏;以前存下的、没写成链接的回答也能点。本来就在链接里的不再包。
*/
md.core.ruler.push('code_location_link', (state) => {
if (!(state.env as RenderEnv | undefined)?.codeLinks)
return
for (const block of state.tokens) {
if (!block.children)
continue
let depth = 0
block.children = block.children.flatMap((token) => {
if (token.type === 'link_open')
depth += 1
else if (token.type === 'link_close')
depth -= 1
const href = token.type === 'code_inline' && depth === 0 ? codeLocationHref(token.content) : undefined
if (href === undefined)
return [token]
const open = new state.Token('link_open', 'a', 1)
open.attrSet('href', md.normalizeLink(href))
return [open, token, new state.Token('link_close', 'a', -1)]
})
}
})
/** 最近渲染过的文字 → HTML,最多留 CACHE_SIZE 段,最久没用的先丢。 */
const CACHE_SIZE = 500
const cache = new Map<string, string>()
@@ -104,7 +132,7 @@ const cache = new Map<string, string>()
export type MarkdownKind = 'comment' | 'peek' | 'answer'
function envOf(kind: MarkdownKind, diagrams: boolean): RenderEnv {
return { symbolLinks: kind === 'comment', diagrams }
return { symbolLinks: kind === 'comment', codeLinks: kind === 'answer', diagrams }
}
/**
@@ -294,3 +294,38 @@ describe('跳过去以后页面还在长高,目标跟着不动', () => {
expect(card().getBoundingClientRect().top).toBe(-20)
})
})
describe('跳到几行(问 AI 的回答里点了带行号的链接)', () => {
/** 记下闪过的元素:闪完的计时器很快就摘掉 data-flash */
function watchFlashes(): Element[] {
const flashed: Element[] = []
const setAttribute = Element.prototype.setAttribute
vi.spyOn(Element.prototype, 'setAttribute').mockImplementation(function (this: Element, name: string, value: string) {
if (name === 'data-flash')
flashed.push(this)
setAttribute.call(this, name, value)
})
return flashed
}
it('开着的预览跳到函数体里的一行:展开折叠着的类和函数,把包住这一行的块滚进视口、闪整块', async () => {
await load()
const { reveal: _, ...plain } = await documentRevealing(SOURCE, 'create_for_user') as Extract<ExtensionToWebview, { type: 'document' }>
await deliver(plain)
await settle()
expect(window.scrollY).toBe(0)
const flashed = watchFlashes()
const line = SOURCE.split('\n').findIndex(text => text.includes('return self.create(user)'))
await deliver({ type: 'reveal', line, endLine: line })
await settle()
const block = flashed.at(-1)
expect(block?.matches('.cr-block')).toBe(true)
expect(block?.textContent).toContain('create')
expect(window.scrollY).toBeGreaterThan(0)
const { top } = block!.getBoundingClientRect()
expect(top).toBeGreaterThanOrEqual(0)
expect(top).toBeLessThan(window.innerHeight)
})
})
@@ -3,7 +3,7 @@
import type { CommentLink } from './repoLink'
import type { WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { commentLinkOf, parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink'
import { codeLocationHref, commentLinkOf, parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink'
/** 页面上一个链接地址是哪种能点的链接;测试里写的都得是 */
function link(href: string): CommentLink {
@@ -35,18 +35,30 @@ describe('parseRepoLink', () => {
expect(parseRepoLink('pkg/mod.py#__init__')).toEqual({ path: 'pkg/mod.py', symbol: '__init__' })
})
it('行号不算', () => {
it('不带 L 的行号、三段的符号不算', () => {
expect(parseRepoLink('file.py#123')).toBeUndefined()
expect(parseRepoLink('file.py#0')).toBeUndefined()
expect(parseRepoLink('file.py#Agent.owner.extra')).toBeUndefined()
})
it('带几行:#L起 或 #L起-L止(1 起,止可以不写 L),和符号二选一', () => {
const path = 'backend/src/luffy_agent/deployment/app/service.py'
expect(parseRepoLink(`${path}#L357`)).toEqual({ path, lines: { start: 357, end: 357 } })
expect(parseRepoLink(`${path}#L357-L364`)).toEqual({ path, lines: { start: 357, end: 364 } })
expect(parseRepoLink(`${path}#L357-364`)).toEqual({ path, lines: { start: 357, end: 364 } })
expect(parseRepoLink('file.py#L0')).toBeUndefined()
expect(parseRepoLink('file.py#L9-L3')).toBeUndefined()
expect(parseRepoLink('file.py#L3-')).toBeUndefined()
expect(parseRepoLink('file.py#L3-L4-L5')).toBeUndefined()
})
it('.. 不算', () => {
expect(parseRepoLink('../etc/passwd')).toBeUndefined()
expect(parseRepoLink('foo/../../bar')).toBeUndefined()
expect(parseRepoLink('foo/%2e%2e/bar')).toBeUndefined()
expect(parseRepoLink('./x.md')).toBeUndefined()
expect(parseRepoLink('/etc/passwd')).toBeUndefined()
expect(parseRepoLink('~/.zshrc')).toBeUndefined()
expect(parseRepoLink('C:/foo')).toBeUndefined()
})
@@ -89,6 +101,18 @@ describe('repoLinks', () => {
expect(links.isBroken(link('backend/a.py#Agent'))).toBe(false)
})
it('带几行的链接:点了发 openRepoLink 带上这几行;文件在不在和别的仓库路径一起问,文件没有就标坏', () => {
const { links, sent } = setup()
expect(links.click(link('backend/app/service.py#L357-L364'))).toBe(true)
expect(sent).toEqual([{ type: 'openRepoLink', path: 'backend/app/service.py', lines: { start: 357, end: 364 } }])
links.scan([link('backend/app/service.py#L357-L364'), link('backend/app/service.py#L3'), link('web/a.ts')])
expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/app/service.py', 'web/a.ts'], references: [] })
links.checked(1, ['backend/app/service.py'], [])
expect(links.isBroken(link('backend/app/service.py#L3'))).toBe(true)
expect(links.isBroken(link('web/a.ts'))).toBe(false)
})
it('符号没有才标坏;打开成功就不再标', () => {
const { links } = setup()
links.broken({ path: 'backend/a.py', symbol: 'Agent' })
@@ -189,3 +213,18 @@ describe('页面上的链接写在哪块的注释里', () => {
expect(at('web')).toBeUndefined()
})
})
describe('行内代码里写的代码位置', () => {
it('整段是「路径:行」「路径:起-止」或一个文件路径:换成仓库链接的地址', () => {
expect(codeLocationHref('backend/src/luffy_agent/deployment/app/routes.py:96-135')).toBe('backend/src/luffy_agent/deployment/app/routes.py#L96-L135')
expect(codeLocationHref('web/src/a.ts:12')).toBe('web/src/a.ts#L12')
expect(codeLocationHref('web/src/a.test.ts')).toBe('web/src/a.test.ts')
expect(codeLocationHref('app/(auth)/[id]/page.tsx:3')).toBe('app/(auth)/[id]/page.tsx#L3')
expect(parseRepoLink(codeLocationHref('backend/app/routes.py:96-135')!)).toEqual({ path: 'backend/app/routes.py', lines: { start: 96, end: 135 } })
})
it('不像文件位置的不算:没有 /、最后一段没有后缀、带协议、绝对路径、跑出仓库、行号不对、有空格', () => {
for (const code of ['a:b', '12:30', 'service.py:12', 'routes.py', 'src/routes', 'application/json', 'http://example.com/a.py:12', 'https://x.io/a.ts', '/abs/x.py:3', '~/x/y.py:3', './src/a.ts:3', '../a/b.ts', 'src/a.ts:0', 'src/a.ts:9-3', 'src/a.ts:12-', 'src/my file.ts', 'a/b.py#L3', 'x = a/b.py'])
expect(codeLocationHref(code), code).toBeUndefined()
})
})
+43 -10
View File
@@ -1,11 +1,13 @@
import type { SymbolReference, WebviewToExtension } from './types'
import type { LineRange, SymbolReference, WebviewToExtension } from './types'
/** 注释里指向仓库代码的链接:从仓库根起的路径,可选一个符号。 */
/** 注释、问 AI 的回答里指向仓库代码的链接:从仓库根起的路径,可选一个符号或几行。 */
export interface RepoLink {
/** 从仓库根算起,用 / 分隔,开头没有 / 或 ./ */
path: string
/** 类名、函数名,或「类名.成员名」。指向目录或整个文件时没有 */
/** 类名、函数名,或「类名.成员名」。指向目录、整个文件或几行时没有 */
symbol?: string
/** 文件里的几行(`#L357`、`#L357-L364`)。和 `symbol` 不会都有 */
lines?: LineRange
}
/**
@@ -38,9 +40,14 @@ export function parseCommentLink(href: string): CommentLink | undefined {
return REFERENCE.test(symbol) ? { symbol } : undefined
}
/** `#` 后面写的几行:`L357`、`L357-L364`,后一个 L 可以不写。 */
const LINES = /^L(\d+)(?:-L?(\d+))?$/
/** 从用户目录起的绝对路径:`~`、`~/…` */
const HOME = /^~(?:\/|$)/
/**
* 从链接地址拆出仓库路径和可选符号。
* 不是这种写法就没有:http / mailto、行号(`#123`)、带 `..`、绝对路径、盘符、以 `/` 或 `./` 开头。
* 从链接地址拆出仓库路径,和可选的符号(`#类名.成员`)或几行(`#L起-L止`,见 [LINES])。
* 不是这种写法就没有:http / mailto、不带 L 的行号(`#123`)、从 0 起或止在起前面的行号、带 `..`、绝对路径(含 `~/`)、盘符、以 `/` 或 `./` 开头。
*/
export function parseRepoLink(url: string): RepoLink | undefined {
let text: string
@@ -50,19 +57,25 @@ export function parseRepoLink(url: string): RepoLink | undefined {
catch {
return undefined
}
if (text === '' || SCHEME.test(text) || text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || text.includes('\\') || hasControl(text))
if (text === '' || SCHEME.test(text) || text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || HOME.test(text) || text.includes('\\') || hasControl(text))
return undefined
const hash = text.indexOf('#')
const rawPath = hash === -1 ? text : text.slice(0, hash)
const symbol = hash === -1 ? undefined : text.slice(hash + 1)
const fragment = hash === -1 ? undefined : text.slice(hash + 1)
const path = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath
if (!isRepoPath(path))
return undefined
if (symbol === undefined)
if (fragment === undefined)
return { path }
if (!isSymbol(symbol))
const lines = LINES.exec(fragment)
if (lines) {
const start = Number(lines[1])
const end = lines[2] === undefined ? start : Number(lines[2])
return start >= 1 && end >= start ? { path, lines: { start, end } } : undefined
}
if (!isSymbol(fragment))
return undefined
return { path, symbol }
return { path, symbol: fragment }
}
function hasControl(text: string): boolean {
@@ -84,6 +97,26 @@ function isSymbol(symbol: string): boolean {
return (parts.length === 1 || parts.length === 2) && parts.every(part => IDENT.test(part))
}
/** 行内代码里的「路径」「路径:行」「路径:起-止」:路径里没有空白、冒号、`#`、`?`、反斜杠。 */
const CODE_LOCATION = /^([^\s:#?\\]+)(?::(\d+)(?:-(\d+))?)?$/
/** 路径最后一段像文件名:点前面有字,后面是字母开头的后缀。 */
const FILE_NAME = /[^/]\.[a-z][a-z0-9]*$/i
/**
* 行内代码整段写的是一处代码位置(`backend/app/routes.py:96-135`)时,把它当仓库链接的地址:`路径:行` 是 `路径#L行`,`路径:起-止` 是 `路径#L起-L止`,只有路径就是路径本身。
* 路径得带 `/`、最后一段像文件名,免得把 `a:b`、`12:30`、`application/json` 认成位置;地址还得 [parseRepoLink] 认(不认绝对路径、跑出仓库的、不对的行号)。不是这样的没有。
*/
export function codeLocationHref(code: string): string | undefined {
const match = CODE_LOCATION.exec(code)
if (!match)
return undefined
const [, path, start, end] = match
if (!path.includes('/') || !FILE_NAME.test(path))
return undefined
const href = start === undefined ? path : `${path}#L${start}${end === undefined ? '' : `-L${end}`}`
return parseRepoLink(href) === undefined ? undefined : href
}
/**
* 页面上一个链接元素是哪种能点的链接(同 parseCommentLink)。不带路径的引用再带上它写在哪块的注释里:离它最近的那张块卡片(.cr-block)
* 上记着的块记号(data-anchor);不在块卡片里的、那张卡片没记块记号的(删掉的块)不带,扩展端只从文件顶层找。
@@ -14,7 +14,7 @@ import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
import { placementOf } from './placement'
import { cardTexts, occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { cardTexts, linesPlan, occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { placed } from './testBlocks'
import { collapsedIds, propertyItemId, propertyRowId } from './viewState'
@@ -328,6 +328,38 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
})
})
describe('跳到几行:滚到哪块、展开哪些', () => {
/**
* 1 函数(到第 8 行)
* 3 如果(到第 4 行)
* 4 返回
* 10 函数(到第 12 行)
*/
const ret = textBlock('0.0.0', 'return', 4, [word('返回')])
const branch: WrittenBlock = { ...textBlock('0.0', 'if', 3, [word('如果')], [ret]), lines: [3, 4] }
const blocks: WrittenBlock[] = [
{ ...textBlock('0', 'function', 1, [word('函数')], [branch]), lines: [1, 8] },
{ ...textBlock('1', 'function', 10, [word('函数')]), lines: [10, 12] },
]
function linesIn(written: WrittenBlock[], start: number, end: number): RevealPlan | undefined {
const page = placed(written)
return linesPlan(page, placementOf(page), start, end)
}
it('取把这几行整段包住的最小那块,展开它的祖先块', () => {
expect(linesIn(blocks, 3, 4)).toEqual({ id: '0.0', expand: ['0'] })
expect(linesIn(blocks, 4, 4)).toEqual({ id: '0.0.0', expand: ['0', '0.0'] })
expect(linesIn(blocks, 2, 6)).toEqual({ id: '0', expand: [] })
expect(linesIn(blocks, 10, 11)).toEqual({ id: '1', expand: [] })
})
it('没有一块包得住整段(跨了两块)时取包住第一行的块;第一行不在任何块里取第一块;没有块时没有目标', () => {
expect(linesIn(blocks, 4, 11)).toEqual({ id: '0.0.0', expand: ['0', '0.0'] })
expect(linesIn(blocks, 0, 0)).toEqual({ id: '0', expand: [] })
expect(linesIn([], 1, 2)).toBeUndefined()
})
})
describe('跳到定义:闪哪一处(defines)', () => {
const range = (startIndex: number, endIndex: number): { startIndex: number, endIndex: number } => ({ startIndex, endIndex })
/**
+25 -13
View File
@@ -167,14 +167,11 @@ function closer(spot: Spot, best: Spot): boolean {
* 父块的大范围盖住后面那个函数的名字时,停在函数上,不闪父块。
* 一样短时,讲这个定义的块胜过只是显示着这个名字的块,再一样就取先遇到的——
* 路由器卡片的接口表里显示着的函数名和下面接口卡片自己的名字一样长,停在接口卡片上,不闪接口表里那一格。
* 页面上找不到这处定义,就取包住 line 的块(块在文件里的首尾行之间);几块都包住时取行数最少的,一样少时取先遇到的外层。
* 没有任何块包住这一行(它在所有块之前,或落在块与块的空档)时取第一块。没有块时没有目标。
* 页面上找不到这处定义,就按行找包住 line 的块(见 [linesPlan])。
* 目标的祖先块都要展开。line、definition 是文件里的,各块里的位置按 placement 换算。
*/
export function revealPlan(blocks: BlockView[], placement: Placement, line: number, definition: number): RevealPlan | undefined {
let best: { plan: RevealPlan, spot: Spot } | undefined
let byLine: Placed | undefined
let byLineSpan = Number.POSITIVE_INFINITY
for (const item of placedBlocks(blocks)) {
const place = placement.of(item.block.id)
if (!place)
@@ -182,17 +179,32 @@ export function revealPlan(blocks: BlockView[], placement: Placement, line: numb
const spot = locate(item.block, definition - place.offset)
if (spot && (best === undefined || closer(spot, best.spot)))
best = { spot, plan: { id: item.block.id, ...(spot.row !== undefined && { row: spot.row }), expand: [...item.ancestors, ...spot.expand] } }
if (place.line <= line && line <= place.endLine) {
const span = place.endLine - place.line
if (span < byLineSpan) {
byLine = item
byLineSpan = span
}
}
return best ? best.plan : linesPlan(blocks, placement, line, line)
}
/** 把 `start` 到 `end` 行整段包住的块里行数最少的那块;一样少时取先遇到的外层。没有块包得住时没有。 */
function smallestCovering(blocks: BlockView[], placement: Placement, start: number, end: number): Placed | undefined {
let found: Placed | undefined
let span = Number.POSITIVE_INFINITY
for (const item of placedBlocks(blocks)) {
const place = placement.of(item.block.id)
if (place && place.line <= start && end <= place.endLine && place.endLine - place.line < span) {
found = item
span = place.endLine - place.line
}
}
if (best)
return best.plan
const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
return found
}
/**
* 跳到几行(`start` 到 `end`,文件里的行,0 起):取把这几行整段包住的块里行数最少的那块(一样少时取先遇到的外层),展开它的祖先块。
* 没有一块包得住整段(跨了两块)时只看第一行;第一行也不在任何块里(在所有块之前,或落在块与块的空档)时取第一块。没有块时没有目标。
*/
export function linesPlan(blocks: BlockView[], placement: Placement, start: number, end: number): RevealPlan | undefined {
const target = smallestCovering(blocks, placement, start, end)
?? smallestCovering(blocks, placement, start, start)
?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
return target && { id: target.block.id, expand: target.ancestors }
}
+12 -3
View File
@@ -225,11 +225,19 @@ export type Excluded = Record<string, string[]>
/**
* 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
* 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下
* 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下。
* endLine 只在跳到几行(line 到 endLine)时有:展开、闪包住这几行的那块。都没有是从文本编辑器切过来,只滚不闪
*/
export interface Reveal {
line: number
definition?: number
endLine?: number
}
/** 文件里的几行:从 start 行到 end 行,1 起、两头都算,同链接里的 `#L起-L止` */
export interface LineRange {
start: number
end: number
}
/**
@@ -627,7 +635,8 @@ export type ExtensionToWebview
* selectProfile:kind 那种对话换 profile;不在本地先改,等扩展端发回这种的 profiles 再按它显示
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
* findNavigation:眼下查找栏的快捷键(下一处 / 上一处)归不归这个页面(查找栏开着、页面有焦点),变了才发
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,lines 是文件里的几行(和 symbol 二选一),
* 都没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference),
* 从那块的声明起一层层往外找
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问
@@ -657,7 +666,7 @@ export type WebviewToExtension
| { type: 'selectProfile', kind: ChatKind, id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'findNavigation', available: boolean }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string, lines?: LineRange }
| { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
| { type: 'openCallTree', version: number, anchor: string }
@@ -3,7 +3,7 @@ import { useLayoutEffect } from 'react'
import { blockPath } from './filter'
import { pageLayout } from './pageLayout'
import { placementOf } from './placement'
import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
import { linesPlan, revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
import { scrollOwner } from './scrollOwner'
import { stickyHeight } from './stickyScroll'
@@ -53,11 +53,12 @@ function nameAt(block: HTMLElement, offset: number): HTMLElement | null {
/**
* 滚到 reveal 指的位置,处理完调 done:
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
* ① 从文本编辑器切过来(没有 definition,也没有 endLine):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
* ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
* 每一步之后重新渲染、再进来一次;最后把目标滚进筛选栏下面并闪几下。
* 属性行 / 子行是这处定义时闪那一行;这块里显示着这个名字(下标盖住定义处)就闪这个名字,不闪整张卡片;
* 名字不在页面上才闪整块。目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。
* ③ 跳到几行(有 endLine,点了带 `#L起-L止` 的链接):目标是包住这几行的块(见 [linesPlan]),露出、展开、滚过去同 ②,闪整块。
* 滚过去以后由 [scrollOwner] 跟住目标(① 跟住那块),页面再长高(注释里的图画出来、中文名到了)也停在原处。
*/
export function useReveal(
@@ -73,8 +74,8 @@ export function useReveal(
useLayoutEffect(() => {
if (!doc || !reveal)
return
const { line, definition } = reveal
if (definition === undefined) {
const { line, definition, endLine } = reveal
if (definition === undefined && endLine === undefined) {
const card = pageLayout.cardForLine(line)
if (card)
scrollOwner.align({ line: card.line, offset: 0, scrollTop: card.top })
@@ -82,7 +83,7 @@ export function useReveal(
return
}
const placement = placementOf(doc.blocks)
const found = revealPlan(doc.blocks, placement, line, definition)
const found = definition === undefined ? linesPlan(doc.blocks, placement, line, endLine ?? line) : revealPlan(doc.blocks, placement, line, definition)
const plan = found && uncoveredPlan(found, covered)
const path = plan ? blockPath(plan.id) : []
if (path.some(id => filtered.has(id))) {
@@ -99,7 +100,7 @@ export function useReveal(
const locate = (): HTMLElement | null => {
const block = plan ? document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`)
const name = row || !block || base === undefined ? null : nameAt(block, definition - base)
const name = row || !block || base === undefined || definition === undefined ? null : nameAt(block, definition - base)
return row ?? name ?? block
}
const el = locate()