✨ feat(vscode): 只有从「未提交的更改」点开的预览才对比上一次提交
This commit is contained in:
1 parent
4ec2b9d5d6
commit
3c7513b7cd
18 files changed
+366
-143
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import type { Uri } from 'vscode'
|
||||
import type { DiffBase, DiffBaseSource } from './diffBase'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { locatedDiffBases } from './diffBase'
|
||||
import { previewDiffBases } from './diffBase'
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
Disposable: {
|
||||
@@ -25,20 +25,24 @@ function source(root: string, label: string): DiffBaseSource & { fire: () => voi
|
||||
}
|
||||
}
|
||||
|
||||
describe('预览按文件所在的位置取对比基准', () => {
|
||||
describe('预览对比什么:审查按位置,上一次提交看是不是从「未提交的更改」点开的', () => {
|
||||
const review = source('/worktrees/issue-446', 'PR 目标分支 master')
|
||||
const head = source('/ws', '上一次提交')
|
||||
const bases = locatedDiffBases([review, head])
|
||||
const bases = previewDiffBases(review, head)
|
||||
|
||||
it('审查工作副本里的对比 PR 目标分支,工作区仓库里的对比上一次提交,都不管的不比', () => {
|
||||
expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'))?.label).toBe('PR 目标分支 master')
|
||||
expect(bases.diffBase(uri('/ws/src/a.ts'))?.label).toBe('上一次提交')
|
||||
expect(bases.diffBase(uri('/elsewhere/a.ts'))).toBeUndefined()
|
||||
it('审查工作副本里的文件对比 PR 目标分支,从哪儿打开的都一样', () => {
|
||||
expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'), 'uncommitted')?.label).toBe('PR 目标分支 master')
|
||||
expect(bases.diffBase(uri('/worktrees/issue-446/src/a.ts'), 'other')?.label).toBe('PR 目标分支 master')
|
||||
})
|
||||
|
||||
it('两方都管的文件按顺序取前面那方', () => {
|
||||
const both = locatedDiffBases([source('/ws', '前面'), source('/ws', '后面')])
|
||||
expect(both.diffBase(uri('/ws/a.ts'))?.label).toBe('前面')
|
||||
it('工作区仓库里有改动的文件:从「未提交的更改」点开的对比上一次提交,别处打开的不比', () => {
|
||||
expect(bases.diffBase(uri('/ws/src/a.ts'), 'uncommitted')?.label).toBe('上一次提交')
|
||||
expect(bases.diffBase(uri('/ws/src/a.ts'), 'other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('两方都不管的不比', () => {
|
||||
expect(bases.diffBase(uri('/elsewhere/a.ts'), 'uncommitted')).toBeUndefined()
|
||||
expect(bases.diffBase(uri('/elsewhere/a.ts'), 'other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('哪一方的基准变了都通知预览重新问;不听了就不再通知', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Disposable } from 'vscode'
|
||||
|
||||
/**
|
||||
* 对比基准:预览拿它取这个文件改之前的样子,和现在的比出差异。
|
||||
* 按文件所在的位置给(见 locatedDiffBases):审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交。
|
||||
* 给哪个基准见 [previewDiffBases]:审查工作副本里的对比 PR 的目标分支,从「未提交的更改」一栏点开的有改动的文件对比上一次提交。
|
||||
*/
|
||||
export interface DiffBase {
|
||||
/** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */
|
||||
@@ -22,22 +22,26 @@ export interface DiffBaseSource {
|
||||
readonly onDidChange: Event<void>
|
||||
}
|
||||
|
||||
/** 一个预览标签页是从哪儿打开的:从侧栏「未提交的更改」一栏点开的是 `uncommitted`,别处(主栏、收藏栏、点链接、跳定义……)打开的是 `other`。 */
|
||||
export type OpenedFrom = 'uncommitted' | 'other'
|
||||
|
||||
/** 预览问对比基准的地方:同一个文件从哪儿打开的不一样,对比的也可能不一样;基准变了发 onDidChange,打开着的预览重新问。 */
|
||||
export interface PreviewDiffBases {
|
||||
diffBase: (uri: Uri, from: OpenedFrom) => DiffBase | undefined
|
||||
readonly onDidChange: Event<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览对比什么按文件所在的位置定,不看是从哪一栏、点链接还是切标签打开的:
|
||||
* 按顺序问各方(审查工作副本在前、工作区仓库在后),第一个管这个文件的说了算,都不管的不比;哪一方的基准变了都通知。
|
||||
* 预览对比什么:审查工作副本里的文件不管从哪儿打开都对比 PR 的目标分支,其余的只有从「未提交的更改」一栏点开的才对比上一次提交,别处打开的不比。
|
||||
*
|
||||
* @param review 审查:工作副本里的文件给 PR 目标分支
|
||||
* @param uncommitted 工作区仓库里有改动的文件给上一次提交
|
||||
*/
|
||||
export function locatedDiffBases(sources: readonly DiffBaseSource[]): DiffBaseSource {
|
||||
export function previewDiffBases(review: DiffBaseSource, uncommitted: DiffBaseSource): PreviewDiffBases {
|
||||
return {
|
||||
diffBase: (uri) => {
|
||||
for (const source of sources) {
|
||||
const base = source.diffBase(uri)
|
||||
if (base)
|
||||
return base
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
diffBase: (uri, from) => review.diffBase(uri) ?? (from === 'uncommitted' ? uncommitted.diffBase(uri) : undefined),
|
||||
onDidChange: (listener, thisArgs, disposables) => {
|
||||
const all = Disposable.from(...sources.map(source => source.onDidChange(listener, thisArgs)))
|
||||
const all = Disposable.from(review.onDidChange(listener, thisArgs), uncommitted.onDidChange(listener, thisArgs))
|
||||
disposables?.push(all)
|
||||
return all
|
||||
},
|
||||
|
||||
@@ -75,7 +75,7 @@ export class PreviewDiff {
|
||||
private showing = false
|
||||
|
||||
constructor(
|
||||
/** 眼下对比的基准;没有是不比(普通文件树) */
|
||||
/** 眼下对比的基准;没有是不比(别处打开的、没有改动的) */
|
||||
private readonly baseOf: () => DiffBase | undefined,
|
||||
/** 用这份文档的语言、翻译插件和库规则解析旧版本;解析失败是 undefined */
|
||||
private readonly readOld: (source: string) => Promise<OldDocument | undefined>,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { FileAttribute } from '../attributes'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { ContractMiss } from '../callTree/endpoint'
|
||||
import type { CallTree } from '../callTree/model'
|
||||
import type { OpenedFrom } from '../diff/diffBase'
|
||||
import type { DocumentDiff } from '../diff/previewDiff'
|
||||
import type { ShownName } from '../nameResolution'
|
||||
import type { ProfileChoice } from '../qa/askProfiles'
|
||||
@@ -133,8 +134,9 @@ export interface StickyScrollSettings {
|
||||
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
|
||||
* diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的才有,见 DiffBase):每发一份文档之后算好了补发,
|
||||
* 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动)再发;null 是不比了(退出了审查、改动提交了)
|
||||
* diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发,
|
||||
* 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了)
|
||||
* openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又被点开再发
|
||||
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
|
||||
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
|
||||
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
|
||||
@@ -161,6 +163,7 @@ export type ExtensionToWebview
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
|
||||
| { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
| { type: 'openedFrom', from: OpenedFrom }
|
||||
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
|
||||
| { type: 'repoLinkBroken', path?: string, symbol?: string }
|
||||
| { type: 'repoLinkOpened', path?: string, symbol?: string }
|
||||
@@ -170,6 +173,7 @@ export type ExtensionToWebview
|
||||
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss }
|
||||
|
||||
/**
|
||||
* ready:页面准备好了,可以收消息;openedFrom 是页面记着的这个标签页是从哪儿打开的,新开的页面没记过就没有
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
|
||||
* visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部
|
||||
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去;
|
||||
@@ -200,7 +204,7 @@ export type ExtensionToWebview
|
||||
* goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(都从 0 起,见 CallTreeNode.jump)
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
= | { type: 'ready', openedFrom?: OpenedFrom }
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'setOnlyChanges', on: boolean }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DiffBase, OpenedFrom } from '../diff/diffBase'
|
||||
import type { RewriteRequest } from '../qa/codeQa'
|
||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -10,6 +11,7 @@ vi.mock('vscode', () => ({
|
||||
workspace: {
|
||||
onDidChangeTextDocument: () => ({ dispose: () => {} }),
|
||||
onDidChangeConfiguration: () => ({ dispose: () => {} }),
|
||||
getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }),
|
||||
openTextDocument: vi.fn(),
|
||||
applyEdit: vi.fn(async () => true),
|
||||
},
|
||||
@@ -28,6 +30,15 @@ vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: ()
|
||||
vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) }))
|
||||
vi.mock('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) }))
|
||||
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
|
||||
/** 排视图数据不真的解析:一份没有块的文档 */
|
||||
vi.mock('./documentMessage', () => ({
|
||||
documentMessage: vi.fn(async (input: { languageId: string, version: number }) => ({
|
||||
message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [], attributes: [], translators: [], translatorId: '', names: [] },
|
||||
displayNames: [],
|
||||
symbols: [],
|
||||
imports: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
const SOURCE = ['// 旧说明', 'export function load() {}']
|
||||
|
||||
@@ -125,3 +136,136 @@ describe('预览里重写注释', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const HEAD_BASE: DiffBase = { label: '上一次提交', key: 'head:web/a.ts', read: async () => '// 改之前', missing: '' }
|
||||
|
||||
/** 对比基准只认从「未提交的更改」点开的:从那儿点开的对比上一次提交,别处打开的不比 */
|
||||
function opening() {
|
||||
const subscribe = () => ({ dispose: () => {} })
|
||||
const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined)
|
||||
const qa = {
|
||||
imageRoot: () => undefined,
|
||||
onDidChange: subscribe,
|
||||
onDidAnswer: subscribe,
|
||||
onDidRewrite: subscribe,
|
||||
profiles: { subscribe, choice: async () => ({ profiles: [], selected: '' }) },
|
||||
view: () => ({ type: 'qa' }),
|
||||
}
|
||||
const provider = new PreviewEditorProvider(...[
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{ subscribe, current: { excluded: {}, onlyChanges: false } },
|
||||
async () => [],
|
||||
qa,
|
||||
async () => [],
|
||||
async () => [],
|
||||
{ diffBase, onDidChange: subscribe },
|
||||
{ namesOf: () => [], onDidChange: subscribe },
|
||||
async () => false,
|
||||
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
|
||||
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
|
||||
const document = { uri, version: 1, languageId: 'typescript', fileName: '/workspace/web/a.ts', getText: () => SOURCE.join('\n') }
|
||||
return { provider, document, diffBase }
|
||||
}
|
||||
|
||||
/** 一个标签页:收发消息、切走(页面被销毁,切回来重新加载、再发 ready) */
|
||||
function tab() {
|
||||
let receive: ((message: WebviewToExtension) => Promise<void>) | undefined
|
||||
let viewStateChanged: (() => void) | undefined
|
||||
const post = vi.fn<(message: ExtensionToWebview) => void>()
|
||||
const panel = {
|
||||
active: true,
|
||||
visible: true,
|
||||
webview: {
|
||||
options: {},
|
||||
postMessage: post,
|
||||
asWebviewUri: (file: unknown) => file,
|
||||
onDidReceiveMessage: (listener: typeof receive) => {
|
||||
receive = listener
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
},
|
||||
onDidChangeViewState: (listener: typeof viewStateChanged) => {
|
||||
viewStateChanged = listener
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
onDidDispose: () => ({ dispose: () => {} }),
|
||||
}
|
||||
return {
|
||||
panel: panel as unknown as Parameters<PreviewEditorProvider['resolveCustomTextEditor']>[1],
|
||||
send: (message: WebviewToExtension) => receive!(message),
|
||||
hide: () => {
|
||||
panel.visible = false
|
||||
viewStateChanged!()
|
||||
panel.visible = true
|
||||
},
|
||||
posted: () => post.mock.calls.map(([message]) => message),
|
||||
clear: () => post.mockClear(),
|
||||
}
|
||||
}
|
||||
|
||||
const comparedWithHead = expect.objectContaining({ type: 'diff', diff: expect.objectContaining({ base: '上一次提交' }) })
|
||||
|
||||
describe('预览对比什么看这个标签页是从哪儿打开的', () => {
|
||||
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))
|
||||
await page.send({ type: 'ready' })
|
||||
|
||||
expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' })
|
||||
await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead))
|
||||
})
|
||||
|
||||
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))
|
||||
await page.send({ type: 'ready' })
|
||||
await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead))
|
||||
|
||||
page.clear()
|
||||
await provider.open(document.uri as never, { from: 'other' }, async () => {})
|
||||
expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'other' })
|
||||
await vi.waitFor(() => expect(page.posted()).toContainEqual({ type: 'diff', diff: null }))
|
||||
|
||||
page.clear()
|
||||
await provider.open(document.uri as never, { from: 'uncommitted' }, async () => {})
|
||||
expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' })
|
||||
await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead))
|
||||
|
||||
page.hide()
|
||||
page.clear()
|
||||
await page.send({ type: 'ready', openedFrom: 'uncommitted' })
|
||||
expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' })
|
||||
await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead))
|
||||
})
|
||||
|
||||
it('没带打开请求的(VS Code 重启后恢复的)按页面记着的来,页面没记过的不比', async () => {
|
||||
const { provider, document, diffBase } = opening()
|
||||
const restored = tab()
|
||||
provider.resolveCustomTextEditor(document as never, restored.panel)
|
||||
await restored.send({ type: 'ready', openedFrom: 'uncommitted' })
|
||||
await vi.waitFor(() => expect(restored.posted()).toContainEqual(comparedWithHead))
|
||||
|
||||
diffBase.mockClear()
|
||||
const fresh = tab()
|
||||
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, diffBase } = opening()
|
||||
const page = tab()
|
||||
await provider.open(document.uri as never, { from: 'other' }, async () => provider.resolveCustomTextEditor(document as never, page.panel))
|
||||
await page.send({ type: 'ready', openedFrom: 'uncommitted' })
|
||||
|
||||
expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'other' })
|
||||
expect(diffBase).toHaveBeenCalledWith(document.uri, 'other')
|
||||
expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { IndexFind } from '../callTree/endpoint'
|
||||
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract'
|
||||
import type { DiffBaseSource } from '../diff/diffBase'
|
||||
import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase'
|
||||
import type { OldDocument, SentDocument } from '../diff/previewDiff'
|
||||
import type { WorkspaceEntries } from '../entries/workspaceEntries'
|
||||
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
|
||||
@@ -51,16 +51,24 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
/** 连续输入时合并成一次重新解析。 */
|
||||
const REFRESH_DELAY_MS = 150
|
||||
|
||||
/**
|
||||
* 一次打开预览带的话:这个标签页是从哪儿打开的(定对比什么);要滚到哪(从文本编辑器切过来时它顶部那一行,或跳到的定义);
|
||||
* 要不要把光标放进顶部「问 AI」输入框(侧栏「问 AI」打开的)。
|
||||
*/
|
||||
export interface OpenRequest {
|
||||
from: OpenedFrom
|
||||
reveal?: Reveal
|
||||
focusPath?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument:
|
||||
* 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。
|
||||
*/
|
||||
export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
private readonly previews = new Set<Preview>()
|
||||
/** 侧栏「问 AI」打开的预览,解析时把光标放进顶部输入框;已经开着的由 openAsking 自己聚焦 */
|
||||
private readonly pathFocus = new Set<string>()
|
||||
/** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */
|
||||
private readonly reveals = new Map<string, Reveal>()
|
||||
/** 按文档 uri:等着交给马上要打开的新预览的打开请求(见 [open]) */
|
||||
private readonly requests = new Map<string, OpenRequest>()
|
||||
/** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */
|
||||
readonly navigation = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value))
|
||||
|
||||
@@ -77,8 +85,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly indexedNames: (languageId: string, name: string) => Promise<IndexedName[]>,
|
||||
/** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */
|
||||
readonly indexed: IndexFind,
|
||||
/** 预览标差异时对比什么:按文件所在的位置定,基准变了发 onDidChange(见 locatedDiffBases) */
|
||||
readonly diffBases: DiffBaseSource,
|
||||
/** 预览标差异时对比什么:看文件在哪、这个标签页是从哪儿打开的,基准变了发 onDidChange */
|
||||
readonly diffBases: PreviewDiffBases,
|
||||
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
|
||||
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
|
||||
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
|
||||
@@ -87,22 +95,26 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly askFeature: (subject: Uri, request: TopRequest) => Promise<string | undefined>,
|
||||
) {}
|
||||
|
||||
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
|
||||
async openAsking(uri: Uri): Promise<void> {
|
||||
/**
|
||||
* 用 `show` 打开 `uri` 的预览,把 `request` 交给它:新建的预览解析时带走;`show` 只是切到了已经开着的预览(没有新建)时,交给切到的那个。
|
||||
*
|
||||
* `show` 默认按「预览标签」打开,同 `vscode.open`(用户关了 `workbench.editor.enablePreview` 时 VS Code 自会固定)。
|
||||
*/
|
||||
async open(uri: Uri, request: OpenRequest, show: () => Thenable<unknown> = () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })): Promise<void> {
|
||||
const key = uri.toString()
|
||||
this.pathFocus.add(key)
|
||||
this.requests.set(key, request)
|
||||
let claimed: boolean
|
||||
try {
|
||||
await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })
|
||||
await show()
|
||||
claimed = this.requests.get(key) !== request
|
||||
}
|
||||
finally {
|
||||
// 新开的预览在解析时已经拿走标记;还在的是本来就开着的那页,这里聚焦
|
||||
if (this.pathFocus.delete(key)) {
|
||||
for (const preview of this.previews) {
|
||||
if (preview.uri.toString() === key)
|
||||
preview.focusPathAsk()
|
||||
}
|
||||
}
|
||||
// 打开命令等新预览解析完才返回;没打开成时也不能把这次请求留给以后别处的打开
|
||||
if (this.requests.get(key) === request)
|
||||
this.requests.delete(key)
|
||||
}
|
||||
if (!claimed)
|
||||
[...this.previews].find(p => p.uri.toString() === key && p.active)?.reopen(request)
|
||||
}
|
||||
|
||||
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
|
||||
@@ -111,9 +123,9 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] }
|
||||
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
|
||||
const key = document.uri.toString()
|
||||
const focusPath = this.pathFocus.delete(key)
|
||||
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key), focusPath)
|
||||
this.reveals.delete(key)
|
||||
const request = this.requests.get(key)
|
||||
this.requests.delete(key)
|
||||
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, request)
|
||||
this.previews.add(preview)
|
||||
panel.onDidDispose(() => {
|
||||
this.previews.delete(preview)
|
||||
@@ -135,26 +147,6 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 open 打开 uri 的预览并滚到 reveal:新建的预览随第一份文档带过去;
|
||||
* open 只是切到了已经开着的预览(没有新建)时,直接发给这个预览,不重新打开。
|
||||
*/
|
||||
async openAt(uri: Uri, reveal: Reveal, open: () => Thenable<unknown>): Promise<void> {
|
||||
const key = uri.toString()
|
||||
this.reveals.set(key, reveal)
|
||||
let claimed: boolean
|
||||
try {
|
||||
await open()
|
||||
claimed = this.reveals.get(key) !== reveal
|
||||
}
|
||||
finally {
|
||||
// 打开命令等新预览解析完才返回;没打开成时也不能把这一处留给以后别处的打开
|
||||
this.reveals.delete(key)
|
||||
}
|
||||
if (!claimed)
|
||||
[...this.previews].find(p => p.uri.toString() === key && p.active)?.reveal(reveal)
|
||||
}
|
||||
|
||||
/** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */
|
||||
visibleLine(uri: Uri): number | undefined {
|
||||
const key = uri.toString()
|
||||
@@ -204,6 +196,13 @@ class Preview {
|
||||
private readonly callees: DocumentCallees
|
||||
/** 这份文件相比对比基准改了什么:发完文档再算、补发 */
|
||||
private readonly diff: PreviewDiff
|
||||
/**
|
||||
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。
|
||||
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
|
||||
*/
|
||||
private pendingReveal: Reveal | undefined
|
||||
/** 这个标签页是从哪儿打开的,定对比什么;没带打开请求的(VS Code 重启后恢复的)先空着,页面 ready 时按它记着的定 */
|
||||
private from: OpenedFrom | undefined
|
||||
|
||||
constructor(
|
||||
private readonly document: TextDocument,
|
||||
@@ -212,14 +211,12 @@ class Preview {
|
||||
private readonly registry: CodeReaderRegistry,
|
||||
private readonly runtime: TreeSitterRuntime,
|
||||
private readonly filter: BlockFilter,
|
||||
/**
|
||||
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。
|
||||
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
|
||||
*/
|
||||
private pendingReveal: Reveal | undefined,
|
||||
focusPath: boolean,
|
||||
/** 打开它的那次请求;VS Code 重启后恢复的、VS Code 自己换编辑器打开的没有 */
|
||||
request: OpenRequest | undefined,
|
||||
) {
|
||||
this.pendingPathFocus = focusPath
|
||||
this.from = request?.from
|
||||
this.pendingReveal = request?.reveal
|
||||
this.pendingPathFocus = request?.focusPath ?? false
|
||||
this.definitions = new DefinitionSources({
|
||||
rule: async reference => ruleDefinitions(await owner.resolveReference(reference)),
|
||||
language: async (offset) => {
|
||||
@@ -234,7 +231,7 @@ class Preview {
|
||||
this.lookups = new DocumentLookups(() => this.schedulePush())
|
||||
this.callees = new DocumentCallees(() => this.schedulePush())
|
||||
this.diff = new PreviewDiff(
|
||||
() => owner.diffBases.diffBase(document.uri),
|
||||
() => this.from === undefined ? undefined : owner.diffBases.diffBase(document.uri, this.from),
|
||||
source => this.readOld(source),
|
||||
(diff) => {
|
||||
if (this.ready)
|
||||
@@ -473,8 +470,22 @@ class Preview {
|
||||
this.post({ type: 'moveOccurrence', step })
|
||||
}
|
||||
|
||||
/** 开着时又被点开(见 [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.reveal)
|
||||
this.reveal(request.reveal)
|
||||
if (request.focusPath)
|
||||
this.focusPathAsk()
|
||||
}
|
||||
|
||||
/** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */
|
||||
reveal(reveal: Reveal): void {
|
||||
private reveal(reveal: Reveal): void {
|
||||
if (this.ready)
|
||||
this.post({ type: 'reveal', ...reveal })
|
||||
else
|
||||
@@ -508,7 +519,7 @@ class Preview {
|
||||
}
|
||||
|
||||
/** 把光标放进顶部输入框;页面还没准备好就等 ready。 */
|
||||
focusPathAsk(): void {
|
||||
private focusPathAsk(): void {
|
||||
if (!this.ready) {
|
||||
this.pendingPathFocus = true
|
||||
return
|
||||
@@ -564,6 +575,9 @@ class Preview {
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
this.ready = true
|
||||
// 打开请求说了从哪儿打开的就按它;没说的(VS Code 重启后恢复的)按页面记着的,页面也没记过的不算从「未提交的更改」点开的
|
||||
this.from ??= msg.openedFrom ?? 'other'
|
||||
this.post({ type: 'openedFrom', from: this.from })
|
||||
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
|
||||
this.post({ type: 'filter', ...this.filter.current })
|
||||
this.postHoverDelay()
|
||||
@@ -780,7 +794,7 @@ class Preview {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处;
|
||||
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开(算从别处打开的,不对比上一次提交),给了 spot 就滚过去、闪这处;
|
||||
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
|
||||
* workbench.editor.enablePreviewFromCodeNavigation 为假时先把当前这个预览标签固定住,免得被目标替掉。
|
||||
*/
|
||||
@@ -793,11 +807,7 @@ class Preview {
|
||||
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 open()
|
||||
await this.owner.open(uri, { from: 'other', ...(opening.reveal && { reveal: opening.reveal }) }, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
|
||||
}
|
||||
|
||||
/** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */
|
||||
|
||||
@@ -51,13 +51,13 @@ 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 && topLine !== undefined)
|
||||
await previews.openAt(uri, { line: topLine }, reopen)
|
||||
if (uri)
|
||||
await previews.open(uri, { from: 'other', ...(topLine !== undefined && { reveal: { line: topLine } }) }, reopen)
|
||||
else
|
||||
await reopen()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkspaceFolder } from 'vscode'
|
||||
import type { ChangedSection, FileNode, FileTreeDecorator, TreeNode } from './fileTree'
|
||||
import type { ChangedSection, FileNode, FileOpenCommand, FileTreeDecorator, TreeNode } from './fileTree'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Uri } from 'vscode'
|
||||
import { buildChangedTree } from './changedTree'
|
||||
@@ -68,6 +68,7 @@ vi.mock('vscode', async () => {
|
||||
const FILE = 1
|
||||
const DIR = 2
|
||||
const decorator = { onDidChange: () => ({ dispose: () => {} }), decorate: () => {} } as unknown as FileTreeDecorator
|
||||
const OPEN: FileOpenCommand = { command: 'open', from: 'other' }
|
||||
let folder: WorkspaceFolder
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -108,7 +109,7 @@ function topOf(files: FileTreeProvider, node: TreeNode): TreeNode {
|
||||
|
||||
describe('工作区文件树:按 uri 找到那一行、一层层找上一层', () => {
|
||||
it('多层目录:getParent 交回 getChildren 给过的同一个对象,根的上一层没有', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const [root] = await files.getChildren()
|
||||
const backend = await childNamed(files, root, 'backend')
|
||||
const src = await childNamed(files, backend, 'src')
|
||||
@@ -123,7 +124,7 @@ describe('工作区文件树:按 uri 找到那一行、一层层找上一层',
|
||||
})
|
||||
|
||||
it('nodeOf 一层层读目录找到文件,就是树上摆的那一行', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py'))
|
||||
|
||||
expect(found?.isDirectory).toBe(false)
|
||||
@@ -133,7 +134,7 @@ describe('工作区文件树:按 uri 找到那一行、一层层找上一层',
|
||||
})
|
||||
|
||||
it('目录也能找到', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src'))
|
||||
|
||||
expect(found?.isDirectory).toBe(true)
|
||||
@@ -143,7 +144,7 @@ describe('工作区文件树:按 uri 找到那一行、一层层找上一层',
|
||||
|
||||
it('树上没有的找不到:工作区外、被 files.exclude 藏了的目录里、盘上没有的', async () => {
|
||||
disk.exclude = { '**/dist': true }
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
|
||||
expect(await files.nodeOf(Uri.file('/elsewhere/a.py'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/dist/bundle.js'))).toBeUndefined()
|
||||
@@ -163,7 +164,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
}
|
||||
|
||||
it('找到那个分组里的那一行,上一层一直走到分组行', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.show({ kind: 'changes', view: [staged, unstaged] })
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py'))
|
||||
@@ -176,7 +177,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
})
|
||||
|
||||
it('没摆出来的文件、目录找不到', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.show({ kind: 'changes', view: [staged, unstaged] })
|
||||
|
||||
@@ -186,7 +187,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
})
|
||||
|
||||
it('同一个文件摆在两个分组里时取最先摆的', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.show({ kind: 'changes', view: [staged, unstaged] })
|
||||
|
||||
@@ -194,7 +195,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
})
|
||||
|
||||
it('分组没有名字时根就在最上面一层,根的上一层没有', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
files.show({ kind: 'changes', view: [{ roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'A' }]) }] }] })
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/gate.py'))
|
||||
const [root] = await files.getChildren()
|
||||
@@ -208,7 +209,7 @@ describe('收藏栏:一个目录下的完整文件树', () => {
|
||||
it('直接摆目录下的各项(不套一层根),规则同工作区文件树:目录在前、files.exclude 藏掉的不摆', async () => {
|
||||
disk.dirs.set('/ws/backend', [['src', DIR], ['main.py', FILE], ['build', DIR]])
|
||||
disk.exclude = { '**/build': true }
|
||||
const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const top = await files.getChildren()
|
||||
|
||||
expect(top.map(node => !isGroupNode(node) && [node.uri.path, node.isDirectory, node.isRoot])).toEqual([
|
||||
@@ -220,7 +221,7 @@ describe('收藏栏:一个目录下的完整文件树', () => {
|
||||
})
|
||||
|
||||
it('最上面一层没有上一层;往下找得到,上一层走到收藏的目录下面为止', async () => {
|
||||
const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py'))
|
||||
|
||||
expect(chainUp(files, found)).toEqual(['/ws/backend/src/luffy/visibility.py', '/ws/backend/src/luffy', '/ws/backend/src'])
|
||||
@@ -228,7 +229,7 @@ describe('收藏栏:一个目录下的完整文件树', () => {
|
||||
})
|
||||
|
||||
it('目录外面的找不到;目录被删了时树是空的', async () => {
|
||||
const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
|
||||
expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/backend'))).toBeUndefined()
|
||||
@@ -239,10 +240,32 @@ describe('收藏栏:一个目录下的完整文件树', () => {
|
||||
|
||||
it('目录不在任何工作区文件夹里(文件夹被移出工作区):拿目录自己当根', async () => {
|
||||
disk.folders = []
|
||||
const files = new FileTreeProvider('open', decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const files = new FileTreeProvider(OPEN, decorator, { kind: 'directory', uri: Uri.file('/ws/backend') })
|
||||
const [src] = await files.getChildren()
|
||||
|
||||
expect(!isGroupNode(src) && src.folder.uri.path).toBe('/ws/backend')
|
||||
expect(files.getParent(src)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('点文件打开', () => {
|
||||
it('命令带上文件和这一栏的 from:「未提交的更改」那一栏的是 uncommitted;删掉的文件不挂命令', async () => {
|
||||
const files = new FileTreeProvider({ command: 'open', from: 'uncommitted' }, decorator)
|
||||
files.show({ kind: 'changes', view: [{ label: '更改', roots: [{ folder, tree: buildChangedTree([
|
||||
{ path: 'backend/src/gate.py', change: 'M' },
|
||||
{ path: 'README.md', change: 'D' },
|
||||
]) }] }] })
|
||||
const gate = await files.nodeOf(Uri.file('/ws/backend/src/gate.py'))
|
||||
const readme = await files.nodeOf(Uri.file('/ws/README.md'))
|
||||
|
||||
expect(files.getTreeItem(gate!).command).toEqual({ command: 'open', title: '打开', arguments: [gate!.uri, 'uncommitted'] })
|
||||
expect(files.getTreeItem(readme!).command).toBeUndefined()
|
||||
})
|
||||
|
||||
it('别的栏的是 other', async () => {
|
||||
const files = new FileTreeProvider(OPEN, decorator)
|
||||
const readme = await files.nodeOf(Uri.file('/ws/README.md'))
|
||||
|
||||
expect(files.getTreeItem(readme!).command?.arguments).toEqual([readme!.uri, 'other'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode'
|
||||
import type { OpenedFrom } from '../diff/diffBase'
|
||||
import type { ChangedTree, FileChange } from './changedTree'
|
||||
import type { ExcludeSetting } from './fileEntries'
|
||||
import * as path from 'node:path'
|
||||
@@ -70,6 +71,12 @@ export interface FileTreeDecorator {
|
||||
decorate: (item: TreeItem, node: FileNode) => void
|
||||
}
|
||||
|
||||
/** 点文件时执行的命令:参数是文件 Uri 和 `from`,预览按它知道这个标签页是从哪儿打开的(见 [OpenedFrom])。 */
|
||||
export interface FileOpenCommand {
|
||||
command: string
|
||||
from: OpenedFrom
|
||||
}
|
||||
|
||||
/** 批量改文件(git checkout、构建输出)时合并成一次刷新。 */
|
||||
const REFRESH_DELAY_MS = 300
|
||||
|
||||
@@ -92,11 +99,11 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
private directoryFolder: WorkspaceFolder | undefined
|
||||
|
||||
/**
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param open 点文件时执行的命令和这一栏的 `from`
|
||||
* @param decorator 给行添信息(文件注释)
|
||||
* @param content 一开始摆什么,默认是工作区文件树
|
||||
*/
|
||||
constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator, private content: TreeContent = WORKSPACE_TREE) {
|
||||
constructor(private readonly open: FileOpenCommand, private readonly decorator: FileTreeDecorator, private content: TreeContent = WORKSPACE_TREE) {
|
||||
this.groups = groupsOf(content)
|
||||
// 只关心增删:文件内容变化不影响树的结构
|
||||
const watcher = workspace.createFileSystemWatcher('**/*', false, true, false)
|
||||
@@ -190,7 +197,7 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
if (node.change === 'D')
|
||||
item.tooltip = DELETED_TOOLTIP // 删掉的文件没有内容可看,不挂打开的命令
|
||||
else
|
||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||
item.command = { command: this.open.command, title: '打开', arguments: [node.uri, this.open.from] }
|
||||
return item
|
||||
}
|
||||
const expanded = node.isRoot || this.content.kind === 'changes'
|
||||
|
||||
@@ -26,7 +26,7 @@ class FavoritePane {
|
||||
|
||||
constructor(favorite: Favorite, openCommand: string, decorator: FileTreeDecorator) {
|
||||
this.uri = Uri.parse(favorite.uri)
|
||||
const files = new FileTreeProvider(openCommand, decorator, { kind: 'directory', uri: this.uri })
|
||||
const files = new FileTreeProvider({ command: openCommand, from: 'other' }, decorator, { kind: 'directory', uri: this.uri })
|
||||
this.view = window.createTreeView(slotViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true })
|
||||
this.view.title = slotTitle('favorite', favorite.name)
|
||||
this.view.description = workspace.asRelativePath(this.uri)
|
||||
@@ -72,7 +72,7 @@ export class FavoriteViews {
|
||||
/**
|
||||
* @param state workspaceState
|
||||
* @param pool 和「了解一块代码」共用的栏位
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri 和 `other`(不是从「未提交的更改」点开的)
|
||||
* @param decorator 给行添信息(文件注释),和别的栏共用
|
||||
*/
|
||||
constructor(private readonly state: Memento, private readonly pool: SlotPool, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import type { CodeReaderApi, RuleLocation, RuleReference } from './contract'
|
||||
import type { OpenedFrom } from './diff/diffBase'
|
||||
import type { TargetSpot } from './editor/openTarget'
|
||||
import type { FileNode } from './explorer/fileTree'
|
||||
import type { FeatureRow } from './features/featureViews'
|
||||
@@ -9,7 +10,7 @@ import { logger } from '../logging/logger'
|
||||
import { packageCommandRules } from './commands/packageCommands'
|
||||
import { shellCommandRules } from './commands/shellCommands'
|
||||
import { systemCommandRules } from './commands/systemCommands'
|
||||
import { locatedDiffBases } from './diff/diffBase'
|
||||
import { previewDiffBases } from './diff/diffBase'
|
||||
import { BlockFilter } from './editor/blockFilter'
|
||||
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
|
||||
import { languageOf, openingFor } from './editor/openTarget'
|
||||
@@ -71,26 +72,21 @@ const FILES_VIEW = 'superpowers.codeReader.files'
|
||||
const OPEN_COMMAND = 'superpowers.codeReader.open'
|
||||
|
||||
/**
|
||||
* 侧栏点文件、点「了解一块代码」栏里的一条:语言受支持就用预览打开,否则用 VS Code 默认的打开方式(见 openingFor)。
|
||||
* 给了 spot(条目的符号在哪)就跳过去:预览滚到那一块、闪一下,普通编辑器选中那个名字。
|
||||
* 侧栏点文件、点「了解一块代码」栏里的一条:语言受支持就用预览打开、告诉它是从哪一栏点开的(`from`),否则用 VS Code 默认的打开方式(见 [openingFor])。
|
||||
* 给了 `spot`(条目的符号在哪)就跳过去:预览滚到那一块、闪一下,普通编辑器选中那个名字。
|
||||
*/
|
||||
async function openFile(uri: Uri, registry: CodeReaderRegistry, previews: PreviewEditorProvider, spot?: TargetSpot): Promise<void> {
|
||||
async function openFile(uri: Uri, from: OpenedFrom, registry: CodeReaderRegistry, previews: PreviewEditorProvider, spot?: TargetSpot): Promise<void> {
|
||||
const opening = openingFor(await languageOf(uri), languageId => registry.languageFor(languageId) !== undefined, spot)
|
||||
if (opening.kind === 'default') {
|
||||
await commands.executeCommand('vscode.open', uri, opening.selection && { selection: opening.selection })
|
||||
return
|
||||
}
|
||||
// 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定)
|
||||
const open = (): Thenable<unknown> => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true })
|
||||
if (opening.reveal)
|
||||
await previews.openAt(uri, opening.reveal, open)
|
||||
else
|
||||
await open()
|
||||
await previews.open(uri, { from, ...(opening.reveal && { reveal: opening.reveal }) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录、每个「了解一块代码」找的功能各一栏;
|
||||
* 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,按文件所在位置标差异)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,审查工作副本里的和从「未提交的更改」点开的标差异)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
|
||||
*/
|
||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
@@ -113,7 +109,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const notes = new FileNotes()
|
||||
const entries = new WorkspaceEntries()
|
||||
const decorator = new RowDecorator(notes, entries)
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, decorator)
|
||||
const files = new FileTreeProvider({ command: OPEN_COMMAND, from: 'other' }, decorator)
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
const locator = new TreeLocator(files, view)
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files)
|
||||
@@ -123,8 +119,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const favorites = new FavoriteViews(context.workspaceState, slots, OPEN_COMMAND, decorator)
|
||||
const features = new FeatureViews(context.workspaceState, slots, qa, item => locateItem(item, { adapterFor: languageId => registry.languageFor(languageId), runtime }))
|
||||
const heads = new HeadBases()
|
||||
// 预览对比什么按文件所在的位置定:审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交
|
||||
const diffBases = locatedDiffBases([review, heads])
|
||||
// 预览对比什么:审查工作副本里的对比 PR 的目标分支,从「未提交的更改」一栏点开的有改动的文件对比上一次提交,别处打开的不比
|
||||
const diffBases = previewDiffBases(review, heads)
|
||||
const previews = new PreviewEditorProvider(
|
||||
context.extensionUri,
|
||||
registry,
|
||||
@@ -141,7 +137,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
)
|
||||
const openChat = async (page: Uri | undefined): Promise<void> => {
|
||||
if (page)
|
||||
await previews.openAsking(page)
|
||||
await previews.open(page, { from: 'other', focusPath: true })
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
@@ -157,7 +153,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const languageId = await languageOf(uri)
|
||||
return languageId !== undefined && registry.languageFor(languageId) !== undefined
|
||||
},
|
||||
uri => previews.openAsking(uri),
|
||||
uri => previews.open(uri, { from: 'other', focusPath: true }),
|
||||
),
|
||||
workspace.registerTextDocumentContentProvider(PATH_PREVIEW_SCHEME, {
|
||||
provideTextDocumentContent: () => '',
|
||||
@@ -196,12 +192,13 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
commands.registerCommand(featureCommand('rename', slot), () => features.rename(slot)),
|
||||
commands.registerCommand(featureCommand('remove', slot), () => features.remove(slot)),
|
||||
]),
|
||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry, previews)),
|
||||
// 侧栏各栏的文件行传来的是 (文件 Uri, 从哪一栏点开的)
|
||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri, from: OpenedFrom) => openFile(uri, from, registry, previews)),
|
||||
// 「了解一块代码」栏里的条目行传来的是这一行:按眼下的代码找到它再打开
|
||||
commands.registerCommand(OPEN_FEATURE_ITEM, async (row?: FeatureRow) => {
|
||||
const found = await features.target(row)
|
||||
if (found)
|
||||
await openFile(found.uri, registry, previews, found.spot)
|
||||
await openFile(found.uri, 'other', registry, previews, found.spot)
|
||||
}),
|
||||
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
|
||||
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
|
||||
|
||||
@@ -13,7 +13,7 @@ function errorText(err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作区仓库里有没提交改动的文件,预览对比上一次提交(见 headDiffBase),不管是从哪一栏、点链接还是切标签打开的。
|
||||
* 工作区仓库里有没提交改动的文件,对比上一次提交(见 [headDiffBase]);只有从「未提交的更改」一栏点开的预览来问(见 [previewDiffBases](vscode/src/codeReader/diff/diffBase.ts#previewDiffBases))。
|
||||
* 第一次有预览来问时才去读 git,读到了通知预览重新问;之后盯着 git 状态,改动、HEAD 变了都通知打开着的预览重新比。
|
||||
*/
|
||||
export class HeadBases implements DiffBaseSource, Disposable {
|
||||
|
||||
@@ -43,11 +43,11 @@ export class UncommittedView implements Disposable {
|
||||
|
||||
/**
|
||||
* @param context 隐藏测试文件的开关记在它的 workspaceState 里
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri 和 `uncommitted`:从这一栏点开的预览对比上一次提交
|
||||
* @param decorator 给行添信息(文件注释),和别的栏共用
|
||||
*/
|
||||
constructor(context: ExtensionContext, openCommand: string, decorator: FileTreeDecorator) {
|
||||
this.files = new FileTreeProvider(openCommand, decorator, { kind: 'changes', view: [] })
|
||||
this.files = new FileTreeProvider({ command: openCommand, from: 'uncommitted' }, decorator, { kind: 'changes', view: [] })
|
||||
this.view = window.createTreeView(UNCOMMITTED_VIEW, { treeDataProvider: this.files, showCollapseAll: true })
|
||||
this.tests = new TestsToggle(context.workspaceState, UNCOMMITTED_TESTS, () => this.rebuild())
|
||||
this.disposables = [this.files, this.view, whenShown(this.view, () => void this.open())]
|
||||
|
||||
@@ -72,7 +72,7 @@ export function App(): ReactElement | null {
|
||||
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
|
||||
/** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */
|
||||
const [names, setNames] = useState<VersionNames | undefined>()
|
||||
/** 这份文件相比对比基准改了什么:审查工作副本里的、工作区仓库里有改动的文件扩展端算好了补来,不比时是 null */
|
||||
/** 这份文件相比对比基准改了什么:审查工作副本里的、从「未提交的更改」点开的有改动的文件扩展端算好了补来,不比时是 null */
|
||||
const [diff, setDiff] = useState<DocumentDiff | null>(null)
|
||||
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
|
||||
const editing = useRef(view.editing)
|
||||
@@ -177,11 +177,16 @@ export function App(): ReactElement | null {
|
||||
case 'diff':
|
||||
setDiff(msg.diff)
|
||||
return
|
||||
case 'openedFrom':
|
||||
viewState.update({ openedFrom: msg.from })
|
||||
return
|
||||
case 'focusPathAsk':
|
||||
setPathFocus(n => n + 1)
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'ready' })
|
||||
// 重启 VS Code 后恢复的标签页,扩展端不知道它是从哪儿打开的,按页面记着的
|
||||
const { openedFrom } = viewState.get()
|
||||
postMessage({ type: 'ready', ...(openedFrom && { openedFrom }) })
|
||||
return off
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -448,6 +448,9 @@ export interface StickyScrollSettings {
|
||||
maxLineCount: number
|
||||
}
|
||||
|
||||
/** 这个标签页是从哪儿打开的:从侧栏「未提交的更改」一栏点开的是 `uncommitted`,别处打开的是 `other`;页面只记着,重启 VS Code 后恢复时报给扩展端。 */
|
||||
export type OpenedFrom = 'uncommitted' | 'other'
|
||||
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
@@ -464,7 +467,8 @@ export interface StickyScrollSettings {
|
||||
* rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
|
||||
* diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的文件才有);每来一份文档之后补来,基准变了再来;null 是不比了
|
||||
* diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了
|
||||
* openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又被点开再来
|
||||
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
|
||||
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
|
||||
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
|
||||
@@ -530,6 +534,7 @@ export type ExtensionToWebview
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
|
||||
| RewriteEnd
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
| { type: 'openedFrom', from: OpenedFrom }
|
||||
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
|
||||
| { type: 'repoLinkBroken', path?: string, symbol?: string }
|
||||
| { type: 'repoLinkOpened', path?: string, symbol?: string }
|
||||
@@ -539,6 +544,7 @@ export type ExtensionToWebview
|
||||
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss }
|
||||
|
||||
/**
|
||||
* ready:页面准备好了,可以收消息;openedFrom 是页面状态里记着的这个标签页是从哪儿打开的,新开的页面没记过就不带
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
* visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部
|
||||
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来;
|
||||
@@ -568,7 +574,7 @@ export type ExtensionToWebview
|
||||
* goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(见 CallTreeNode.jump)
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
= | { type: 'ready', openedFrom?: OpenedFrom }
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'setOnlyChanges', on: boolean }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
|
||||
@@ -62,6 +62,14 @@ describe('webview 状态', () => {
|
||||
expect(createViewStateStore(memoryStorage(null)).get()).toEqual({ editing: [], folds: {} })
|
||||
})
|
||||
|
||||
it('记着这个标签页是从哪儿打开的,页面重新加载后取回;认不得的值当没记过', () => {
|
||||
const storage = memoryStorage()
|
||||
createViewStateStore(storage).update({ openedFrom: 'uncommitted' })
|
||||
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, openedFrom: 'uncommitted' })
|
||||
expect(createViewStateStore(memoryStorage({ openedFrom: 'other' })).get()).toEqual({ editing: [], folds: {}, openedFrom: 'other' })
|
||||
expect(createViewStateStore(memoryStorage({ openedFrom: 'files' })).get()).toEqual({ editing: [], folds: {} })
|
||||
})
|
||||
|
||||
it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => {
|
||||
expect(propertyRowId('6.1.0', 3)).toBe('6.1.0/p3')
|
||||
expect(propertyRowId('6', 1)).not.toBe('6.1')
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { ScrollAnchor } from './scrollPosition'
|
||||
import type { BlockView } from './types'
|
||||
import type { BlockView, OpenedFrom } from './types'
|
||||
|
||||
/**
|
||||
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
|
||||
* 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 propertyRowId、propertyItemId),以及滚到了哪。
|
||||
* 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),以及滚到了哪。
|
||||
* VS Code 重启后恢复标签页时也带着它,这个标签页是从哪儿打开的靠它报回扩展端。
|
||||
*/
|
||||
export interface ViewState {
|
||||
editing: string[]
|
||||
/** 用户自己点过折叠箭头的块、属性行和子行:id → 折叠着没有。没点过的按默认,见 collapsedIds */
|
||||
folds: Record<string, boolean>
|
||||
scroll?: ScrollAnchor
|
||||
/** 这个标签页是从哪儿打开的,扩展端告诉的;新开的页面还没记 */
|
||||
openedFrom?: OpenedFrom
|
||||
}
|
||||
|
||||
export interface ViewStateStorage {
|
||||
@@ -51,7 +54,12 @@ function readViewState(saved: unknown): ViewState {
|
||||
const folds = isRecord(state.folds)
|
||||
? Object.fromEntries(Object.entries(state.folds).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
|
||||
: {}
|
||||
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
|
||||
return {
|
||||
editing,
|
||||
folds,
|
||||
...(isScrollAnchor(state.scroll) && { scroll: state.scroll }),
|
||||
...((state.openedFrom === 'uncommitted' || state.openedFrom === 'other') && { openedFrom: state.openedFrom }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user