✨ feat(vscode): 跳定义、点链接、编辑切回预览不改开着的预览对比什么

This commit is contained in:
cruldra committed 2026-10-12 16:10:41 +11:00
1 parent d4416e7b26
commit ce5327dcd5
8 files changed
+239 -47

No files matched your search

+1 -1
View File
@@ -136,7 +136,7 @@ export interface StickyScrollSettings {
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发,
* 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了)
* openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又被点开再发
* openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又从侧栏点开、换了从哪儿打开的再发
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
@@ -218,7 +218,28 @@ describe('预览对比什么看这个标签页是从哪儿打开的', () => {
await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead))
})
it('开着的预览再从别处点开就不比,再从「未提交的更改」点开又比;只切标签页不变', async () => {
it('跳定义、点链接打到开着的预览不改它对比什么、只滚过去;要新开的预览不比', async () => {
const { provider, document, diffBase } = opening()
const page = tab()
await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel))
await page.send({ type: 'ready' })
await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead))
page.clear()
await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => {})
expect(page.posted()).toEqual([{ type: 'reveal', line: 1 }])
expect(provider.openedFrom(document.uri as never)).toBe('uncommitted')
diffBase.mockClear()
const fresh = tab()
await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, fresh.panel))
await fresh.send({ type: 'ready' })
expect(fresh.posted()).toContainEqual({ type: 'openedFrom', from: 'other' })
expect(diffBase).toHaveBeenCalledWith(document.uri, 'other')
expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted')
})
it('开着的预览再从主栏点开就不比,再从「未提交的更改」点开又比;只切标签页不变', async () => {
const { provider, document } = opening()
const page = tab()
await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel))
+30 -12
View File
@@ -54,9 +54,11 @@ const REFRESH_DELAY_MS = 150
/**
* 一次打开预览带的话:这个标签页是从哪儿打开的(定对比什么);要滚到哪(从文本编辑器切过来时它顶部那一行,或跳到的定义);
* 要不要把光标放进顶部「问 AI」输入框(侧栏「问 AI」打开的)。
*
* 只有侧栏各栏点开文件、「编辑」切回「预览」带 `from`;跳定义、点链接、问 AI 这些只是去看一眼,不带:新开的预览不比,已经开着的照旧。
*/
export interface OpenRequest {
from: OpenedFrom
from?: OpenedFrom
reveal?: Reveal
focusPath?: boolean
}
@@ -149,9 +151,19 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
/** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */
visibleLine(uri: Uri): number | undefined {
return this.shown(uri)?.visibleLine
}
/** uri 的预览这个标签页是从哪儿打开的;几个编辑器组里都开着时取活动的那个,页面还没准备好、不知道的没有。 */
openedFrom(uri: Uri): OpenedFrom | undefined {
return this.shown(uri)?.openedFrom
}
/** uri 的预览:几个编辑器组里都开着时取活动的那个。 */
private shown(uri: Uri): Preview | undefined {
const key = uri.toString()
const previews = [...this.previews].filter(p => p.uri.toString() === key)
return (previews.find(p => p.active) ?? previews[0])?.visibleLine
return previews.find(p => p.active) ?? previews[0]
}
}
@@ -201,7 +213,7 @@ class Preview {
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
*/
private pendingReveal: Reveal | undefined
/** 这个标签页是从哪儿打开的,定对比什么;没带打开请求的(VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */
/** 这个标签页是从哪儿打开的,定对比什么;打开请求没说的(跳定义新开的、VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */
private from: OpenedFrom | undefined
constructor(
@@ -334,6 +346,10 @@ class Preview {
return this.panel.active
}
get openedFrom(): OpenedFrom | undefined {
return this.from
}
async push(): Promise<void> {
clearTimeout(this.timer)
if (!this.ready)
@@ -470,13 +486,15 @@ class Preview {
this.post({ type: 'moveOccurrence', step })
}
/** 开着时又被点开(见 [PreviewEditorProvider.open]):按这次是从哪儿打开的重新比,要滚到哪、要不要聚焦输入框照这次的。 */
/** 开着时又被点开(见 [PreviewEditorProvider.open]):这次说了从哪儿打开的就按它重新比,没说的照旧;要滚到哪、要不要聚焦输入框照这次的。 */
reopen(request: OpenRequest): void {
this.from = request.from
if (this.ready) {
this.post({ type: 'openedFrom', from: request.from })
if (this.sent)
void this.diff.update(this.sent)
if (request.from !== undefined) {
this.from = request.from
if (this.ready) {
this.post({ type: 'openedFrom', from: request.from })
if (this.sent)
void this.diff.update(this.sent)
}
}
if (request.reveal)
this.reveal(request.reveal)
@@ -575,7 +593,7 @@ class Preview {
switch (msg.type) {
case 'ready':
this.ready = true
// 打开请求说了从哪儿打开的就按它;没说的(VS Code 重启后恢复的)按页面记着的,页面也没记过的不算从「未提交的更改」点开的
// 打开请求说了从哪儿打开的就按它;没说的按页面记着的(VS Code 重启后恢复的),页面也没记过的(新开的)不比
this.from ??= msg.openedFrom ?? 'other'
this.post({ type: 'openedFrom', from: this.from })
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
@@ -794,7 +812,7 @@ class Preview {
}
/**
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(算从别处打开的,不对比上一次提交),给了 spot 就滚过去、闪这处;
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(新开的不比,已经开着的照旧),给了 spot 就滚过去、闪这处;
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
* workbench.editor.enablePreviewFromCodeNavigation 为假时先把当前这个预览标签固定住,免得被目标替掉。
*/
@@ -807,7 +825,7 @@ class Preview {
await commands.executeCommand('vscode.open', uri, { ...options, ...(opening.selection && { selection: opening.selection }) })
return
}
await this.owner.open(uri, { from: 'other', ...(opening.reveal && { reveal: opening.reveal }) }, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
await this.owner.open(uri, opening.reveal ? { reveal: opening.reveal } : {}, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
}
/** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */
@@ -0,0 +1,112 @@
import type { OpenedFrom } from '../diff/diffBase'
import type { OpenRequest } from './previewEditor'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EditorSwitch } from './switchEditor'
/** 假的 VS Code:文件、标签页的输入、活动的文本编辑器、标签页关掉的通知 */
const fake = vi.hoisted(() => {
class Uri {
constructor(readonly path: string) {}
toString(): string {
return `file://${this.path}`
}
}
class TabInputText {
constructor(readonly uri: Uri) {}
}
class TabInputCustom {
constructor(readonly uri: Uri, readonly viewType: string) {}
}
const tabListeners = new Set<(event: { closed: unknown[] }) => void>()
const window = {
activeTextEditor: undefined as unknown,
visibleTextEditors: [] as unknown[],
onDidChangeVisibleTextEditors: () => ({ dispose: () => {} }),
tabGroups: {
activeTabGroup: { activeTab: undefined },
onDidChangeTabs: (listener: (event: { closed: unknown[] }) => void) => {
tabListeners.add(listener)
return { dispose: () => tabListeners.delete(listener) }
},
},
}
return { Uri, TabInputText, TabInputCustom, window, tabListeners, executeCommand: vi.fn() }
})
vi.mock('vscode', () => ({
Uri: fake.Uri,
TabInputText: fake.TabInputText,
TabInputCustom: fake.TabInputCustom,
window: fake.window,
commands: { executeCommand: fake.executeCommand },
Range: class {
constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {}
},
TextEditorRevealType: { AtTop: 3 },
}))
vi.mock('./previewEditor', () => ({ PREVIEW_VIEW_TYPE: 'superpowers.codeReader.preview' }))
const uri = new fake.Uri('/workspace/web/a.ts')
/** 第 1 个编辑器组里 a.ts 的文本编辑器,顶部是第 10 行 */
const editor = { document: { uri, lineCount: 100 }, viewColumn: 1, visibleRanges: [{ start: { line: 10 } }], revealRange: vi.fn() }
/** 开着的预览:从 from 打开的;open 照 VS Code 那样执行打开 */
function previews(from: OpenedFrom | undefined) {
return {
visibleLine: () => 10,
openedFrom: () => from,
open: vi.fn(async (_uri: unknown, _request: OpenRequest, show: () => Thenable<unknown>) => {
await show()
}),
}
}
beforeEach(() => {
fake.window.activeTextEditor = undefined
fake.window.visibleTextEditors = []
fake.tabListeners.clear()
// 换成文本编辑器后,a.ts 的文本编辑器就是活动的那个
fake.executeCommand.mockImplementation(async (command: string, editorId: string) => {
if (command === 'reopenActiveEditorWith' && editorId === 'default') {
fake.window.activeTextEditor = editor
fake.window.visibleTextEditors = [editor]
}
})
})
describe('编辑器右上角「预览 / 编辑」切换', () => {
it('预览切到文本编辑器再切回来:照切走前的从哪儿打开,滚到文本编辑器顶部那一行', async () => {
const opened = previews('uncommitted')
const switcher = new EditorSwitch(opened as never)
await switcher.showSource([uri, { groupId: 1 }])
await switcher.showPreview([uri, { groupId: 1 }])
expect(opened.open).toHaveBeenLastCalledWith(uri, { from: 'uncommitted', reveal: { line: 10 } }, expect.any(Function))
switcher.dispose()
})
it('文本编辑器不是从预览切过来的:切到预览不带从哪儿打开(新开的预览不比)', async () => {
const opened = previews('uncommitted')
const switcher = new EditorSwitch(opened as never)
fake.window.activeTextEditor = editor
fake.window.visibleTextEditors = [editor]
await switcher.showPreview([uri, { groupId: 1 }])
expect(opened.open).toHaveBeenLastCalledWith(uri, { reveal: { line: 10 } }, expect.any(Function))
switcher.dispose()
})
it('切过去的那个文本编辑器标签页关了就忘掉;别的编辑器组里关的不算', async () => {
const opened = previews('uncommitted')
const switcher = new EditorSwitch(opened as never)
await switcher.showSource([uri, { groupId: 1 }])
fake.tabListeners.forEach(listener => listener({ closed: [{ input: new fake.TabInputText(uri), group: { viewColumn: 2 } }] }))
await switcher.showPreview([uri, { groupId: 1 }])
expect(opened.open).toHaveBeenLastCalledWith(uri, { from: 'uncommitted', reveal: { line: 10 } }, expect.any(Function))
fake.tabListeners.forEach(listener => listener({ closed: [{ input: new fake.TabInputText(uri), group: { viewColumn: 1 } }] }))
await switcher.showPreview([uri, { groupId: 1 }])
expect(opened.open).toHaveBeenLastCalledWith(uri, { reveal: { line: 10 } }, expect.any(Function))
switcher.dispose()
})
})
+60 -23
View File
@@ -1,6 +1,7 @@
import type { Disposable, TextEditor } from 'vscode'
import type { Disposable, TextEditor, ViewColumn } from 'vscode'
import type { OpenedFrom } from '../diff/diffBase'
import type { PreviewEditorProvider } from './previewEditor'
import { commands, Range, TabInputCustom, TextEditorRevealType, Uri, window } from 'vscode'
import { commands, Range, TabInputCustom, TabInputText, TextEditorRevealType, Uri, window } from 'vscode'
import { PREVIEW_VIEW_TYPE } from './previewEditor'
/** VS Code 自带普通文本编辑器的 editor id。 */
@@ -51,27 +52,63 @@ function waitForTextEditor(uri: Uri): Promise<TextEditor | undefined> {
}).finally(() => disposables.forEach(d => d.dispose()))
}
/** 文本编辑器 → 预览(同一标签页原位替换,算从别处打开的):新预览先滚到文本编辑器顶部那一行所在的块。 */
export async function showPreview(previews: PreviewEditorProvider, args: unknown[]): Promise<void> {
const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri
const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined
const reopen = (): Thenable<unknown> => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)
if (uri)
await previews.open(uri, { from: 'other', ...(topLine !== undefined && { reveal: { line: topLine } }) }, reopen)
else
await reopen()
/** 一个编辑器组里的一个文件:同一个标签页在预览和文本编辑器之间换着看时按它认。 */
function tabKey(viewColumn: ViewColumn | undefined, uri: Uri): string {
return `${viewColumn ?? ''}:${uri.toString()}`
}
/** 预览 → 文本编辑器(同一标签页原位替换):把预览视口顶部那块的起始行放到文本编辑器顶部。 */
export async function showSource(previews: PreviewEditorProvider, args: unknown[]): Promise<void> {
const uri = resourceArg(args) ?? activeCustomEditorUri()
const line = uri ? previews.visibleLine(uri) : undefined
await commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args)
if (!uri || line === undefined)
return
const editor = await waitForTextEditor(uri)
if (!editor)
return
const target = Math.min(line, editor.document.lineCount - 1)
editor.revealRange(new Range(target, 0, target, 0), TextEditorRevealType.AtTop)
/**
* 编辑器右上角的「预览 / 编辑」:同一个标签页原位换成另一种编辑器,换回预览时照切走前的样子对比,不算从别处重新打开。
* 切走的预览是从哪儿打开的按编辑器组和文件记着,换上的那个文本编辑器标签页关了就忘掉。
*/
export class EditorSwitch implements Disposable {
/** 切到文本编辑器的预览原来是从哪儿打开的,按 [tabKey] 记 */
private readonly sourceFrom = new Map<string, OpenedFrom>()
private readonly closing: Disposable
constructor(private readonly previews: Pick<PreviewEditorProvider, 'open' | 'visibleLine' | 'openedFrom'>) {
this.closing = window.tabGroups.onDidChangeTabs(({ closed }) => {
for (const tab of closed) {
if (tab.input instanceof TabInputText)
this.sourceFrom.delete(tabKey(tab.group.viewColumn, tab.input.uri))
}
})
}
/** 文本编辑器 → 预览:新预览先滚到文本编辑器顶部那一行所在的块;这个文本编辑器是从预览切过来的,照原来的从哪儿打开。 */
async showPreview(args: unknown[]): Promise<void> {
const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri
const reopen = (): Thenable<unknown> => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)
if (!uri) {
await reopen()
return
}
const editor = textEditorOf(uri)
const topLine = editor?.visibleRanges[0]?.start.line
const from = editor && this.sourceFrom.get(tabKey(editor.viewColumn, uri))
await this.previews.open(uri, { ...(from && { from }), ...(topLine !== undefined && { reveal: { line: topLine } }) }, reopen)
}
/** 预览 → 文本编辑器:把预览视口顶部那块的起始行放到文本编辑器顶部,记下这个预览是从哪儿打开的。 */
async showSource(args: unknown[]): Promise<void> {
const uri = resourceArg(args) ?? activeCustomEditorUri()
const line = uri ? this.previews.visibleLine(uri) : undefined
const from = uri ? this.previews.openedFrom(uri) : undefined
await commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args)
if (!uri || (line === undefined && from === undefined))
return
const editor = await waitForTextEditor(uri)
if (!editor)
return
if (from)
this.sourceFrom.set(tabKey(editor.viewColumn, uri), from)
if (line === undefined)
return
const target = Math.min(line, editor.document.lineCount - 1)
editor.revealRange(new Range(target, 0, target, 0), TextEditorRevealType.AtTop)
}
dispose(): void {
this.closing.dispose()
}
}
+7 -5
View File
@@ -16,7 +16,7 @@ import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigat
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'
import { EditorSwitch } from './editor/switchEditor'
import { WorkspaceEntries } from './entries/workspaceEntries'
import { registerCopyCommands } from './explorer/copyCommands'
import { FileTreeProvider } from './explorer/fileTree'
@@ -135,9 +135,10 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
uri => locator.reveal(uri),
(subject, request) => features.ask(subject, request),
)
const switcher = new EditorSwitch(previews)
const openChat = async (page: Uri | undefined): Promise<void> => {
if (page)
await previews.open(page, { from: 'other', focusPath: true })
await previews.open(page, { focusPath: true })
}
context.subscriptions.push(
@@ -153,7 +154,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
const languageId = await languageOf(uri)
return languageId !== undefined && registry.languageFor(languageId) !== undefined
},
uri => previews.open(uri, { from: 'other', focusPath: true }),
uri => previews.open(uri, { focusPath: true }),
),
workspace.registerTextDocumentContentProvider(PATH_PREVIEW_SCHEME, {
provideTextDocumentContent: () => '',
@@ -171,6 +172,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
favorites,
features,
heads,
switcher,
// 主栏审查时刷新是重读 PR 改过的文件,平时是重读工作区文件树
commands.registerCommand('superpowers.codeReader.refresh', () => review.reviewing ? review.reload() : files.refresh()),
commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()),
@@ -201,8 +203,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
await openFile(found.uri, 'other', registry, previews, found.spot)
}),
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)),
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => switcher.showPreview(args)),
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => switcher.showSource(args)),
commands.registerCommand(NEXT_OCCURRENCE, () => previews.moveOccurrence(1)),
commands.registerCommand(PREVIOUS_OCCURRENCE, () => previews.moveOccurrence(-1)),
)