🐛 fix(vscode): 代码阅读注释里的链接指到别的文件时用预览打开,和 ⌘ 点击跳到定义走同一条路
This commit is contained in:
1 parent
e04a90c877
commit
bc7380c716
9 files changed
+123
-72
No files matched your search
@@ -4,8 +4,8 @@ import { definitionAction } from './definitionAction'
|
||||
|
||||
const CURRENT = 'file:///ws/app/routes.py'
|
||||
|
||||
function at(uri: string, line: number, supported = true, character = 4): DefinitionTarget {
|
||||
return { uri, line, character, supported }
|
||||
function at(uri: string, line: number, character = 4): DefinitionTarget {
|
||||
return { uri, line, character }
|
||||
}
|
||||
|
||||
describe('⌘ + 点击查到定义之后怎么跳', () => {
|
||||
@@ -18,27 +18,22 @@ describe('⌘ + 点击查到定义之后怎么跳', () => {
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'here', target })
|
||||
})
|
||||
|
||||
it('另一个文件、语言有代码阅读:用预览打开', () => {
|
||||
it('另一个文件:交给打开别的文件那一条路', () => {
|
||||
const target = at('file:///ws/luffy_agent/core/db.py', 30)
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'preview', target })
|
||||
})
|
||||
|
||||
it('另一个文件、语言没有代码阅读:用普通文本编辑器打开', () => {
|
||||
const target = at('file:///ws/native/ext.c', 7, false)
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'text', target })
|
||||
expect(definitionAction([target], CURRENT)).toEqual({ kind: 'elsewhere', target })
|
||||
})
|
||||
|
||||
it('几处定义:让用户挑,挑好的一处再按上面的规则跳', () => {
|
||||
const stub = at('file:///ws/typings/db.pyi', 3)
|
||||
const source = at('file:///ws/luffy_agent/core/db.py', 30)
|
||||
expect(definitionAction([stub, source], CURRENT)).toEqual({ kind: 'pick', targets: [stub, source] })
|
||||
expect(definitionAction([source], CURRENT)).toEqual({ kind: 'preview', target: source })
|
||||
expect(definitionAction([source], CURRENT)).toEqual({ kind: 'elsewhere', target: source })
|
||||
})
|
||||
|
||||
it('同一处报了几遍算一处;同一行不同列算两处', () => {
|
||||
const target = at('file:///ws/luffy_agent/core/db.py', 30)
|
||||
expect(definitionAction([target, { ...target }], CURRENT)).toEqual({ kind: 'preview', target })
|
||||
const other = at(target.uri, 30, true, 20)
|
||||
expect(definitionAction([target, { ...target }], CURRENT)).toEqual({ kind: 'elsewhere', target })
|
||||
const other = at(target.uri, 30, 20)
|
||||
expect(definitionAction([target, other], CURRENT)).toEqual({ kind: 'pick', targets: [target, other] })
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,19 @@
|
||||
/** 一处定义。uri 是 Uri.toString(),用来和当前文件比、去重;supported:目标文件的语言有代码阅读。 */
|
||||
/** 一处定义。uri 是 Uri.toString(),用来和当前文件比、去重。 */
|
||||
export interface DefinitionTarget {
|
||||
uri: string
|
||||
line: number
|
||||
character: number
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌘ / Ctrl + 点击查到定义之后怎么做:
|
||||
* none 不动;pick 让用户从几处里挑一处,挑好的那处再交给 definitionAction 决定;
|
||||
* here 定义就在当前文件,预览原地滚过去;preview 用代码阅读预览打开目标文件;text 目标语言没有代码阅读,用普通文本编辑器打开。
|
||||
* here 定义就在当前文件,预览原地滚过去;elsewhere 在另一个文件,用哪种编辑器打开见 openTarget.ts 的 openingFor。
|
||||
*/
|
||||
export type DefinitionAction<T extends DefinitionTarget>
|
||||
= | { kind: 'none' }
|
||||
| { kind: 'pick', targets: T[] }
|
||||
| { kind: 'here' | 'preview' | 'text', target: T }
|
||||
| { kind: 'here' | 'elsewhere', target: T }
|
||||
|
||||
/** 定义提供者查到的几处(可能重复)→ 怎么跳。current 是当前文件的 Uri.toString()。 */
|
||||
export function definitionAction<T extends DefinitionTarget>(targets: readonly T[], current: string): DefinitionAction<T> {
|
||||
@@ -25,7 +24,5 @@ export function definitionAction<T extends DefinitionTarget>(targets: readonly T
|
||||
if (unique.length > 1)
|
||||
return { kind: 'pick', targets: unique }
|
||||
const [target] = unique
|
||||
if (target.uri === current)
|
||||
return { kind: 'here', target }
|
||||
return { kind: target.supported ? 'preview' : 'text', target }
|
||||
return { kind: target.uri === current ? 'here' : 'elsewhere', target }
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export async function definitionKind(
|
||||
}
|
||||
|
||||
/** 查到的各处定义打开目标文件补上行文字、下标;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */
|
||||
export async function findDefinitions(locations: readonly FoundLocation[], supports: (languageId: string) => boolean): Promise<Definition[]> {
|
||||
export async function findDefinitions(locations: readonly FoundLocation[]): Promise<Definition[]> {
|
||||
const found = await Promise.all(locations.map(async (location): Promise<Definition[]> => {
|
||||
const { start } = location.range
|
||||
try {
|
||||
@@ -158,7 +158,6 @@ export async function findDefinitions(locations: readonly FoundLocation[], suppo
|
||||
uri: location.uri.toString(),
|
||||
line: start.line,
|
||||
character: start.character,
|
||||
supported: supports(document.languageId),
|
||||
location,
|
||||
offset: document.offsetAt(start),
|
||||
lineText: document.lineAt(start.line).text,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Range, Uri } from 'vscode'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { workspace } from 'vscode'
|
||||
import { languageOf, openingFor } from './openTarget'
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
workspace: { openTextDocument: vi.fn() },
|
||||
}))
|
||||
|
||||
const supports = (languageId: string): boolean => languageId === 'python'
|
||||
const range = { start: { line: 41, character: 6 }, end: { line: 41, character: 28 } } as Range
|
||||
const spot = { range, offset: 1200 }
|
||||
|
||||
describe('打开别的文件用哪种编辑器', () => {
|
||||
it('语言有代码阅读:用预览打开,给了位置就滚到那一行、闪这处定义', () => {
|
||||
expect(openingFor('python', supports, spot)).toEqual({ kind: 'preview', reveal: { line: 41, definition: 1200 } })
|
||||
})
|
||||
|
||||
it('语言有代码阅读、只打开文件(不带符号的仓库链接):用预览打开,不滚', () => {
|
||||
expect(openingFor('python', supports)).toEqual({ kind: 'preview' })
|
||||
})
|
||||
|
||||
it('语言没有代码阅读:VS Code 默认的打开方式,选中定义处的名字', () => {
|
||||
expect(openingFor('c', supports, spot)).toEqual({ kind: 'default', selection: range })
|
||||
})
|
||||
|
||||
it('打不开成文本(图片):VS Code 默认的打开方式', () => {
|
||||
expect(openingFor(undefined, supports)).toEqual({ kind: 'default' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('文件的语言', () => {
|
||||
it('按 VS Code 打开的文档认;打不开成文本时没有', async () => {
|
||||
const openTextDocument = vi.mocked(workspace.openTextDocument)
|
||||
openTextDocument.mockResolvedValueOnce({ languageId: 'python' } as Awaited<ReturnType<typeof workspace.openTextDocument>>)
|
||||
expect(await languageOf({} as Uri)).toBe('python')
|
||||
openTextDocument.mockRejectedValueOnce(new Error('binary'))
|
||||
expect(await languageOf({} as Uri)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Range, Uri } from 'vscode'
|
||||
import type { Reveal } from './messages'
|
||||
import { workspace } from 'vscode'
|
||||
|
||||
/** 别的文件里要打开的一处:名字的范围(普通编辑器选中它)和起点下标(预览滚过去、闪这处定义)。 */
|
||||
export interface TargetSpot {
|
||||
range: Range
|
||||
offset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一个文件用哪种编辑器:preview 用代码阅读预览打开,带了 reveal 就滚过去、闪这处;
|
||||
* default 交给 VS Code 默认的打开方式(普通编辑器,带了 selection 就选中它;图片看图)。
|
||||
*/
|
||||
export type Opening
|
||||
= | { kind: 'preview', reveal?: Reveal }
|
||||
| { kind: 'default', selection?: Range }
|
||||
|
||||
/**
|
||||
* 语言有代码阅读的用预览,其余用 VS Code 默认的打开方式;languageId 是 undefined 表示打不开成文本(图片等二进制文件)。
|
||||
* 侧栏点文件、⌘ / Ctrl + 点击跳到别的文件里的定义、点注释里的仓库链接和不带路径的引用,都按它定。
|
||||
*/
|
||||
export function openingFor(languageId: string | undefined, supports: (languageId: string) => boolean, spot?: TargetSpot): Opening {
|
||||
if (languageId !== undefined && supports(languageId))
|
||||
return { kind: 'preview', ...(spot && { reveal: { line: spot.range.start.line, definition: spot.offset } }) }
|
||||
return { kind: 'default', ...(spot && { selection: spot.range }) }
|
||||
}
|
||||
|
||||
/** 按 VS Code 的判断(含 files.associations)取文件的 languageId;图片等二进制文件打不开成文本,返回 undefined。 */
|
||||
export async function languageOf(uri: Uri): Promise<string | undefined> {
|
||||
try {
|
||||
return (await workspace.openTextDocument(uri)).languageId
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
|
||||
import type { IndexedEntry } from '../workspaceIndex'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { Definition, FoundLocation } from './definitions'
|
||||
import type { FoundLocation } from './definitions'
|
||||
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import type { TargetSpot } from './openTarget'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { commands, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
@@ -37,8 +38,9 @@ import { DefinitionSources } from './definitionSources'
|
||||
import { documentMessage } from './documentMessage'
|
||||
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
|
||||
import { occurrencesOf } from './occurrences'
|
||||
import { languageOf, openingFor } from './openTarget'
|
||||
import { pathPreviewOf } from './pathPreview'
|
||||
import { findRepoSymbol, isImageFile, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
|
||||
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
|
||||
@@ -620,8 +622,7 @@ class Preview {
|
||||
if (version !== document.version)
|
||||
return
|
||||
const current = document.uri.toString()
|
||||
const supports = (languageId: string): boolean => this.registry.languageFor(languageId) !== undefined
|
||||
let action = definitionAction(await findDefinitions(await this.definitions.locate(version, offset), supports), current)
|
||||
let action = definitionAction(await findDefinitions(await this.definitions.locate(version, offset)), current)
|
||||
if (action.kind === 'pick') {
|
||||
const picked = await pickDefinition(action.targets)
|
||||
action = definitionAction(picked ? [picked] : [], current)
|
||||
@@ -629,13 +630,12 @@ class Preview {
|
||||
if (action.kind === 'none' || action.kind === 'pick')
|
||||
return
|
||||
const { target } = action
|
||||
const reveal: Reveal = { line: target.line, definition: target.offset }
|
||||
if (action.kind === 'here') {
|
||||
this.reveal(reveal)
|
||||
this.reveal({ line: target.line, definition: target.offset })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.openDefinition(action.kind, target, reveal)
|
||||
await this.openElsewhere(target.location.uri, { range: target.location.range, offset: target.offset })
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -645,18 +645,24 @@ class Preview {
|
||||
}
|
||||
|
||||
/**
|
||||
* 和普通编辑器里 ⌘ + 点击一样:在同一个编辑器组打开目标(是否按「预览标签」打开听 VS Code 的设置),
|
||||
* 并按 workbench.editor.enablePreviewFromCodeNavigation 把当前这个预览标签固定住,免得被目标替掉。
|
||||
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处;
|
||||
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
|
||||
* workbench.editor.enablePreviewFromCodeNavigation 为假时先把当前这个预览标签固定住,免得被目标替掉。
|
||||
*/
|
||||
private async openDefinition(kind: 'preview' | 'text', target: Definition, reveal: Reveal): Promise<void> {
|
||||
private async openElsewhere(uri: Uri, spot?: TargetSpot): Promise<void> {
|
||||
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('enablePreviewFromCodeNavigation', false))
|
||||
await commands.executeCommand('workbench.action.keepEditor')
|
||||
const options: TextDocumentShowOptions = { viewColumn: this.panel.viewColumn }
|
||||
const { uri, range } = target.location
|
||||
if (kind === 'text')
|
||||
await window.showTextDocument(uri, { ...options, selection: range })
|
||||
const opening = openingFor(await languageOf(uri), languageId => this.registry.languageFor(languageId) !== undefined, spot)
|
||||
if (opening.kind === 'default') {
|
||||
await commands.executeCommand('vscode.open', uri, { ...options, ...(opening.selection && { selection: opening.selection }) })
|
||||
return
|
||||
}
|
||||
const open = (): Thenable<unknown> => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)
|
||||
if (opening.reveal)
|
||||
await this.owner.openAt(uri, opening.reveal, open)
|
||||
else
|
||||
await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
|
||||
await open()
|
||||
}
|
||||
|
||||
/** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,哪些找不到。 */
|
||||
@@ -754,7 +760,7 @@ class Preview {
|
||||
return
|
||||
}
|
||||
if (symbol === undefined) {
|
||||
await this.showRepoFile(uri)
|
||||
await this.openElsewhere(uri)
|
||||
this.markRepoLinkOpened(rel)
|
||||
return
|
||||
}
|
||||
@@ -818,25 +824,14 @@ class Preview {
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳到链接指的符号:在这份文件里就在本预览里滚过去、闪一下;别的文件在预览所在的编辑器组打开、选中符号的名字。 */
|
||||
/** 跳到链接指的符号:在这份文件里就在本预览里滚过去、闪一下;别的文件同 ⌘ + 点击跳到定义(见 openElsewhere)。 */
|
||||
private async showLinkTarget({ uri, selection }: LinkTarget): Promise<void> {
|
||||
if (uri === this.document.uri.toString()) {
|
||||
this.reveal({ line: this.document.positionAt(selection.startIndex).line, definition: selection.startIndex })
|
||||
return
|
||||
}
|
||||
const doc = await workspace.openTextDocument(Uri.parse(uri))
|
||||
await this.showRepoFile(doc.uri, new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)))
|
||||
}
|
||||
|
||||
/** 在预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。图片用默认打开方式,才看得到图。 */
|
||||
private async showRepoFile(uri: Uri, selection?: Range): Promise<void> {
|
||||
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('enablePreviewFromCodeNavigation', false))
|
||||
await commands.executeCommand('workbench.action.keepEditor')
|
||||
if (selection === undefined && isImageFile(uri.fsPath)) {
|
||||
await commands.executeCommand('vscode.open', uri, { viewColumn: this.panel.viewColumn })
|
||||
return
|
||||
}
|
||||
await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) })
|
||||
await this.openElsewhere(doc.uri, { range: new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)), offset: selection.startIndex })
|
||||
}
|
||||
|
||||
private markRepoLinkBroken(path: string | undefined, symbol?: string): void {
|
||||
|
||||
@@ -33,14 +33,9 @@ export function isSafeRepoPath(rel: string): boolean {
|
||||
/** 注释里当图片显示的文件后缀。webview 的 repoImage.ts 用同一份。 */
|
||||
const IMAGE_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i
|
||||
|
||||
/** 文件名是图片。绝对路径也能用来判断,不检查有没有跑出仓库。 */
|
||||
export function isImageFile(filePath: string): boolean {
|
||||
return IMAGE_FILE.test(filePath)
|
||||
}
|
||||
|
||||
/** 从仓库根起、能在注释里当图片显示的路径。别的仓库文件不给预览地址。 */
|
||||
export function isRepoImagePath(rel: string): boolean {
|
||||
return isSafeRepoPath(rel) && isImageFile(rel)
|
||||
return isSafeRepoPath(rel) && IMAGE_FILE.test(rel)
|
||||
}
|
||||
|
||||
/** target 解析之后还在 root 里面(root 自己不算)。 */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { shellCommandRules } from './commands/shellCommands'
|
||||
import { systemCommandRules } from './commands/systemCommands'
|
||||
import { BlockFilter } from './editor/blockFilter'
|
||||
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
|
||||
import { languageOf, openingFor } from './editor/openTarget'
|
||||
import { PATH_PREVIEW_SCHEME } from './editor/pathPreview'
|
||||
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
|
||||
import { showPreview, showSource } from './editor/switchEditor'
|
||||
@@ -59,20 +60,10 @@ const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds'
|
||||
const FILES_VIEW = 'superpowers.codeReader.files'
|
||||
const OPEN_COMMAND = 'superpowers.codeReader.open'
|
||||
|
||||
/** 按 VS Code 的判断(含 files.associations)取文件的 languageId;图片等二进制文件打不开成文本,返回 undefined。 */
|
||||
async function languageOf(uri: Uri): Promise<string | undefined> {
|
||||
try {
|
||||
return (await workspace.openTextDocument(uri)).languageId
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 侧栏点文件:语言受支持就用预览打开,否则用普通编辑器。 */
|
||||
/** 侧栏点文件:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。 */
|
||||
async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
const languageId = await languageOf(uri)
|
||||
if (languageId !== undefined && registry.languageFor(languageId))
|
||||
const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined)
|
||||
if (opening.kind === 'preview')
|
||||
// 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定)
|
||||
await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })
|
||||
else
|
||||
|
||||
Reference in new issue
Block a user