✨ feat(vscode): 代码阅读侧栏拆成多栏:常驻未提交的更改、收藏目录各占一栏
This commit is contained in:
1 parent
45e526afa9
commit
b40ddb5e3a
27 files changed
+2427
-674
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import type { Uri } from 'vscode'
|
||||
import type { DiffBase, DiffBaseSource } from './diffBase'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { locatedDiffBases } from './diffBase'
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
Disposable: {
|
||||
from: (...items: { dispose: () => void }[]) => ({ dispose: () => items.forEach(item => item.dispose()) }),
|
||||
},
|
||||
}))
|
||||
|
||||
const uri = (path: string): Uri => ({ path }) as Uri
|
||||
const base = (label: string): DiffBase => ({ label, key: label, read: async () => null, missing: '' })
|
||||
|
||||
/** 只管 root 下面的文件的一方;fire 模拟它的基准变了 */
|
||||
function source(root: string, label: string): DiffBaseSource & { fire: () => void } {
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
diffBase: file => file.path.startsWith(`${root}/`) ? base(label) : undefined,
|
||||
onDidChange: (listener) => {
|
||||
listeners.add(listener)
|
||||
return { dispose: () => listeners.delete(listener) }
|
||||
},
|
||||
fire: () => listeners.forEach(listener => listener()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('预览按文件所在的位置取对比基准', () => {
|
||||
const review = source('/worktrees/issue-446', 'PR 目标分支 master')
|
||||
const head = source('/ws', '上一次提交')
|
||||
const bases = locatedDiffBases([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('两方都管的文件按顺序取前面那方', () => {
|
||||
const both = locatedDiffBases([source('/ws', '前面'), source('/ws', '后面')])
|
||||
expect(both.diffBase(uri('/ws/a.ts'))?.label).toBe('前面')
|
||||
})
|
||||
|
||||
it('哪一方的基准变了都通知预览重新问;不听了就不再通知', () => {
|
||||
const changed = vi.fn()
|
||||
const listening = bases.onDidChange(changed)
|
||||
review.fire()
|
||||
head.fire()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
|
||||
listening.dispose()
|
||||
head.fire()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Event, Uri } from 'vscode'
|
||||
import { Disposable } from 'vscode'
|
||||
|
||||
/**
|
||||
* 对比基准:侧栏只列改动的文件时(「未提交的更改」对比上一次提交,「选工单」审查对比 PR 的目标分支),
|
||||
* 预览拿它取这个文件改之前的样子,和现在的比出差异。由开着的那种侧栏筛选给(TreeFilter.diffBase)。
|
||||
* 对比基准:预览拿它取这个文件改之前的样子,和现在的比出差异。
|
||||
* 按文件所在的位置给(见 locatedDiffBases):审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交。
|
||||
*/
|
||||
export interface DiffBase {
|
||||
/** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */
|
||||
@@ -12,3 +15,31 @@ export interface DiffBase {
|
||||
/** 基准里没有这个文件时横幅上的一句说明 */
|
||||
missing: string
|
||||
}
|
||||
|
||||
/** 给对比基准的一方:它管的文件给基准,不管的没有;基准变了(HEAD 动了、换了审查的 PR)发 onDidChange,打开着的预览重新问。 */
|
||||
export interface DiffBaseSource {
|
||||
diffBase: (uri: Uri) => DiffBase | undefined
|
||||
readonly onDidChange: Event<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览对比什么按文件所在的位置定,不看是从哪一栏、点链接还是切标签打开的:
|
||||
* 按顺序问各方(审查工作副本在前、工作区仓库在后),第一个管这个文件的说了算,都不管的不比;哪一方的基准变了都通知。
|
||||
*/
|
||||
export function locatedDiffBases(sources: readonly DiffBaseSource[]): DiffBaseSource {
|
||||
return {
|
||||
diffBase: (uri) => {
|
||||
for (const source of sources) {
|
||||
const base = source.diffBase(uri)
|
||||
if (base)
|
||||
return base
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
onDidChange: (listener, thisArgs, disposables) => {
|
||||
const all = Disposable.from(...sources.map(source => source.onDidChange(listener, thisArgs)))
|
||||
disposables?.push(all)
|
||||
return all
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,8 @@ export interface StickyScrollSettings {
|
||||
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
|
||||
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发,
|
||||
* 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件)
|
||||
* diff:这份文件相比对比基准改了什么(审查工作副本里的、工作区仓库里有改动的才有,见 DiffBase):每发一份文档之后算好了补发,
|
||||
* 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动)再发;null 是不比了(退出了审查、改动提交了)
|
||||
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
|
||||
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
|
||||
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
|
||||
|
||||
@@ -48,7 +48,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
|
||||
qa,
|
||||
async () => [],
|
||||
async () => [],
|
||||
{ onDidChangeDiffBase: subscribe },
|
||||
{ onDidChange: subscribe },
|
||||
{ onDidChange: subscribe },
|
||||
async () => false,
|
||||
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
|
||||
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
|
||||
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, SourceRange } from '../contract'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { DiffBaseSource } from '../diff/diffBase'
|
||||
import type { OldDocument, SentDocument } from '../diff/previewDiff'
|
||||
import type { WorkspaceEntries } from '../entries/workspaceEntries'
|
||||
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
|
||||
@@ -49,12 +49,6 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
/** 连续输入时合并成一次重新解析。 */
|
||||
const REFRESH_DELAY_MS = 150
|
||||
|
||||
/** 预览对比什么:侧栏开着的筛选定(TreeFilters),换了筛选、基准变了发 onDidChangeDiffBase。 */
|
||||
export interface DiffBases {
|
||||
diffBase: (uri: Uri) => DiffBase | undefined
|
||||
onDidChangeDiffBase: Event<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument:
|
||||
* 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。
|
||||
@@ -81,8 +75,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly indexedNames: (languageId: string, name: string) => Promise<IndexedName[]>,
|
||||
/** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */
|
||||
readonly indexed: IndexFind,
|
||||
/** 预览标差异时对比什么 */
|
||||
readonly diffBases: DiffBases,
|
||||
/** 预览标差异时对比什么:按文件所在的位置定,基准变了发 onDidChange(见 locatedDiffBases) */
|
||||
readonly diffBases: DiffBaseSource,
|
||||
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
|
||||
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
|
||||
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
|
||||
@@ -245,7 +239,7 @@ class Preview {
|
||||
text => logger.add({ level: 'warn', source: 'codeReader', message: `${document.uri.fsPath}:${text}` }),
|
||||
)
|
||||
this.disposables = [
|
||||
owner.diffBases.onDidChangeDiffBase(() => {
|
||||
owner.diffBases.onDidChange(() => {
|
||||
if (this.ready && this.sent)
|
||||
void this.diff.update(this.sent)
|
||||
}),
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock('vscode', async () => {
|
||||
get workspaceFolders() {
|
||||
return disk.folders
|
||||
},
|
||||
getWorkspaceFolder: (uri: FakeUri) => (disk.folders as { uri: FakeUri }[]).find(folder => uri.path === folder.uri.path || uri.path.startsWith(`${folder.uri.path}/`)),
|
||||
fs: {
|
||||
readDirectory: async (uri: FakeUri) => {
|
||||
const entries = disk.dirs.get(uri.path)
|
||||
@@ -164,7 +165,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
it('找到那个分组里的那一行,上一层一直走到分组行', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.showChanges([staged, unstaged])
|
||||
files.show({ kind: 'changes', view: [staged, unstaged] })
|
||||
const found = await files.nodeOf(Uri.file('/ws/backend/src/luffy/visibility.py'))
|
||||
|
||||
expect(found?.section).toBe(unstaged)
|
||||
@@ -177,7 +178,7 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
it('没摆出来的文件、目录找不到', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.showChanges([staged, unstaged])
|
||||
files.show({ kind: 'changes', view: [staged, unstaged] })
|
||||
|
||||
expect(await files.nodeOf(Uri.file('/ws/README.md'))).toBeUndefined()
|
||||
expect(await files.nodeOf(Uri.file('/ws/dist'))).toBeUndefined()
|
||||
@@ -187,14 +188,14 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
it('同一个文件摆在两个分组里时取最先摆的', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
const { staged, unstaged } = sections()
|
||||
files.showChanges([staged, unstaged])
|
||||
files.show({ kind: 'changes', view: [staged, unstaged] })
|
||||
|
||||
expect((await files.nodeOf(Uri.file('/ws/backend/src/gate.py')))?.section).toBe(staged)
|
||||
})
|
||||
|
||||
it('分组没有名字时根就在最上面一层,根的上一层没有', async () => {
|
||||
const files = new FileTreeProvider('open', decorator)
|
||||
files.showChanges([{ roots: [{ folder, tree: buildChangedTree([{ path: 'backend/src/gate.py', change: 'A' }]) }] }])
|
||||
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()
|
||||
|
||||
@@ -202,3 +203,46 @@ describe('只摆改动的文件时:在树上就找到,不在就没有', () =
|
||||
expect(topOf(files, found!)).toBe(root)
|
||||
})
|
||||
})
|
||||
|
||||
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 top = await files.getChildren()
|
||||
|
||||
expect(top.map(node => !isGroupNode(node) && [node.uri.path, node.isDirectory, node.isRoot])).toEqual([
|
||||
['/ws/backend/src', true, false],
|
||||
['/ws/backend/main.py', false, false],
|
||||
])
|
||||
// 行的根是所在的工作区文件夹:复制相对路径、文件注释、files.exclude 都按它算
|
||||
expect(top.every(node => !isGroupNode(node) && node.folder === folder)).toBe(true)
|
||||
})
|
||||
|
||||
it('最上面一层没有上一层;往下找得到,上一层走到收藏的目录下面为止', async () => {
|
||||
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'])
|
||||
expect(topOf(files, found!)).toBe((await files.getChildren())[0])
|
||||
})
|
||||
|
||||
it('目录外面的找不到;目录被删了时树是空的', async () => {
|
||||
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()
|
||||
disk.dirs.delete('/ws/backend')
|
||||
files.refresh()
|
||||
expect(await files.getChildren()).toEqual([])
|
||||
})
|
||||
|
||||
it('目录不在任何工作区文件夹里(文件夹被移出工作区):拿目录自己当根', async () => {
|
||||
disk.folders = []
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { pathRelation } from './treeReveal'
|
||||
|
||||
export interface FileNode {
|
||||
uri: Uri
|
||||
/** 这一行所在的根:工作区文件夹;只摆改动的文件时是那个根(仓库根、审查的工作副本) */
|
||||
/** 这一行所在的根:工作区文件夹(收藏栏里是收藏的目录所在的那个);只摆改动的文件时是那个根(仓库根、审查的工作副本) */
|
||||
folder: WorkspaceFolder
|
||||
isDirectory: boolean
|
||||
isRoot: boolean
|
||||
@@ -36,6 +36,19 @@ export interface ChangedSection {
|
||||
/** 只摆改动的文件时树上摆什么:一个或几个分组,每组下面每个根一个根节点,再往下只有改动的文件和通往它们的目录。 */
|
||||
export type ChangedFilesView = readonly ChangedSection[]
|
||||
|
||||
/**
|
||||
* 一棵树上摆什么:
|
||||
* workspace 是工作区文件树,每个工作区文件夹一个根节点(主栏);
|
||||
* directory 是这个目录下的完整文件树,直接摆它下面的各项、不再套一层根(收藏栏);
|
||||
* changes 是只摆改动的文件(主栏审查工单时 PR 改过的,「未提交的更改」一栏分「暂存的更改」「更改」两组),目录全部展开,删掉的文件列出来但打不开。
|
||||
*/
|
||||
export type TreeContent
|
||||
= | { kind: 'workspace' }
|
||||
| { kind: 'directory', uri: Uri }
|
||||
| { kind: 'changes', view: ChangedFilesView }
|
||||
|
||||
export const WORKSPACE_TREE: TreeContent = { kind: 'workspace' }
|
||||
|
||||
/** 分组行(「暂存的更改」「更改」)。 */
|
||||
export interface GroupNode {
|
||||
group: ChangedSection
|
||||
@@ -61,10 +74,8 @@ export interface FileTreeDecorator {
|
||||
const REFRESH_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
||||
* 侧栏的一棵文件树,只看和打开:主栏、「未提交的更改」、各收藏栏各用一个,摆什么见 TreeContent,可以整个换掉(show)。
|
||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||
* 筛选时换成只摆改动的文件(showChanges:审查工单时 PR 改过的,未提交的更改时各仓库里没提交的,分「暂存的更改」「更改」两组):
|
||||
* 目录全部展开,删掉的文件列出来但打不开。
|
||||
* 能按 Uri 找到树上那一行和它的上一层(nodeOf、getParent),侧栏定位到某个文件 / 目录(TreeLocator)靠它。
|
||||
*/
|
||||
export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
@@ -75,16 +86,18 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
/** 按分组、Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行;同一个文件可以在几个分组里各有一行。 */
|
||||
private readonly nodes = new Map<ChangedSection | undefined, Map<string, FileNode>>()
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
/** 筛选时只摆的那些文件;undefined 是普通的工作区文件树 */
|
||||
private changes: ChangedFilesView | undefined
|
||||
/** 筛选时每个分组的分组行 */
|
||||
private groups: GroupNode[] = []
|
||||
/** 只摆改动的文件时每个分组的分组行 */
|
||||
private groups: GroupNode[]
|
||||
/** 摆一个目录时它所在的工作区文件夹;整棵重摆时重新找(工作区文件夹可能变了) */
|
||||
private directoryFolder: WorkspaceFolder | undefined
|
||||
|
||||
/**
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param decorator 给行添信息(文件注释)
|
||||
* @param content 一开始摆什么,默认是工作区文件树
|
||||
*/
|
||||
constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
|
||||
constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator, private content: TreeContent = WORKSPACE_TREE) {
|
||||
this.groups = groupsOf(content)
|
||||
// 只关心增删:文件内容变化不影响树的结构
|
||||
const watcher = workspace.createFileSystemWatcher('**/*', false, true, false)
|
||||
this.disposables = [
|
||||
@@ -105,6 +118,7 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.nodes.clear()
|
||||
this.directoryFolder = undefined
|
||||
this.changed.fire(undefined)
|
||||
}
|
||||
|
||||
@@ -117,39 +131,31 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
return item
|
||||
}
|
||||
|
||||
/** 换成只摆改动的文件(undefined 回到工作区文件树),整棵树重画。 */
|
||||
showChanges(view: ChangedFilesView | undefined): void {
|
||||
this.changes = view
|
||||
this.groups = (view ?? []).map(group => ({ group }))
|
||||
/** 换成摆 content,整棵树重画。 */
|
||||
show(content: TreeContent): void {
|
||||
this.content = content
|
||||
this.groups = groupsOf(content)
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
async getChildren(node?: TreeNode): Promise<TreeNode[]> {
|
||||
if (this.changes)
|
||||
const content = this.content
|
||||
if (content.kind === 'changes')
|
||||
return this.changedChildren(node)
|
||||
if (!node)
|
||||
return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true))
|
||||
if (isGroupNode(node))
|
||||
if (node && isGroupNode(node))
|
||||
return [] // 分组行只在只摆改动的文件时有
|
||||
|
||||
let entries: [string, FileType][]
|
||||
try {
|
||||
entries = await workspace.fs.readDirectory(node.uri)
|
||||
}
|
||||
catch {
|
||||
return [] // 目录刚被删掉或没有权限
|
||||
}
|
||||
const excluded = createExcludeMatcher(workspace.getConfiguration('files', node.folder).get<ExcludeSetting>('exclude'))
|
||||
const names = new Set(entries.map(([name]) => name))
|
||||
const dir = path.posix.relative(node.folder.uri.path, node.uri.path)
|
||||
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
|
||||
.filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names))
|
||||
.map(entry => this.nodeAt(Uri.joinPath(node.uri, entry.name), node.folder, entry.isDirectory, false))
|
||||
if (node)
|
||||
return this.entriesOf(node.uri, node.folder)
|
||||
if (content.kind === 'workspace')
|
||||
return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true))
|
||||
this.directoryFolder ??= workspace.getWorkspaceFolder(content.uri) ?? { uri: content.uri, name: path.posix.basename(content.uri.path), index: workspace.workspaceFolders?.length ?? 0 }
|
||||
return this.entriesOf(content.uri, this.directoryFolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行的上一层,交回 getChildren 给过的同一个对象(`TreeView.reveal` 按对象认行):
|
||||
* 文件、目录的上一层是所在目录,根节点的上一层是它所在的分组行(只摆改动的文件、分组有名字时),别的没有。
|
||||
* 文件、目录的上一层是所在目录,根节点的上一层是它所在的分组行(只摆改动的文件、分组有名字时);
|
||||
* 摆一个目录时最上面一层(这个目录下的各项)没有上一层。
|
||||
*/
|
||||
getParent(node: TreeNode): TreeNode | undefined {
|
||||
if (isGroupNode(node))
|
||||
@@ -157,13 +163,15 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
if (node.isRoot)
|
||||
return this.groups.find(({ group }) => group === node.section && group.label !== undefined)
|
||||
const parentPath = path.posix.dirname(node.uri.path)
|
||||
if (this.content.kind === 'directory' && parentPath === this.content.uri.path)
|
||||
return undefined
|
||||
const isRoot = path.posix.relative(node.folder.uri.path, parentPath) === ''
|
||||
return this.nodeAt(isRoot ? node.folder.uri : node.uri.with({ path: parentPath }), node.folder, true, isRoot, node.section)
|
||||
}
|
||||
|
||||
/**
|
||||
* 树上 uri 这一行(文件或目录):从最上面一层层往下找,只进通往它的目录,路上的行按 getChildren 摆出来的记下。
|
||||
* 不在树上(工作区外、被 files.exclude 藏了、只摆改动的文件时没摆它)时没有;同一个文件摆在几个分组里时取最先摆的那行。
|
||||
* 不在树上(工作区外、收藏的目录外、被 files.exclude 藏了、只摆改动的文件时没摆它)时没有;同一个文件摆在几个分组里时取最先摆的那行。
|
||||
*/
|
||||
async nodeOf(uri: Uri): Promise<FileNode | undefined> {
|
||||
return this.findBelow(undefined, uri)
|
||||
@@ -185,7 +193,7 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||
return item
|
||||
}
|
||||
const expanded = node.isRoot || this.changes !== undefined
|
||||
const expanded = node.isRoot || this.content.kind === 'changes'
|
||||
const item = new TreeItem(node.uri, expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed)
|
||||
item.iconPath = ThemeIcon.Folder
|
||||
if (node.isRoot)
|
||||
@@ -214,6 +222,23 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
return node
|
||||
}
|
||||
|
||||
/** 目录 dir 下面一层:目录在前、按名字排,files.exclude 按所在根 folder 算相对路径藏掉的不摆。 */
|
||||
private async entriesOf(dir: Uri, folder: WorkspaceFolder): Promise<FileNode[]> {
|
||||
let entries: [string, FileType][]
|
||||
try {
|
||||
entries = await workspace.fs.readDirectory(dir)
|
||||
}
|
||||
catch {
|
||||
return [] // 目录刚被删掉或没有权限
|
||||
}
|
||||
const excluded = createExcludeMatcher(workspace.getConfiguration('files', folder).get<ExcludeSetting>('exclude'))
|
||||
const names = new Set(entries.map(([name]) => name))
|
||||
const relative = path.posix.relative(folder.uri.path, dir.path)
|
||||
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
|
||||
.filter(entry => !excluded(relative ? `${relative}/${entry.name}` : entry.name, names))
|
||||
.map(entry => this.nodeAt(Uri.joinPath(dir, entry.name), folder, entry.isDirectory, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* 只摆改动的文件时的一层:最上面是分组行(没有名字的分组直接摆它的各个根),分组下面是各个根,
|
||||
* 再往下只有改动的文件和通往它们的目录。
|
||||
@@ -261,3 +286,8 @@ export class FileTreeProvider implements TreeDataProvider<TreeNode> {
|
||||
this.timer = setTimeout(() => this.refresh(), REFRESH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** 只摆改动的文件时每个分组一行分组行,别的没有 */
|
||||
function groupsOf(content: TreeContent): GroupNode[] {
|
||||
return content.kind === 'changes' ? content.view.map(group => ({ group })) : []
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId } from '../favorites/favoriteSlots'
|
||||
import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles'
|
||||
import { COPY_COMMANDS } from './copyText'
|
||||
import { entryContext, GROUP_CONTEXT } from './fileEntries'
|
||||
@@ -12,38 +13,126 @@ interface Manifest {
|
||||
commands: { command: string, title: string, icon?: string }[]
|
||||
submenus: { id: string, label: string, icon?: string }[]
|
||||
menus: Record<string, MenuItem[]>
|
||||
views: Record<string, { id: string, name: string, when?: string }[]>
|
||||
}
|
||||
}
|
||||
|
||||
const { commands, submenus, menus } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes
|
||||
const { commands, submenus, menus, views } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes
|
||||
|
||||
/** 上下文键的值:没设的是 undefined */
|
||||
type Context = Record<string, string | boolean | string[] | undefined>
|
||||
|
||||
/**
|
||||
* 照 VS Code 的规则算 when 子句,够菜单里用的写法:`&&` `||` `!` 括号、`==` `!=`、`=~ /正则/`、`in 列表`、单独一个键(有值算真)。
|
||||
* `&&` 比 `||` 先算,和 VS Code 一样。
|
||||
*/
|
||||
function evaluate(when: string, context: Context): boolean {
|
||||
let at = 0
|
||||
const skip = (): void => {
|
||||
while (when[at] === ' ')
|
||||
at++
|
||||
}
|
||||
const eat = (token: string): boolean => {
|
||||
skip()
|
||||
if (!when.startsWith(token, at))
|
||||
return false
|
||||
at += token.length
|
||||
return true
|
||||
}
|
||||
const read = (pattern: RegExp): RegExpExecArray => {
|
||||
skip()
|
||||
const match = pattern.exec(when.slice(at))
|
||||
if (!match)
|
||||
throw new Error(`读不懂 when:${when}(在「${when.slice(at)}」)`)
|
||||
at += match[0].length
|
||||
return match
|
||||
}
|
||||
const primary = (): boolean => {
|
||||
if (eat('(')) {
|
||||
const value = or()
|
||||
if (!eat(')'))
|
||||
throw new Error(`括号没配对:${when}`)
|
||||
return value
|
||||
}
|
||||
const key = read(/^[\w.]+/)[0]
|
||||
if (eat('=~')) {
|
||||
const [, source, flags] = read(/^\/((?:\\.|[^/\\])*)\/([a-z]*)/)
|
||||
return new RegExp(source, flags).test(String(context[key] ?? ''))
|
||||
}
|
||||
if (eat('=='))
|
||||
return String(context[key]) === read(/^[\w.]+/)[0]
|
||||
if (eat('!='))
|
||||
return String(context[key]) !== read(/^[\w.]+/)[0]
|
||||
if (eat('in ')) {
|
||||
const list = context[read(/^[\w.]+/)[0]]
|
||||
return Array.isArray(list) && list.includes(String(context[key]))
|
||||
}
|
||||
return Boolean(context[key])
|
||||
}
|
||||
const unary = (): boolean => eat('!') ? !unary() : primary()
|
||||
function and(): boolean {
|
||||
let value = unary()
|
||||
while (eat('&&')) {
|
||||
const right = unary()
|
||||
value = value && right
|
||||
}
|
||||
return value
|
||||
}
|
||||
function or(): boolean {
|
||||
let value = and()
|
||||
while (eat('||')) {
|
||||
const right = and()
|
||||
value = value || right
|
||||
}
|
||||
return value
|
||||
}
|
||||
const value = or()
|
||||
skip()
|
||||
if (at !== when.length)
|
||||
throw new Error(`when 没读完:${when}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const FILES = 'superpowers.codeReader.files'
|
||||
const UNCOMMITTED = 'superpowers.codeReader.uncommitted'
|
||||
const FAVORITE = favoriteViewId(7)
|
||||
const READER_VIEWS = [FILES, UNCOMMITTED, ...FAVORITE_SLOTS.map(favoriteViewId)]
|
||||
const REVIEWING = 'superpowers.codeReader.reviewing'
|
||||
|
||||
const COPY_MENU = 'superpowers.codeReader.copy'
|
||||
const ADD_NOTE = 'superpowers.codeReader.addNote'
|
||||
const EDIT_NOTE = 'superpowers.codeReader.editNote'
|
||||
const STAGE = 'superpowers.codeReader.stage'
|
||||
const ASK = 'superpowers.codeReader.askPath'
|
||||
const ADD_FAVORITE = 'superpowers.codeReader.addFavorite'
|
||||
|
||||
const file = entryContext({ isDirectory: false, isRoot: false })
|
||||
const folder = entryContext({ isDirectory: true, isRoot: false })
|
||||
const root = entryContext({ isDirectory: true, isRoot: true })
|
||||
/** 「未提交的更改」里「更改」组的文件行 */
|
||||
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
|
||||
const STAGE = 'superpowers.codeReader.stage'
|
||||
const ASK = 'superpowers.codeReader.askPath'
|
||||
const noted = (context: string): string => rowDecoration(context, 'x', '注释', undefined)!.contextValue
|
||||
|
||||
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
|
||||
const palette = new Map(menus.commandPalette.map(item => [item.command, item.when]))
|
||||
const titleOf = (id: string): { title: string, icon?: string } | undefined => commands.find(item => item.command === id)
|
||||
|
||||
/** 某个菜单的某一组(行尾按钮 inline / 右键菜单的 copy、notes)对这一行显示哪些命令或子菜单,照 `@序号` 排 */
|
||||
function itemsFor(menu: string, group: string, contextValue: string): string[] {
|
||||
/** 在 view 这一栏(context 是别的上下文键)的某一行上,某个菜单的某一组(行尾按钮 inline / 右键菜单的 copy、notes…)显示哪些命令或子菜单,照 `@序号` 排 */
|
||||
function itemsFor(menu: string, group: string, row: string, view = FILES, context: Context = {}): string[] {
|
||||
return menus[menu]
|
||||
.filter(item => item.group?.split('@')[0] === group && item.when.includes('view == superpowers.codeReader.files'))
|
||||
.filter((item) => {
|
||||
const pattern = /viewItem =~ \/(.+)\/$/.exec(item.when)?.[1]
|
||||
return pattern !== undefined && new RegExp(pattern).test(contextValue)
|
||||
})
|
||||
.filter(item => item.group?.split('@')[0] === group && evaluate(item.when, { isMac: true, ...context, view, viewItem: row }))
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
.map(item => item.command ?? item.submenu ?? '')
|
||||
}
|
||||
|
||||
/** view 这一栏标题栏上显示哪些按钮,照 `@序号` 排 */
|
||||
function titleBar(view: string, context: Context = {}): string[] {
|
||||
return menus['view/title']
|
||||
.filter(item => evaluate(item.when, { ...context, view }))
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
.map(item => item.command ?? '')
|
||||
}
|
||||
|
||||
const FILE_COPIES = [
|
||||
'superpowers.codeReader.copyFileName',
|
||||
'superpowers.codeReader.copyFileStem',
|
||||
@@ -56,70 +145,84 @@ const FOLDER_COPIES = [
|
||||
'superpowers.codeReader.copyAbsolutePath',
|
||||
]
|
||||
|
||||
describe('侧栏行尾按钮', () => {
|
||||
it('先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => {
|
||||
for (const context of [file, folder]) {
|
||||
expect(itemsFor('view/item/context', 'inline', context)).toEqual([ASK, COPY_MENU, ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([ASK, COPY_MENU, EDIT_NOTE])
|
||||
}
|
||||
expect(itemsFor('view/item/context', 'inline', root)).toEqual([])
|
||||
describe('菜单的 when 子句', () => {
|
||||
it('每一条都读得懂(括号配对、正则写全)', () => {
|
||||
for (const item of Object.values(menus).flat())
|
||||
expect(() => evaluate(item.when, {})).not.toThrow()
|
||||
})
|
||||
|
||||
it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => {
|
||||
expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE])
|
||||
it('行菜单只挂在代码阅读的各栏,别的视图(看板、资源管理器)上没有', () => {
|
||||
for (const view of ['superpowers.kanban', 'workbench.explorer.fileView'])
|
||||
expect(itemsFor('view/item/context', 'inline', file, view)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('侧栏行尾按钮', () => {
|
||||
it('每一栏都是先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;根都没有', () => {
|
||||
for (const view of READER_VIEWS) {
|
||||
expect(itemsFor('view/item/context', 'inline', file, view)).toEqual([ASK, COPY_MENU, ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(file), view)).toEqual([ASK, COPY_MENU, EDIT_NOTE])
|
||||
expect(itemsFor('view/item/context', 'inline', root, view)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('目录行在主栏、收藏栏多一个「收藏目录」,排在最后;「未提交的更改」里没有', () => {
|
||||
for (const view of [FILES, FAVORITE]) {
|
||||
expect(itemsFor('view/item/context', 'inline', folder, view)).toEqual([ASK, COPY_MENU, ADD_NOTE, ADD_FAVORITE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(folder), view)).toEqual([ASK, COPY_MENU, EDIT_NOTE, ADD_FAVORITE])
|
||||
}
|
||||
expect(itemsFor('view/item/context', 'inline', folder, UNCOMMITTED)).toEqual([ASK, COPY_MENU, ADD_NOTE])
|
||||
})
|
||||
|
||||
it('「未提交的更改」里「更改」组的文件行多一个「暂存」,排在最后;别的行、别的栏都没有', () => {
|
||||
expect(itemsFor('view/item/context', 'inline', unstaged, UNCOMMITTED)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(unstaged), UNCOMMITTED)).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE])
|
||||
for (const context of [file, folder, root, noted(file), noted(folder)])
|
||||
expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE)
|
||||
expect(itemsFor('view/item/context', 'inline', context, UNCOMMITTED)).not.toContain(STAGE)
|
||||
for (const view of [FILES, FAVORITE])
|
||||
expect(itemsFor('view/item/context', 'inline', unstaged, view)).not.toContain(STAGE)
|
||||
})
|
||||
|
||||
it('分组行(「暂存的更改」「更改」)什么按钮、菜单都没有', () => {
|
||||
for (const group of ['inline', 'copy', 'fileManager', 'notes'])
|
||||
expect(itemsFor('view/item/context', group, GROUP_CONTEXT)).toEqual([])
|
||||
expect(itemsFor(COPY_MENU, 'copy', GROUP_CONTEXT)).toEqual([])
|
||||
for (const group of ['inline', 'copy', 'fileManager', 'notes', 'star'])
|
||||
expect(itemsFor('view/item/context', group, GROUP_CONTEXT, UNCOMMITTED)).toEqual([])
|
||||
expect(itemsFor(COPY_MENU, 'copy', GROUP_CONTEXT, UNCOMMITTED)).toEqual([])
|
||||
})
|
||||
|
||||
it('「复制」是带 copy 图标的子菜单(VS Code 画成点开的下拉按钮),注释按钮的图标是 note / edit', () => {
|
||||
expect(submenus.find(item => item.id === COPY_MENU)).toEqual({ id: COPY_MENU, label: '复制', icon: '$(copy)' })
|
||||
expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)')
|
||||
expect(commands.find(item => item.command === EDIT_NOTE)?.icon).toBe('$(edit)')
|
||||
expect(titleOf(ADD_NOTE)?.icon).toBe('$(note)')
|
||||
expect(titleOf(EDIT_NOTE)?.icon).toBe('$(edit)')
|
||||
})
|
||||
|
||||
it('「问 AI」在复制左边,文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => {
|
||||
const ask = menus['view/item/context'].filter(item => item.command === ASK)
|
||||
expect(ask).toEqual([{
|
||||
command: ASK,
|
||||
when: 'view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/',
|
||||
group: 'inline@0',
|
||||
}])
|
||||
expect(ask[0].when.includes('filter')).toBe(false)
|
||||
it('「问 AI」文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => {
|
||||
for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)])
|
||||
expect(itemsFor('view/item/context', 'inline', context)[0]).toBe(ASK)
|
||||
expect(itemsFor('view/item/context', 'inline', root)).not.toContain(ASK)
|
||||
expect(commands.find(item => item.command === ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' })
|
||||
expect(menus.commandPalette.find(item => item.command === ASK)?.when).toBe('false')
|
||||
expect(itemsFor('view/item/context', 'inline', context, FILES, { [REVIEWING]: true })[0]).toBe(ASK)
|
||||
expect(titleOf(ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' })
|
||||
expect(palette.get(ASK)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('复制菜单', () => {
|
||||
it.each([COPY_MENU, 'view/item/context'])('%s:文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => {
|
||||
for (const context of [file, noted(file), unstaged, noted(unstaged)])
|
||||
expect(itemsFor(menu, 'copy', context)).toEqual(FILE_COPIES)
|
||||
for (const context of [folder, noted(folder)])
|
||||
expect(itemsFor(menu, 'copy', context)).toEqual(FOLDER_COPIES)
|
||||
expect(itemsFor(menu, 'copy', root)).toEqual([])
|
||||
it.each([COPY_MENU, 'view/item/context'])('%s:每一栏都是文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => {
|
||||
for (const view of READER_VIEWS) {
|
||||
for (const context of [file, noted(file), unstaged, noted(unstaged)])
|
||||
expect(itemsFor(menu, 'copy', context, view)).toEqual(FILE_COPIES)
|
||||
for (const context of [folder, noted(folder)])
|
||||
expect(itemsFor(menu, 'copy', context, view)).toEqual(FOLDER_COPIES)
|
||||
expect(itemsFor(menu, 'copy', root, view)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('菜单上的字', () => {
|
||||
const title = (id: string): string | undefined => commands.find(item => item.command === id)?.title
|
||||
expect(FILE_COPIES.map(title)).toEqual(['复制文件名', '复制文件名(不带后缀)', '复制相对路径', '复制绝对路径'])
|
||||
expect(title('superpowers.codeReader.copyFolderName')).toBe('复制文件夹名')
|
||||
expect(FILE_COPIES.map(id => titleOf(id)?.title)).toEqual(['复制文件名', '复制文件名(不带后缀)', '复制相对路径', '复制绝对路径'])
|
||||
expect(titleOf('superpowers.codeReader.copyFolderName')?.title).toBe('复制文件夹名')
|
||||
})
|
||||
|
||||
it('命令都登记了、都有注册,命令面板里藏起来', () => {
|
||||
const palette = new Map(menus.commandPalette.map(item => [item.command, item.when]))
|
||||
for (const id of [...new Set([...FILE_COPIES, ...FOLDER_COPIES])]) {
|
||||
expect(id in COPY_COMMANDS).toBe(true)
|
||||
expect(commands.some(item => item.command === id)).toBe(true)
|
||||
expect(titleOf(id)).toBeDefined()
|
||||
expect(palette.get(id)).toBe('false')
|
||||
}
|
||||
expect(Object.keys(COPY_COMMANDS).sort()).toEqual([...new Set([...FILE_COPIES, ...FOLDER_COPIES])].sort())
|
||||
@@ -127,12 +230,14 @@ describe('复制菜单', () => {
|
||||
})
|
||||
|
||||
describe('右键菜单的注释', () => {
|
||||
it('没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根没有', () => {
|
||||
for (const context of [file, folder, unstaged]) {
|
||||
expect(itemsFor('view/item/context', 'notes', context)).toEqual([ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'notes', noted(context))).toEqual([EDIT_NOTE])
|
||||
it('每一栏没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,根没有', () => {
|
||||
for (const view of READER_VIEWS) {
|
||||
for (const context of [file, folder, unstaged]) {
|
||||
expect(itemsFor('view/item/context', 'notes', context, view)).toEqual([ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'notes', noted(context), view)).toEqual([EDIT_NOTE])
|
||||
}
|
||||
expect(itemsFor('view/item/context', 'notes', root, view)).toEqual([])
|
||||
}
|
||||
expect(itemsFor('view/item/context', 'notes', root)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,101 +248,143 @@ describe('右键菜单的「在系统文件管理器中显示」', () => {
|
||||
{ platform: 'isWindows', command: 'superpowers.codeReader.revealInFileExplorer', title: '在文件资源管理器中显示' },
|
||||
{ platform: 'isLinux', command: 'superpowers.codeReader.openContainingFolder', title: '打开所在的文件夹' },
|
||||
]
|
||||
const REVEAL_IDS = REVEALS.map(item => item.command)
|
||||
const whenOf = (command: string): string | undefined => menus['view/item/context'].find(item => item.command === command)?.when
|
||||
|
||||
it('文件、文件夹(含未提交、有注释)都有,工作区根没有;审查时也不藏', () => {
|
||||
for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)])
|
||||
expect(itemsFor('view/item/context', 'fileManager', context)).toEqual(REVEAL_IDS)
|
||||
expect(itemsFor('view/item/context', 'fileManager', root)).toEqual([])
|
||||
expect(REVEAL_IDS.some(id => whenOf(id)?.includes('filter'))).toBe(false)
|
||||
})
|
||||
|
||||
it('每个系统只出自己那一项,字和 VS Code 自带的一样', () => {
|
||||
for (const { platform, command, title } of REVEALS) {
|
||||
const when = whenOf(command)
|
||||
expect(when).toContain(`&& ${platform} &&`)
|
||||
for (const other of REVEALS.filter(item => item.platform !== platform))
|
||||
expect(when).not.toContain(other.platform)
|
||||
expect(commands.find(item => item.command === command)?.title).toBe(title)
|
||||
it('每个系统只出自己那一项:每一栏的文件、文件夹(含未提交、有注释)都有,根没有;审查时也不藏', () => {
|
||||
for (const { platform, command } of REVEALS) {
|
||||
const system = { isMac: false, [platform]: true, [REVIEWING]: true }
|
||||
for (const view of READER_VIEWS) {
|
||||
for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)])
|
||||
expect(itemsFor('view/item/context', 'fileManager', context, view, system)).toEqual([command])
|
||||
expect(itemsFor('view/item/context', 'fileManager', root, view, system)).toEqual([])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('自成一组,排在复制后面、注释前面(VS Code 按组名的字母序排组,组和组之间画分隔线)', () => {
|
||||
it('字和 VS Code 自带的一样,只在右键菜单里,不挂行尾按钮,命令面板里藏起来', () => {
|
||||
for (const { command, title } of REVEALS) {
|
||||
expect(titleOf(command)?.title).toBe(title)
|
||||
expect(menus['view/item/context'].filter(item => item.command === command)).toHaveLength(1)
|
||||
expect(palette.get(command)).toBe('false')
|
||||
}
|
||||
})
|
||||
|
||||
it('右键菜单分组:复制、在系统文件管理器中显示、注释、收藏目录(VS Code 按组名的字母序排组,组和组之间画分隔线)', () => {
|
||||
const groups = menus['view/item/context']
|
||||
.filter(item => item.when.includes('view == superpowers.codeReader.files') && item.group !== undefined && !item.group.startsWith('inline'))
|
||||
.filter(item => item.group !== undefined && !item.group.startsWith('inline'))
|
||||
.map(item => item.group!.split('@')[0])
|
||||
expect([...new Set(groups)].sort((a, b) => a.localeCompare(b))).toEqual(['copy', 'fileManager', 'notes'])
|
||||
})
|
||||
|
||||
it('只在右键菜单里,不挂行尾按钮,命令面板里藏起来', () => {
|
||||
for (const id of REVEAL_IDS) {
|
||||
expect(menus['view/item/context'].filter(item => item.command === id)).toHaveLength(1)
|
||||
expect(menus.commandPalette.find(item => item.command === id)?.when).toBe('false')
|
||||
}
|
||||
expect([...new Set(groups)].sort((a, b) => a.localeCompare(b))).toEqual(['copy', 'fileManager', 'notes', 'star'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('筛选(审查工单、未提交的更改)', () => {
|
||||
const FILTER = 'superpowers.codeReader.filter'
|
||||
describe('主栏的审查工单', () => {
|
||||
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
||||
const UNCOMMITTED = 'superpowers.codeReader.showUncommitted'
|
||||
const HIDE_TESTS = 'superpowers.codeReader.hideTests'
|
||||
const SHOW_TESTS = 'superpowers.codeReader.showTests'
|
||||
const HIDE_TESTS = 'superpowers.codeReader.hideReviewTests'
|
||||
const SHOW_TESTS = 'superpowers.codeReader.showReviewTests'
|
||||
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
|
||||
const TESTS_HIDDEN = 'superpowers.codeReader.testsHidden'
|
||||
const title = menus['view/title']
|
||||
.filter(item => item.when.startsWith('view == superpowers.codeReader.files'))
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
const whenOf = (command: string): string | undefined => title.find(item => item.command === command)?.when
|
||||
const REFRESH = 'superpowers.codeReader.refresh'
|
||||
const TESTS_HIDDEN = 'superpowers.codeReader.reviewTestsHidden'
|
||||
|
||||
it('标题栏:选工单、未提交的更改、隐藏 / 显示测试文件、开着筛选时才有的「显示全部」、刷新', () => {
|
||||
expect(title.map(item => item.command)).toEqual([PICK, UNCOMMITTED, HIDE_TESTS, SHOW_TESTS, SHOW_ALL, 'superpowers.codeReader.refresh'])
|
||||
expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true)
|
||||
expect(whenOf(SHOW_ALL)).toBe(`view == superpowers.codeReader.files && ${FILTER}`)
|
||||
for (const command of [PICK, UNCOMMITTED])
|
||||
expect(whenOf(command)).not.toContain(FILTER)
|
||||
it('标题栏:平时是选工单、刷新;审查时多出隐藏 / 显示测试文件(按开关换着显示)和「显示全部」', () => {
|
||||
expect(titleBar(FILES)).toEqual([PICK, REFRESH])
|
||||
expect(titleBar(FILES, { [REVIEWING]: true })).toEqual([PICK, HIDE_TESTS, SHOW_ALL, REFRESH])
|
||||
expect(titleBar(FILES, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toEqual([PICK, SHOW_TESTS, SHOW_ALL, REFRESH])
|
||||
expect(menus['view/title'].filter(item => item.when.includes(FILES)).every(item => item.group?.startsWith('navigation'))).toBe(true)
|
||||
})
|
||||
|
||||
it('隐藏 / 显示测试文件在未提交的更改和审查时都有(同一个开关),按是否隐藏着换着显示,整棵项目树上没有', () => {
|
||||
const listing = `(${FILTER} == uncommitted || ${FILTER} == review)`
|
||||
expect(whenOf(HIDE_TESTS)).toBe(`view == superpowers.codeReader.files && ${listing} && !${TESTS_HIDDEN}`)
|
||||
expect(whenOf(SHOW_TESTS)).toBe(`view == superpowers.codeReader.files && ${listing} && ${TESTS_HIDDEN}`)
|
||||
const palette = new Map(menus.commandPalette.map(item => [item.command, item.when]))
|
||||
expect(palette.get(HIDE_TESTS)).toBe(`${listing} && !${TESTS_HIDDEN}`)
|
||||
expect(palette.get(SHOW_TESTS)).toBe(`${listing} && ${TESTS_HIDDEN}`)
|
||||
it('审查时主栏行上没有注释、收藏按钮(那些行是工作副本里的,不记注释、不收藏);别的栏照常有,复制照旧', () => {
|
||||
const reviewing = { [REVIEWING]: true }
|
||||
expect(itemsFor('view/item/context', 'inline', file, FILES, reviewing)).toEqual([ASK, COPY_MENU])
|
||||
expect(itemsFor('view/item/context', 'inline', folder, FILES, reviewing)).toEqual([ASK, COPY_MENU])
|
||||
for (const group of ['notes', 'star'])
|
||||
expect(itemsFor('view/item/context', group, folder, FILES, reviewing)).toEqual([])
|
||||
expect(itemsFor('view/item/context', 'copy', file, FILES, reviewing)).toEqual(FILE_COPIES)
|
||||
expect(itemsFor('view/item/context', 'inline', folder, FAVORITE, reviewing)).toEqual([ASK, COPY_MENU, ADD_NOTE, ADD_FAVORITE])
|
||||
expect(itemsFor('view/item/context', 'inline', file, UNCOMMITTED, reviewing)).toEqual([ASK, COPY_MENU, ADD_NOTE])
|
||||
})
|
||||
|
||||
it('「暂存」只在未提交的更改时挂在「更改」组的文件行上,命令面板里藏起来', () => {
|
||||
const stage = menus['view/item/context'].filter(item => item.command === STAGE)
|
||||
expect(stage).toHaveLength(1)
|
||||
expect(stage[0].when).toContain(`${FILTER} == uncommitted`)
|
||||
expect(menus.commandPalette.find(item => item.command === STAGE)?.when).toBe('false')
|
||||
it('命令面板:「显示全部」、隐藏 / 显示测试文件只在审查时有', () => {
|
||||
expect(evaluate(palette.get(SHOW_ALL)!, {})).toBe(false)
|
||||
expect(evaluate(palette.get(SHOW_ALL)!, { [REVIEWING]: true })).toBe(true)
|
||||
expect(evaluate(palette.get(HIDE_TESTS)!, { [REVIEWING]: true })).toBe(true)
|
||||
expect(evaluate(palette.get(HIDE_TESTS)!, {})).toBe(false)
|
||||
expect(evaluate(palette.get(SHOW_TESTS)!, { [REVIEWING]: true, [TESTS_HIDDEN]: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('按钮的字和图标', () => {
|
||||
expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' })
|
||||
expect(commands.find(item => item.command === UNCOMMITTED)).toMatchObject({ title: '未提交的更改', icon: '$(git-commit)' })
|
||||
expect(commands.find(item => item.command === HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' })
|
||||
expect(commands.find(item => item.command === SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' })
|
||||
expect(commands.find(item => item.command === STAGE)).toMatchObject({ title: '暂存', icon: '$(add)' })
|
||||
expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' })
|
||||
expect(titleOf(PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' })
|
||||
expect(titleOf(HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' })
|
||||
expect(titleOf(SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' })
|
||||
expect(titleOf(SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' })
|
||||
})
|
||||
})
|
||||
|
||||
it('审查时行上没有注释按钮(工作副本里不记注释),未提交的更改时照常有;复制照旧', () => {
|
||||
const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE)
|
||||
expect(noteItems.length).toBeGreaterThan(0)
|
||||
expect(noteItems.every(item => item.when.includes(`${FILTER} != review`))).toBe(true)
|
||||
const copyItems = menus['view/item/context'].filter(item => item.submenu === COPY_MENU || item.command?.startsWith('superpowers.codeReader.copy'))
|
||||
expect(copyItems.some(item => item.when.includes(FILTER))).toBe(false)
|
||||
describe('「未提交的更改」一栏', () => {
|
||||
const HIDE_TESTS = 'superpowers.codeReader.hideUncommittedTests'
|
||||
const SHOW_TESTS = 'superpowers.codeReader.showUncommittedTests'
|
||||
const REFRESH = 'superpowers.codeReader.refreshUncommitted'
|
||||
const TESTS_HIDDEN = 'superpowers.codeReader.uncommittedTestsHidden'
|
||||
|
||||
it('是主栏下面常驻的一栏', () => {
|
||||
expect(views['superpowers-reader'].slice(0, 2)).toEqual([
|
||||
{ id: FILES, name: '代码阅读' },
|
||||
{ id: UNCOMMITTED, name: '未提交的更改' },
|
||||
])
|
||||
})
|
||||
|
||||
it('标题栏:隐藏 / 显示测试文件(这一栏自己的开关,和审查的分开)、刷新;不跟着审查变', () => {
|
||||
expect(titleBar(UNCOMMITTED)).toEqual([HIDE_TESTS, REFRESH])
|
||||
expect(titleBar(UNCOMMITTED, { [TESTS_HIDDEN]: true })).toEqual([SHOW_TESTS, REFRESH])
|
||||
expect(titleBar(UNCOMMITTED, { [REVIEWING]: true, 'superpowers.codeReader.reviewTestsHidden': true })).toEqual([HIDE_TESTS, REFRESH])
|
||||
})
|
||||
|
||||
it('按钮的字和图标同主栏的,命令面板里藏起来(只对这一栏)', () => {
|
||||
expect(titleOf(HIDE_TESTS)).toMatchObject({ title: '隐藏测试文件', icon: '$(beaker-stop)' })
|
||||
expect(titleOf(SHOW_TESTS)).toMatchObject({ title: '显示测试文件', icon: '$(beaker)' })
|
||||
expect(titleOf(REFRESH)).toMatchObject({ title: '刷新', icon: '$(refresh)' })
|
||||
expect(titleOf(STAGE)).toMatchObject({ title: '暂存', icon: '$(add)' })
|
||||
for (const id of [HIDE_TESTS, SHOW_TESTS, REFRESH, STAGE])
|
||||
expect(palette.get(id)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('收藏栏', () => {
|
||||
it(`预先声明 ${FAVORITE_SLOTS.length} 个栏位,排在「未提交的更改」后面,各自有收藏占着时才显示`, () => {
|
||||
expect(views['superpowers-reader'].slice(2)).toEqual(FAVORITE_SLOTS.map(slot => ({ id: favoriteViewId(slot), name: `收藏 ${slot}`, when: favoriteContext(slot) })))
|
||||
})
|
||||
|
||||
it('每一栏的标题栏:重命名、取消收藏,只对这一栏', () => {
|
||||
for (const slot of FAVORITE_SLOTS)
|
||||
expect(titleBar(favoriteViewId(slot))).toEqual([favoriteCommand('rename', slot), favoriteCommand('remove', slot)])
|
||||
})
|
||||
|
||||
it('每个栏位的命令都登记了、字和图标一样,命令面板里藏起来', () => {
|
||||
for (const slot of FAVORITE_SLOTS) {
|
||||
expect(titleOf(favoriteCommand('rename', slot))).toMatchObject({ title: '重命名', icon: '$(edit)' })
|
||||
expect(titleOf(favoriteCommand('remove', slot))).toMatchObject({ title: '取消收藏', icon: '$(star-full)' })
|
||||
expect(palette.get(favoriteCommand('rename', slot))).toBe('false')
|
||||
expect(palette.get(favoriteCommand('remove', slot))).toBe('false')
|
||||
}
|
||||
})
|
||||
|
||||
it('命令面板里「显示全部」只在开着筛选时有', () => {
|
||||
expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(FILTER)
|
||||
it('「收藏目录」:行尾按钮和右键菜单同一项,只对目录行(不含根、文件),星标图标,命令面板里藏起来', () => {
|
||||
for (const view of [FILES, FAVORITE]) {
|
||||
expect(itemsFor('view/item/context', 'star', folder, view)).toEqual([ADD_FAVORITE])
|
||||
for (const context of [file, root, unstaged])
|
||||
expect(itemsFor('view/item/context', 'star', context, view)).toEqual([])
|
||||
}
|
||||
expect(itemsFor('view/item/context', 'star', folder, UNCOMMITTED)).toEqual([])
|
||||
expect(titleOf(ADD_FAVORITE)).toMatchObject({ title: '收藏目录', icon: '$(star-empty)' })
|
||||
expect(palette.get(ADD_FAVORITE)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('只用一个上下文键,旧的 superpowers.codeReader.reviewing 不再出现', () => {
|
||||
describe('菜单里用到的', () => {
|
||||
it('命令都登记了;旧的筛选按钮、上下文键不再出现', () => {
|
||||
const ids = new Set(commands.map(item => item.command))
|
||||
for (const item of Object.values(menus).flat().filter(item => item.command?.startsWith('superpowers.codeReader.')))
|
||||
expect(ids.has(item.command!)).toBe(true)
|
||||
expect(ids.has('superpowers.codeReader.showUncommitted')).toBe(false)
|
||||
const whens = Object.values(menus).flat().map(item => item.when)
|
||||
expect(whens.some(when => when.includes('superpowers.codeReader.reviewing'))).toBe(false)
|
||||
expect(whens.some(when => /superpowers\.codeReader\.(?:filter|testsHidden)\b/.test(when))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Memento } from 'vscode'
|
||||
import { commands } from 'vscode'
|
||||
|
||||
/** 开关存在 workspaceState 的哪个键、标题栏按哪个上下文键换着显示「隐藏 / 显示测试文件」。 */
|
||||
export interface TestsToggleKeys {
|
||||
state: string
|
||||
context: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 一栏标题栏上「隐藏 / 显示测试文件」的开关:各栏各一个(主栏的审查、「未提交的更改」),按工作区记住;
|
||||
* 上下文键跟着开关走,标题栏按它换着显示两个按钮。
|
||||
*/
|
||||
export class TestsToggle {
|
||||
private value: boolean
|
||||
|
||||
/**
|
||||
* @param state workspaceState
|
||||
* @param keys 开关记在哪、上下文键叫什么
|
||||
* @param changed 开关换了:这一栏按新开关重摆
|
||||
*/
|
||||
constructor(private readonly state: Memento, private readonly keys: TestsToggleKeys, private readonly changed: () => void) {
|
||||
this.value = state.get<boolean>(keys.state) ?? false
|
||||
void commands.executeCommand('setContext', keys.context, this.value)
|
||||
}
|
||||
|
||||
/** 隐藏着测试文件:摆树时把测试文件剔掉 */
|
||||
get hidden(): boolean {
|
||||
return this.value
|
||||
}
|
||||
|
||||
/** 标题栏的「隐藏 / 显示测试文件」:换开关、按工作区记住,这一栏马上按新开关重摆。 */
|
||||
async set(hidden: boolean): Promise<void> {
|
||||
this.value = hidden
|
||||
this.changed()
|
||||
await Promise.all([
|
||||
commands.executeCommand('setContext', this.keys.context, hidden),
|
||||
this.state.update(this.keys.state, hidden || undefined),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { Event, Memento, TreeView, Uri } from 'vscode'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { FileTreeProvider, TreeNode } from './fileTree'
|
||||
import { commands, Disposable, EventEmitter } from 'vscode'
|
||||
|
||||
/** 上下文键:侧栏树开着哪种筛选,没设是全部文件。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
||||
const FILTER_CONTEXT = 'superpowers.codeReader.filter'
|
||||
/** workspaceState:隐藏测试文件。几种筛选共用这一个开关。 */
|
||||
const HIDE_TESTS_KEY = 'superpowers.codeReader.hideTests'
|
||||
/** 上下文键:隐藏着测试文件。标题栏的「隐藏测试文件 / 显示测试文件」按它换着显示。 */
|
||||
const TESTS_HIDDEN_CONTEXT = 'superpowers.codeReader.testsHidden'
|
||||
|
||||
/** 侧栏树的筛选:review 审查工单(只摆 PR 改过的文件),uncommitted 未提交的更改(只摆各仓库里没提交的文件)。 */
|
||||
export type TreeFilterKind = 'review' | 'uncommitted'
|
||||
|
||||
/** 一种筛选。 */
|
||||
export interface TreeFilter {
|
||||
/** 标题栏的「刷新」:重读这种筛选摆的文件 */
|
||||
reload: () => Promise<void>
|
||||
/** 「显示全部」或换成别的筛选:不再筛、不再记着,正在进的作废 */
|
||||
exit: () => Promise<void>
|
||||
/** 开着这种筛选时 uri 这个文件的预览对比什么(见 DiffBase);不在这种筛选管的范围里(仓库外、工作副本外)时没有 */
|
||||
diffBase: (uri: Uri) => DiffBase | undefined
|
||||
/** 开着这种筛选时隐藏测试文件的开关换了:按 TreeFilters.testsHidden 马上重摆树 */
|
||||
testsToggled: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏树上的几种筛选,同时只开一种:开一种时别的都退出;上下文键跟着开着的那种。
|
||||
* 筛选开起来时自己换上树、标题后面的字(description)、标题下面的一行(message);退出时由这里回到工作区文件树。
|
||||
* 开着的筛选也定打开着的预览对比什么(diffBase),换了筛选、基准变了都通知预览重新比。
|
||||
* 隐藏测试文件的开关也在这里:几种筛选共用一个、按工作区记住,换了只叫开着的那种重摆。
|
||||
*/
|
||||
export class TreeFilters {
|
||||
private readonly filters: TreeFilter[] = []
|
||||
private current: TreeFilter | undefined
|
||||
private readonly baseChange = new EventEmitter<void>()
|
||||
/** 开着的筛选换了(开了、换了、关了),或它的对比基准变了(git 状态变了、审查重读了 PR):打开着的预览照 diffBase 重新比 */
|
||||
readonly onDidChangeDiffBase: Event<void> = this.baseChange.event
|
||||
private hideTests: boolean
|
||||
|
||||
/** state 是 workspaceState:隐藏测试文件的开关记在这里。 */
|
||||
constructor(private readonly files: FileTreeProvider, private readonly view: TreeView<TreeNode>, private readonly state: Memento) {
|
||||
this.hideTests = state.get<boolean>(HIDE_TESTS_KEY) ?? false
|
||||
void commands.executeCommand('setContext', TESTS_HIDDEN_CONTEXT, this.hideTests)
|
||||
}
|
||||
|
||||
/** 隐藏着测试文件:筛选摆树时把测试文件剔掉 */
|
||||
get testsHidden(): boolean {
|
||||
return this.hideTests
|
||||
}
|
||||
|
||||
/** 标题栏的「隐藏 / 显示测试文件」:换开关、按工作区记住,开着的筛选马上按新开关重摆。 */
|
||||
async setTestsHidden(hidden: boolean): Promise<void> {
|
||||
this.hideTests = hidden
|
||||
this.current?.testsToggled()
|
||||
await Promise.all([
|
||||
commands.executeCommand('setContext', TESTS_HIDDEN_CONTEXT, hidden),
|
||||
this.state.update(HIDE_TESTS_KEY, hidden || undefined),
|
||||
])
|
||||
}
|
||||
|
||||
/** 开着的筛选下 uri 这个文件的预览对比什么;没开筛选(全部文件)时没有,预览不标差异。 */
|
||||
diffBase(uri: Uri): DiffBase | undefined {
|
||||
return this.current?.diffBase(uri)
|
||||
}
|
||||
|
||||
/** filter 的对比基准变了时调;它没开着就不理。 */
|
||||
baseChanged(filter: TreeFilter): void {
|
||||
if (this.current === filter)
|
||||
this.baseChange.fire()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.baseChange.dispose()
|
||||
}
|
||||
|
||||
/** 开着的那种筛选;没开是全部文件 */
|
||||
get active(): TreeFilter | undefined {
|
||||
return this.current
|
||||
}
|
||||
|
||||
/** 登记一种筛选:别的筛选开起来时让它退出。 */
|
||||
add(filter: TreeFilter): void {
|
||||
this.filters.push(filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图第一次露出来时调 run(已经露着就马上调):筛选接着上次的,没打开侧栏就不去拉 PR、读 git。
|
||||
* @returns 还没露出来时盯着的监听
|
||||
*/
|
||||
whenShown(run: () => void): Disposable {
|
||||
if (this.view.visible) {
|
||||
run()
|
||||
return Disposable.from()
|
||||
}
|
||||
const shown = this.view.onDidChangeVisibility(({ visible }) => {
|
||||
if (!visible)
|
||||
return
|
||||
shown.dispose()
|
||||
run()
|
||||
})
|
||||
return shown
|
||||
}
|
||||
|
||||
/** filter 换上侧栏树之前调:别的筛选都退出(同步的部分在这里就做完了,调用方紧接着换上自己的),上下文键设成 kind。 */
|
||||
async enter(filter: TreeFilter, kind: TreeFilterKind): Promise<void> {
|
||||
this.current = filter
|
||||
this.baseChange.fire()
|
||||
await Promise.all([
|
||||
...this.filters.filter(other => other !== filter).map(other => other.exit()),
|
||||
commands.executeCommand('setContext', FILTER_CONTEXT, kind),
|
||||
])
|
||||
}
|
||||
|
||||
/** filter 退出时调:它开着就回到工作区文件树,清掉标题后面的字、标题下面的一行和上下文键;没开着(还在进、或已经换成别的)什么都不动。 */
|
||||
async leave(filter: TreeFilter): Promise<void> {
|
||||
if (this.current !== filter)
|
||||
return
|
||||
this.current = undefined
|
||||
this.baseChange.fire()
|
||||
this.files.showChanges(undefined)
|
||||
this.view.description = undefined
|
||||
this.view.message = undefined
|
||||
await commands.executeCommand('setContext', FILTER_CONTEXT, undefined)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ function activeTabUri(): Uri | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏的树定位到某个文件 / 目录:展开路上的目录、选中那一行(目录顺带展开),焦点留在编辑器。
|
||||
* 侧栏的一棵树(主栏、各收藏栏各一个)定位到某个文件 / 目录:展开路上的目录、选中那一行(目录顺带展开),焦点留在编辑器。
|
||||
* 跟着当前文件走:激活的标签页换成一个文件就定位过去,点链接、跳到定义、在标签栏切换、侧栏点开都走这一条;
|
||||
* 规则同 VS Code 资源管理器:`explorer.autoReveal` 关掉不跟,`explorer.autoRevealExclude` 里的不跟;
|
||||
* 树上没有的(工作区外、被 files.exclude 藏了、筛选时没摆它)不动,也不退出筛选。
|
||||
* 侧栏这棵树没露出来时不跟(reveal 会把它亮出来,抢了用户的侧栏),露出来时补上。
|
||||
* 树上没有的(工作区外、收藏的目录外、被 files.exclude 藏了、审查时没摆它)不动,也不退出审查。
|
||||
* 这棵树没露出来时不跟(reveal 会把它亮出来,抢了用户的侧栏),露出来时补上。
|
||||
*/
|
||||
export class TreeLocator {
|
||||
private readonly disposables: Disposable[]
|
||||
@@ -50,7 +50,7 @@ export class TreeLocator {
|
||||
|
||||
/**
|
||||
* 在树上定位 uri(仓库链接指到的目录);侧栏这棵树没露出来时 VS Code 把它亮出来,点了链接就是要看。
|
||||
* @returns 树上没有它(工作区外、被藏了、筛选时没摆它)时 false
|
||||
* @returns 树上没有它(工作区外、被藏了、审查时没摆它)时 false
|
||||
*/
|
||||
async reveal(uri: Uri): Promise<boolean> {
|
||||
// 要看的是这一行:正在跟的作废,树因此亮出来时也不再跟回当前文件
|
||||
@@ -89,7 +89,7 @@ export class TreeLocator {
|
||||
await this.show(node)
|
||||
}
|
||||
catch (err) {
|
||||
// 找到行之后树刚好刷新了(文件增删、换了筛选),这一行已经不是树上的那个对象
|
||||
// 找到行之后树刚好刷新了(文件增删、进出审查),这一行已经不是树上的那个对象
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `侧栏没能定位到 ${uri.fsPath}:${message}` })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TreeView } from 'vscode'
|
||||
import { Disposable } from 'vscode'
|
||||
|
||||
/**
|
||||
* 侧栏一栏第一次露出来时调 run(已经露着就马上调):没展开这一栏就不去拉 PR、读 git。
|
||||
* @returns 还没露出来时盯着的监听
|
||||
*/
|
||||
export function whenShown(view: TreeView<unknown>, run: () => void): Disposable {
|
||||
if (view.visible) {
|
||||
run()
|
||||
return Disposable.from()
|
||||
}
|
||||
const shown = view.onDidChangeVisibility(({ visible }) => {
|
||||
if (!visible)
|
||||
return
|
||||
shown.dispose()
|
||||
run()
|
||||
})
|
||||
return shown
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Favorite } from './favoriteSlots'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FAVORITE_SLOTS, favoriteCommand, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
|
||||
const favorite = (slot: number, name = `收藏${slot}`): Favorite => ({ uri: `file:///ws/dir${slot}`, name, slot })
|
||||
|
||||
/** 栏位 1…count 全占着 */
|
||||
const filled = (count: number): Favorite[] => Array.from({ length: count }, (_, index) => favorite(index + 1))
|
||||
|
||||
describe('新收藏占哪个栏位', () => {
|
||||
it('没有收藏时占第 1 个', () => {
|
||||
expect(placeFavorite([], 'file:///ws/a')).toEqual({ slot: 1 })
|
||||
})
|
||||
|
||||
it('占没被占的里面最小的:中间空出来的先用', () => {
|
||||
expect(placeFavorite([favorite(1), favorite(2)], 'file:///ws/a')).toEqual({ slot: 3 })
|
||||
expect(placeFavorite([favorite(1), favorite(3), favorite(4)], 'file:///ws/a')).toEqual({ slot: 2 })
|
||||
expect(placeFavorite([favorite(5)], 'file:///ws/a')).toEqual({ slot: 1 })
|
||||
})
|
||||
|
||||
it('取消收藏后空出来的栏位给下一个收藏用,别的收藏不挪位置', () => {
|
||||
const left = removeFavorite([favorite(1), favorite(2), favorite(3)], 2)
|
||||
|
||||
expect(left).toEqual([favorite(1), favorite(3)])
|
||||
expect(placeFavorite(left, 'file:///ws/a')).toEqual({ slot: 2 })
|
||||
})
|
||||
|
||||
it(`${FAVORITE_SLOTS.length} 个栏位都占满了:满了`, () => {
|
||||
expect(FAVORITE_SLOTS).toEqual(Array.from({ length: 30 }, (_, index) => index + 1))
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/a')).toBe('full')
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length - 1), 'file:///ws/a')).toEqual({ slot: FAVORITE_SLOTS.length })
|
||||
})
|
||||
|
||||
it('同一个目录收藏过:给出原来那个,不再占栏位;满了也先认出收藏过', () => {
|
||||
const favorites = [favorite(1), favorite(2)]
|
||||
expect(placeFavorite(favorites, 'file:///ws/dir2')).toEqual({ existing: favorite(2) })
|
||||
expect(placeFavorite(filled(FAVORITE_SLOTS.length), 'file:///ws/dir7')).toEqual({ existing: favorite(7) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('重命名、取消收藏', () => {
|
||||
it('重命名只改那一栏的名字,栏位不动', () => {
|
||||
expect(renameFavorite([favorite(1), favorite(4)], 4, '后端接口')).toEqual([favorite(1), favorite(4, '后端接口')])
|
||||
})
|
||||
|
||||
it('取消收藏只去掉那一栏;没占着的栏位什么都不变', () => {
|
||||
expect(removeFavorite([favorite(1), favorite(4)], 1)).toEqual([favorite(4)])
|
||||
expect(removeFavorite([favorite(1), favorite(4)], 2)).toEqual([favorite(1), favorite(4)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位对应的视图、上下文键、命令', () => {
|
||||
it('第 n 个栏位各有自己的视图 id、显示不显示的上下文键、标题栏上重命名 / 取消收藏的命令', () => {
|
||||
expect(favoriteViewId(3)).toBe('superpowers.codeReader.favorite3')
|
||||
expect(favoriteContext(3)).toBe('superpowers.codeReader.hasFavorite3')
|
||||
expect(favoriteCommand('rename', 3)).toBe('superpowers.codeReader.renameFavorite3')
|
||||
expect(favoriteCommand('remove', 30)).toBe('superpowers.codeReader.removeFavorite30')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 收藏栏的栏位:VS Code 的视图要在 package.json 里预先声明,所以声明了固定这么多个收藏栏(superpowers.codeReader.favorite1 … favorite30),
|
||||
* 有收藏占着的才显示。这里是栏位号 1、2、3…
|
||||
*/
|
||||
export const FAVORITE_SLOTS: readonly number[] = Array.from({ length: 30 }, (_, index) => index + 1)
|
||||
|
||||
/** 一个收藏:目录(Uri 字符串)、栏标题上的名字、占着第几个栏位。 */
|
||||
export interface Favorite {
|
||||
uri: string
|
||||
name: string
|
||||
slot: number
|
||||
}
|
||||
|
||||
/** 第 slot 个收藏栏的视图 id */
|
||||
export function favoriteViewId(slot: number): string {
|
||||
return `superpowers.codeReader.favorite${slot}`
|
||||
}
|
||||
|
||||
/** 上下文键:第 slot 个栏位有收藏占着,这一栏才显示(package.json 里视图的 when 按它) */
|
||||
export function favoriteContext(slot: number): string {
|
||||
return `superpowers.codeReader.hasFavorite${slot}`
|
||||
}
|
||||
|
||||
/** 第 slot 个收藏栏标题栏上的按钮命令:rename 重命名、remove 取消收藏。标题栏按钮不知道点的是哪一栏,所以每个栏位各一条。 */
|
||||
export function favoriteCommand(action: 'rename' | 'remove', slot: number): string {
|
||||
return `superpowers.codeReader.${action}Favorite${slot}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏 uri 这个目录要占哪个栏位:收藏过的给出原来那个(不重复收藏),栏位都占满了是 full,别的占没被占的里面最小的。
|
||||
* 一个收藏一直占着它的栏位、删掉才空出来,用户拖动排过的位置 VS Code 按栏位记着,所以不会错乱。
|
||||
*/
|
||||
export function placeFavorite(favorites: readonly Favorite[], uri: string): { existing: Favorite } | { slot: number } | 'full' {
|
||||
const existing = favorites.find(favorite => favorite.uri === uri)
|
||||
if (existing)
|
||||
return { existing }
|
||||
const slot = FAVORITE_SLOTS.find(slot => !favorites.some(favorite => favorite.slot === slot))
|
||||
return slot === undefined ? 'full' : { slot }
|
||||
}
|
||||
|
||||
/** 第 slot 栏的收藏改叫 name,栏位不动。 */
|
||||
export function renameFavorite(favorites: readonly Favorite[], slot: number, name: string): Favorite[] {
|
||||
return favorites.map(favorite => favorite.slot === slot ? { ...favorite, name } : favorite)
|
||||
}
|
||||
|
||||
/** 取消第 slot 栏的收藏,空出这个栏位。 */
|
||||
export function removeFavorite(favorites: readonly Favorite[], slot: number): Favorite[] {
|
||||
return favorites.filter(favorite => favorite.slot !== slot)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { Memento, TreeView } from 'vscode'
|
||||
import type { FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree'
|
||||
import type { Favorite } from './favoriteSlots'
|
||||
import * as path from 'node:path'
|
||||
import { commands, FileType, Uri, window, workspace } from 'vscode'
|
||||
import { FileTreeProvider } from '../explorer/fileTree'
|
||||
import { TreeLocator } from '../explorer/treeLocator'
|
||||
import { FAVORITE_SLOTS, favoriteContext, favoriteViewId, placeFavorite, removeFavorite, renameFavorite } from './favoriteSlots'
|
||||
|
||||
/** workspaceState:收藏的目录(Favorite 的数组),按工作区记。 */
|
||||
const FAVORITES_KEY = 'superpowers.codeReader.favorites'
|
||||
|
||||
/** 弹顶部的输入框问收藏栏叫什么(原值整段选中);Esc 或点别处是不改。 */
|
||||
async function askName(title: string, value: string): Promise<string | undefined> {
|
||||
const name = await window.showInputBox({
|
||||
title,
|
||||
value,
|
||||
prompt: '收藏栏标题上显示的名字',
|
||||
validateInput: text => text.trim() ? undefined : '名字不能为空',
|
||||
})
|
||||
return name?.trim()
|
||||
}
|
||||
|
||||
/** 亮出第 slot 个收藏栏(VS Code 给每个视图自带一条 `<视图 id>.focus` 命令)。 */
|
||||
async function focus(slot: number): Promise<void> {
|
||||
await commands.executeCommand(`${favoriteViewId(slot)}.focus`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个收藏栏:这个目录下的完整文件树(自己的文件树和视图),标题是起的名字、后面淡色写目录相对工作区的路径;
|
||||
* 当前文件在这个目录下、这一栏露着时跟过去定位。目录被删了时栏里说一句目录不在了。收藏时建、取消收藏时销毁。
|
||||
*/
|
||||
class FavoritePane {
|
||||
private readonly uri: Uri
|
||||
private readonly view: TreeView<TreeNode>
|
||||
private readonly disposables: { dispose: () => unknown }[]
|
||||
/** 第几次查目录在不在:连着查时只认最后一次 */
|
||||
private round = 0
|
||||
|
||||
constructor(favorite: Favorite, openCommand: string, decorator: FileTreeDecorator) {
|
||||
this.uri = Uri.parse(favorite.uri)
|
||||
const files = new FileTreeProvider(openCommand, decorator, { kind: 'directory', uri: this.uri })
|
||||
this.view = window.createTreeView(favoriteViewId(favorite.slot), { treeDataProvider: files, showCollapseAll: true })
|
||||
this.view.title = favorite.name
|
||||
this.view.description = workspace.asRelativePath(this.uri)
|
||||
this.disposables = [
|
||||
files,
|
||||
this.view,
|
||||
new TreeLocator(files, this.view),
|
||||
// 树整棵重摆时(目录下有增删、工作区文件夹变了)看看目录还在不在
|
||||
files.onDidChangeTreeData((nodes) => {
|
||||
if (!nodes)
|
||||
void this.check()
|
||||
}),
|
||||
]
|
||||
void this.check()
|
||||
}
|
||||
|
||||
rename(name: string): void {
|
||||
this.view.title = name
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private async check(): Promise<void> {
|
||||
const round = ++this.round
|
||||
const exists = await workspace.fs.stat(this.uri).then(stat => (stat.type & FileType.Directory) !== 0, () => false)
|
||||
if (round === this.round)
|
||||
this.view.message = exists ? undefined : `目录不在了:${workspace.asRelativePath(this.uri)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏的收藏栏:收藏一个目录占一个栏位,栏里是这个目录下的完整文件树,规则同主栏的工作区文件树、不受审查影响(栏位怎么分见 placeFavorite)。
|
||||
* 只给有收藏占着的栏位建视图,上下文键让这一栏显示出来;收藏按工作区记住,打开窗口时照记着的建回来。
|
||||
*/
|
||||
export class FavoriteViews {
|
||||
private favorites: Favorite[]
|
||||
/** 栏位号 → 那一栏 */
|
||||
private readonly panes = new Map<number, FavoritePane>()
|
||||
|
||||
/**
|
||||
* @param state workspaceState
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param decorator 给行添信息(文件注释),和别的栏共用
|
||||
*/
|
||||
constructor(private readonly state: Memento, private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
|
||||
this.favorites = state.get<Favorite[]>(FAVORITES_KEY) ?? []
|
||||
for (const favorite of this.favorites)
|
||||
this.open(favorite)
|
||||
void Promise.all(this.favorites.map(favorite => commands.executeCommand('setContext', favoriteContext(favorite.slot), true)))
|
||||
}
|
||||
|
||||
/** 目录行上的「收藏目录」:起个名字(默认是目录名)占一个空栏位并亮出那一栏;收藏过的说一声、亮出原来那一栏,满了说一声。 */
|
||||
async add(node: FileNode | undefined): Promise<void> {
|
||||
if (!node)
|
||||
return // 只挂在侧栏的目录行上,别处调用没有目标
|
||||
const uri = node.uri.toString()
|
||||
const placement = placeFavorite(this.favorites, uri)
|
||||
if (placement === 'full') {
|
||||
void window.showWarningMessage(`收藏栏满了(最多 ${FAVORITE_SLOTS.length} 个),先取消一个再收藏`)
|
||||
return
|
||||
}
|
||||
if ('existing' in placement) {
|
||||
void window.showInformationMessage(`这个目录已经收藏过了:「${placement.existing.name}」`)
|
||||
await focus(placement.existing.slot)
|
||||
return
|
||||
}
|
||||
const name = await askName('收藏目录', path.posix.basename(node.uri.path))
|
||||
if (name === undefined)
|
||||
return
|
||||
const favorite: Favorite = { uri, name, slot: placement.slot }
|
||||
this.open(favorite)
|
||||
await Promise.all([
|
||||
this.save([...this.favorites, favorite]),
|
||||
commands.executeCommand('setContext', favoriteContext(favorite.slot), true),
|
||||
])
|
||||
await focus(favorite.slot)
|
||||
}
|
||||
|
||||
/** 收藏栏标题栏的「重命名」:改第 slot 栏的标题。 */
|
||||
async rename(slot: number): Promise<void> {
|
||||
const favorite = this.favorites.find(item => item.slot === slot)
|
||||
if (!favorite)
|
||||
return
|
||||
const name = await askName('重命名收藏', favorite.name)
|
||||
if (name === undefined)
|
||||
return
|
||||
this.panes.get(slot)?.rename(name)
|
||||
await this.save(renameFavorite(this.favorites, slot, name))
|
||||
}
|
||||
|
||||
/** 收藏栏标题栏的「取消收藏」:藏起第 slot 栏、空出栏位,再销毁它的视图(免得露着的栏里闪一下没有数据)。 */
|
||||
async remove(slot: number): Promise<void> {
|
||||
const pane = this.panes.get(slot)
|
||||
this.panes.delete(slot)
|
||||
await Promise.all([
|
||||
this.save(removeFavorite(this.favorites, slot)),
|
||||
commands.executeCommand('setContext', favoriteContext(slot), false),
|
||||
])
|
||||
pane?.dispose()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const pane of this.panes.values())
|
||||
pane.dispose()
|
||||
}
|
||||
|
||||
private open(favorite: Favorite): void {
|
||||
this.panes.set(favorite.slot, new FavoritePane(favorite, this.openCommand, this.decorator))
|
||||
}
|
||||
|
||||
private async save(favorites: Favorite[]): Promise<void> {
|
||||
this.favorites = favorites
|
||||
await this.state.update(FAVORITES_KEY, favorites.length > 0 ? favorites : undefined)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,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 { BlockFilter } from './editor/blockFilter'
|
||||
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
|
||||
import { languageOf, openingFor } from './editor/openTarget'
|
||||
@@ -19,8 +20,9 @@ import { FileTreeProvider } from './explorer/fileTree'
|
||||
import { registerPathAsk } from './explorer/pathAskPanel'
|
||||
import { registerRevealCommands } from './explorer/revealCommands'
|
||||
import { RowDecorator } from './explorer/rowDecorator'
|
||||
import { TreeFilters } from './explorer/treeFilters'
|
||||
import { TreeLocator } from './explorer/treeLocator'
|
||||
import { FAVORITE_SLOTS, favoriteCommand } from './favorites/favoriteSlots'
|
||||
import { FavoriteViews } from './favorites/favoriteViews'
|
||||
import { createBashAdapter } from './languages/bash'
|
||||
import { createDockerfileAdapter } from './languages/dockerfile'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
@@ -52,7 +54,8 @@ import { dockerfileZhTranslator } from './translators/dockerfileZh'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
import { rustZhTranslator } from './translators/rustZh'
|
||||
import { typescriptZhTranslator } from './translators/typescriptZh'
|
||||
import { UncommittedMode } from './uncommitted/uncommittedMode'
|
||||
import { HeadBases } from './uncommitted/headBases'
|
||||
import { UncommittedView } from './uncommitted/uncommittedView'
|
||||
import { workspaceIndexHost } from './workspaceFiles'
|
||||
import { WorkspaceIndexes } from './workspaceIndex'
|
||||
|
||||
@@ -72,7 +75,8 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入代码阅读:注册表、侧栏文件树(问路径、复制、在系统文件管理器里显示、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 接入代码阅读:注册表、侧栏的几栏(主栏的文件树可以按审查工单只看 PR 改过的文件,常驻的「未提交的更改」,每个收藏目录一栏;
|
||||
* 行上有问路径、复制、在系统文件管理器里显示、文件注释、收藏目录)、预览编辑器(含问 AI,按文件所在位置标差异)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
|
||||
*/
|
||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
@@ -94,14 +98,17 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
}
|
||||
const notes = new FileNotes()
|
||||
const entries = new WorkspaceEntries()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, new RowDecorator(notes, entries))
|
||||
const decorator = new RowDecorator(notes, entries)
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, decorator)
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
const locator = new TreeLocator(files, view)
|
||||
const filters = new TreeFilters(files, view, context.workspaceState)
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files, filters)
|
||||
const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters)
|
||||
// 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries, uri => locator.reveal(uri))
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files)
|
||||
const uncommitted = new UncommittedView(context, OPEN_COMMAND, decorator)
|
||||
const favorites = new FavoriteViews(context.workspaceState, OPEN_COMMAND, decorator)
|
||||
const heads = new HeadBases()
|
||||
// 预览对比什么按文件所在的位置定:审查工作副本里的对比 PR 的目标分支,工作区仓库里有改动的对比上一次提交
|
||||
const diffBases = locatedDiffBases([review, heads])
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), diffBases, entries, uri => locator.reveal(uri))
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
@@ -129,18 +136,27 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
files,
|
||||
view,
|
||||
locator,
|
||||
filters,
|
||||
review,
|
||||
uncommitted,
|
||||
// 开着筛选时刷新是重读它摆的文件(PR 改过的、git 状态),平时是重读工作区文件树
|
||||
commands.registerCommand('superpowers.codeReader.refresh', () => filters.active ? filters.active.reload() : files.refresh()),
|
||||
favorites,
|
||||
heads,
|
||||
// 主栏审查时刷新是重读 PR 改过的文件,平时是重读工作区文件树
|
||||
commands.registerCommand('superpowers.codeReader.refresh', () => review.reviewing ? review.reload() : files.refresh()),
|
||||
commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()),
|
||||
commands.registerCommand('superpowers.codeReader.showUncommitted', () => uncommitted.enter()),
|
||||
commands.registerCommand('superpowers.codeReader.hideTests', () => filters.setTestsHidden(true)),
|
||||
commands.registerCommand('superpowers.codeReader.showTests', () => filters.setTestsHidden(false)),
|
||||
commands.registerCommand('superpowers.codeReader.hideReviewTests', () => review.setTestsHidden(true)),
|
||||
commands.registerCommand('superpowers.codeReader.showReviewTests', () => review.setTestsHidden(false)),
|
||||
commands.registerCommand('superpowers.codeReader.showAllFiles', () => review.exit()),
|
||||
commands.registerCommand('superpowers.codeReader.refreshUncommitted', () => uncommitted.reload()),
|
||||
commands.registerCommand('superpowers.codeReader.hideUncommittedTests', () => uncommitted.setTestsHidden(true)),
|
||||
commands.registerCommand('superpowers.codeReader.showUncommittedTests', () => uncommitted.setTestsHidden(false)),
|
||||
// 「更改」组文件行上的按钮传来的是这一行的节点
|
||||
commands.registerCommand('superpowers.codeReader.stage', (node?: FileNode) => uncommitted.stage(node)),
|
||||
commands.registerCommand('superpowers.codeReader.showAllFiles', () => filters.active?.exit()),
|
||||
// 目录行上的按钮传来的是这一行的节点;收藏栏标题栏上的按钮不带是哪一栏,所以每个栏位各一条命令
|
||||
commands.registerCommand('superpowers.codeReader.addFavorite', (node?: FileNode) => favorites.add(node)),
|
||||
...FAVORITE_SLOTS.flatMap(slot => [
|
||||
commands.registerCommand(favoriteCommand('rename', slot), () => favorites.rename(slot)),
|
||||
commands.registerCommand(favoriteCommand('remove', slot), () => favorites.remove(slot)),
|
||||
]),
|
||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)),
|
||||
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
|
||||
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode'
|
||||
import type { Disposable, Event, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode'
|
||||
import type { PrFileDto } from '../../gitea/api'
|
||||
import type { RepoContext } from '../../gitea/repoContext'
|
||||
import type { Issue } from '../../gitea/types'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { DiffBase, DiffBaseSource } from '../diff/diffBase'
|
||||
import type { FileTreeProvider, TreeNode } from '../explorer/fileTree'
|
||||
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||
import type { FileCount } from './pickList'
|
||||
import * as path from 'node:path'
|
||||
import { QuickPickItemKind, Uri, window, workspace } from 'vscode'
|
||||
import { commands, EventEmitter, QuickPickItemKind, Uri, window, workspace } from 'vscode'
|
||||
import { fileAtRef } from '../../git/fileAt'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { WORKSPACE_TREE } from '../explorer/fileTree'
|
||||
import { TestsToggle } from '../explorer/testsToggle'
|
||||
import { whenShown } from '../explorer/viewShown'
|
||||
import { toPathKey } from '../pathMap'
|
||||
import { ChangeDecorations } from './changeDecorations'
|
||||
import { changeOf, reviewBaseFile, reviewTree } from './changedFiles'
|
||||
@@ -20,6 +22,10 @@ import { localWorktree, prepareWorktree } from './reviewWorktree'
|
||||
|
||||
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
|
||||
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
|
||||
/** 上下文键:主栏正审查着工单。标题栏的「显示全部」、隐藏 / 显示测试文件按它显示,主栏行上的注释、收藏按钮按它藏起来(那些行是工作副本里的)。 */
|
||||
const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing'
|
||||
/** 审查时隐藏测试文件的开关:记在 workspaceState 的键、标题栏按它换按钮的上下文键 */
|
||||
const REVIEW_TESTS = { state: 'superpowers.codeReader.reviewHideTests', context: 'superpowers.codeReader.reviewTestsHidden' }
|
||||
|
||||
/**
|
||||
* 怎么进的审查,决定不能审查、出错时怎么收场:
|
||||
@@ -49,34 +55,43 @@ function errorText(err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
|
||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码,预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。选的工单按工作区记住。
|
||||
* 和别的筛选(未提交的更改)互斥,见 TreeFilters。
|
||||
* 主栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后主栏只剩这张工单的 PR 改过的文件(按改动着色),
|
||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码,工作副本里的文件预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。
|
||||
* 选的工单按工作区记住,主栏第一次露出来时接着审查;隐藏测试文件的开关是审查自己的,也按工作区记住。
|
||||
*/
|
||||
export class ReviewMode implements TreeFilter, Disposable {
|
||||
export class ReviewMode implements DiffBaseSource, Disposable {
|
||||
private readonly decorations = new ChangeDecorations()
|
||||
private readonly baseChange = new EventEmitter<void>()
|
||||
/** 审查的对比基准变了(进了审查、重读了 PR、换了工单、退出了):打开着的预览重新问 */
|
||||
readonly onDidChange: Event<void> = this.baseChange.event
|
||||
private readonly tests: TestsToggle
|
||||
private readonly disposables: Disposable[]
|
||||
/** 正在审查的:工单、PR、改过的文件、工作副本、对比基准 */
|
||||
/** 正在审查的(主栏摆着它):工单、PR、改过的文件、工作副本、对比基准 */
|
||||
private loaded: Loaded | undefined
|
||||
/** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */
|
||||
private generation = 0
|
||||
|
||||
/** viewId 是侧栏视图的 id:进审查时在这个视图上转进度。 */
|
||||
/** viewId、view、files 是主栏的视图 id、视图、文件树:进审查时在主栏上转进度、换上 PR 改过的文件。 */
|
||||
constructor(
|
||||
private readonly context: ExtensionContext,
|
||||
private readonly viewId: string,
|
||||
private readonly view: TreeView<TreeNode>,
|
||||
private readonly files: FileTreeProvider,
|
||||
private readonly filters: TreeFilters,
|
||||
) {
|
||||
filters.add(this)
|
||||
this.tests = new TestsToggle(context.workspaceState, REVIEW_TESTS, () => this.place())
|
||||
this.disposables = [
|
||||
this.decorations,
|
||||
this.baseChange,
|
||||
window.registerFileDecorationProvider(this.decorations),
|
||||
filters.whenShown(() => void this.restore()),
|
||||
whenShown(view, () => void this.restore()),
|
||||
]
|
||||
}
|
||||
|
||||
/** 正审查着工单:主栏只摆着 PR 改过的文件 */
|
||||
get reviewing(): boolean {
|
||||
return this.loaded !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」「进行中」列里能审查的工单(按列分组),马上就有;
|
||||
* 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。
|
||||
@@ -152,13 +167,20 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
: []))
|
||||
}
|
||||
|
||||
/** 「显示全部」或换成别的筛选:不再审查、不再记着这张工单,正在进的审查作废;正审查着的回到工作区文件树。 */
|
||||
/** 「显示全部」:不再审查、不再记着这张工单,正在进的审查作废;正审查着的主栏回到工作区文件树,清掉标题后面的字和标题下面的一行。 */
|
||||
async exit(): Promise<void> {
|
||||
this.generation++
|
||||
const shown = this.loaded !== undefined
|
||||
this.loaded = undefined
|
||||
this.decorations.show([])
|
||||
if (shown) {
|
||||
this.baseChange.fire()
|
||||
this.files.show(WORKSPACE_TREE)
|
||||
this.view.description = undefined
|
||||
this.view.message = undefined
|
||||
}
|
||||
await Promise.all([
|
||||
this.filters.leave(this),
|
||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, false),
|
||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined),
|
||||
])
|
||||
}
|
||||
@@ -188,9 +210,9 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** 隐藏测试文件的开关换了:正审查着的文件按新开关重摆。 */
|
||||
testsToggled(): void {
|
||||
this.place()
|
||||
/** 审查时标题栏的「隐藏 / 显示测试文件」:正审查着的文件按新开关重摆。 */
|
||||
setTestsHidden(hidden: boolean): Promise<void> {
|
||||
return this.tests.set(hidden)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -277,13 +299,13 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
|
||||
private async show(loaded: Loaded): Promise<void> {
|
||||
const root = Uri.file(loaded.worktree)
|
||||
// 先记下再进:进的时候打开着的预览就来问对比基准
|
||||
// 先记下再通知:打开着的预览马上来问对比基准
|
||||
this.loaded = loaded
|
||||
const entering = this.filters.enter(this, 'review')
|
||||
this.baseChange.fire()
|
||||
this.place()
|
||||
this.decorations.show(loaded.files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) })))
|
||||
await Promise.all([
|
||||
entering,
|
||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, true),
|
||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, loaded.issue.number),
|
||||
])
|
||||
}
|
||||
@@ -296,8 +318,8 @@ export class ReviewMode implements TreeFilter, Disposable {
|
||||
const { issue, pr, files, worktree } = loaded
|
||||
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
|
||||
const folder: WorkspaceFolder = { uri: Uri.file(worktree), name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
|
||||
const { tree, hidden } = reviewTree(files, this.filters.testsHidden)
|
||||
this.files.showChanges([{ roots: [{ folder, tree }] }])
|
||||
const { tree, hidden } = reviewTree(files, this.tests.hidden)
|
||||
this.files.show({ kind: 'changes', view: [{ roots: [{ folder, tree }] }] })
|
||||
this.view.description = reviewDescription(issue.number, hidden)
|
||||
this.view.message = reviewMessage(issue, pr, files.length)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { GitApi, GitRepository } from '../../git/gitApi'
|
||||
import { Disposable, workspace } from 'vscode'
|
||||
|
||||
/** git 状态连着变(保存、暂存、切分支)时合成一次。 */
|
||||
const SETTLE_MS = 300
|
||||
|
||||
/**
|
||||
* 盯着 git:各仓库的状态变了(改动、HEAD 动了)、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,连着变的合成一次调 changed。
|
||||
* @returns 不再盯了时 dispose
|
||||
*/
|
||||
export function watchGit(api: GitApi, changed: () => void): Disposable {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const schedule = (): void => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(changed, SETTLE_MS)
|
||||
}
|
||||
const repositories = new Map<GitRepository, Disposable>()
|
||||
const add = (repository: GitRepository): void => {
|
||||
if (!repositories.has(repository))
|
||||
repositories.set(repository, repository.state.onDidChange(schedule))
|
||||
}
|
||||
api.repositories.forEach(add)
|
||||
return Disposable.from(
|
||||
api.onDidOpenRepository((repository) => {
|
||||
add(repository)
|
||||
schedule()
|
||||
}),
|
||||
api.onDidCloseRepository((repository) => {
|
||||
repositories.get(repository)?.dispose()
|
||||
repositories.delete(repository)
|
||||
schedule()
|
||||
}),
|
||||
api.onDidChangeState(schedule),
|
||||
workspace.onDidChangeWorkspaceFolders(schedule),
|
||||
{
|
||||
dispose: () => {
|
||||
clearTimeout(timer)
|
||||
repositories.forEach(listener => listener.dispose())
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Disposable, Event, Uri } from 'vscode'
|
||||
import type { GitApi } from '../../git/gitApi'
|
||||
import type { DiffBase, DiffBaseSource } from '../diff/diffBase'
|
||||
import { EventEmitter, workspace } from 'vscode'
|
||||
import { fileAtRef } from '../../git/fileAt'
|
||||
import { loadGitApi } from '../../git/gitExtension'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { watchGit } from './gitWatch'
|
||||
import { headDiffBase } from './uncommittedFiles'
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作区仓库里有没提交改动的文件,预览对比上一次提交(见 headDiffBase),不管是从哪一栏、点链接还是切标签打开的。
|
||||
* 第一次有预览来问时才去读 git,读到了通知预览重新问;之后盯着 git 状态,改动、HEAD 变了都通知打开着的预览重新比。
|
||||
*/
|
||||
export class HeadBases implements DiffBaseSource, Disposable {
|
||||
private readonly changed = new EventEmitter<void>()
|
||||
readonly onDidChange: Event<void> = this.changed.event
|
||||
private api: GitApi | undefined
|
||||
/** 已经去读 git 了:读没读到都不再读 */
|
||||
private loading = false
|
||||
private watching: Disposable | undefined
|
||||
|
||||
diffBase(uri: Uri): DiffBase | undefined {
|
||||
const api = this.api
|
||||
if (!api) {
|
||||
void this.load()
|
||||
return undefined
|
||||
}
|
||||
const repository = api.getRepository(uri)
|
||||
if (!repository)
|
||||
return undefined
|
||||
return headDiffBase(repository, uri.path, workspace.workspaceFolders ?? [], (head, relative) => fileAtRef(repository.rootUri.fsPath, head, relative))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.watching?.dispose()
|
||||
this.changed.dispose()
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.loading)
|
||||
return
|
||||
this.loading = true
|
||||
let api: GitApi | undefined
|
||||
try {
|
||||
api = await loadGitApi()
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: '对比上一次提交:激活 vscode.git 扩展失败,预览不标差异', details: errorText(err) })
|
||||
return
|
||||
}
|
||||
if (!api) {
|
||||
logger.add({ level: 'info', source: 'codeReader', message: '对比上一次提交:vscode.git 扩展没装或被禁用了,预览不标差异' })
|
||||
return
|
||||
}
|
||||
this.api = api
|
||||
this.watching = watchGit(api, () => this.changed.fire())
|
||||
this.changed.fire()
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function isTestFile(path: string): boolean {
|
||||
return segments.some(dir => TEST_DIRS.has(dir.toLowerCase())) || TEST_FILE_NAMES.some(pattern => pattern.test(name))
|
||||
}
|
||||
|
||||
/** 视图标题后面淡色的字(TreeView.description):藏了测试文件时在后面注明藏了几个。「未提交的更改」和审查用同一个说法。 */
|
||||
/** 视图标题后面淡色的字(TreeView.description):藏了测试文件时在后面注明藏了几个(审查时用)。 */
|
||||
export function withHiddenTests(description: string, hiddenTests: number): string {
|
||||
return hiddenTests > 0 ? `${description} · 已隐藏 ${hiddenTests} 个测试文件` : description
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { GitStatus } from '../../git/gitApi'
|
||||
import type { ChangeGroups } from './uncommittedFiles'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { GIT_STATUS } from '../../git/gitApi'
|
||||
import { changeOfGitStatus, headMissingText, isUntracked, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
import { changeOfGitStatus, headDiffBase, headMissingText, isUntracked, uncommittedDescription, uncommittedFiles, uncommittedMessage, uncommittedRoots, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
|
||||
const at = (path: string, status: GitStatus): { uri: { path: string }, status: GitStatus } => ({ uri: { path }, status })
|
||||
|
||||
@@ -176,9 +176,9 @@ describe('隐藏测试文件', () => {
|
||||
})
|
||||
|
||||
describe('标题后面的字、标题下面的一行', () => {
|
||||
it('藏了测试文件时标题后面注明藏了几个', () => {
|
||||
expect(uncommittedDescription(0)).toBe('未提交的更改')
|
||||
expect(uncommittedDescription(3)).toBe('未提交的更改 · 已隐藏 3 个测试文件')
|
||||
it('藏了测试文件时标题后面注明藏了几个,没藏的不写(标题本身就是「未提交的更改」)', () => {
|
||||
expect(uncommittedDescription(0)).toBeUndefined()
|
||||
expect(uncommittedDescription(3)).toBe('已隐藏 3 个测试文件')
|
||||
})
|
||||
|
||||
it('树上有文件时没有提示;只剩藏起来的测试文件、什么都没有、git 还在找仓库时各说一句', () => {
|
||||
@@ -203,3 +203,53 @@ describe('对比上一次提交', () => {
|
||||
expect(headMissingText({ hasCommit: false, untracked: true })).toBe('仓库里还没有提交')
|
||||
})
|
||||
})
|
||||
|
||||
describe('工作区仓库里的文件按位置对比上一次提交', () => {
|
||||
const folders = [{ uri: { path: '/ws' }, name: 'ws' }]
|
||||
const repository = (root: string, parts: Partial<ChangeGroups>, HEAD: { commit?: string } = { commit: 'abc123' }) =>
|
||||
({ rootUri: { path: root }, state: { ...groups(parts), HEAD } })
|
||||
|
||||
it('有没提交的改动(暂存的、没暂存的、未跟踪的、冲突的)才比:基准是上一次提交里的同一个文件', async () => {
|
||||
for (const parts of [
|
||||
{ indexChanges: [at('/ws/src/a.ts', GIT_STATUS.INDEX_MODIFIED)] },
|
||||
{ workingTreeChanges: [at('/ws/src/a.ts', GIT_STATUS.MODIFIED)] },
|
||||
{ untrackedChanges: [at('/ws/src/a.ts', GIT_STATUS.UNTRACKED)] },
|
||||
{ mergeChanges: [at('/ws/src/a.ts', GIT_STATUS.BOTH_MODIFIED)] },
|
||||
]) {
|
||||
const read = vi.fn(async () => 'old')
|
||||
const base = headDiffBase(repository('/ws', parts), '/ws/src/a.ts', folders, read)
|
||||
|
||||
expect(base).toMatchObject({ label: '上一次提交', key: 'abc123:src/a.ts' })
|
||||
expect(await base?.read()).toBe('old')
|
||||
expect(read).toHaveBeenCalledWith('abc123', 'src/a.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('没改动的文件不比(预览不标差异)', () => {
|
||||
const repo = repository('/ws', { workingTreeChanges: [at('/ws/src/b.ts', GIT_STATUS.MODIFIED)] })
|
||||
expect(headDiffBase(repo, '/ws/src/a.ts', folders, vi.fn())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('不算工作区里的仓库(审查的工作副本、别处的仓库)里的不比,改了也不比', () => {
|
||||
const worktree = repository('/worktrees/issue-446', { workingTreeChanges: [at('/worktrees/issue-446/a.ts', GIT_STATUS.MODIFIED)] })
|
||||
expect(headDiffBase(worktree, '/worktrees/issue-446/a.ts', folders, vi.fn())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('嵌套在工作区里的仓库、包着工作区的仓库都算,路径相对仓库根', () => {
|
||||
const nested = repository('/ws/packages/lib', { workingTreeChanges: [at('/ws/packages/lib/x.ts', GIT_STATUS.MODIFIED)] })
|
||||
expect(headDiffBase(nested, '/ws/packages/lib/x.ts', folders, vi.fn())?.key).toBe('abc123:x.ts')
|
||||
const outer = repository('/repo', { workingTreeChanges: [at('/repo/app/x.ts', GIT_STATUS.MODIFIED)] })
|
||||
expect(headDiffBase(outer, '/repo/app/x.ts', [{ uri: { path: '/repo/app' }, name: 'app' }], vi.fn())?.key).toBe('abc123:app/x.ts')
|
||||
})
|
||||
|
||||
it('新文件:上一次提交里没有,横幅说还没被跟踪;仓库还没有提交时不去读', async () => {
|
||||
const untracked = repository('/ws', { untrackedChanges: [at('/ws/new.ts', GIT_STATUS.UNTRACKED)] })
|
||||
expect(headDiffBase(untracked, '/ws/new.ts', folders, vi.fn())?.missing).toBe('还没被 git 跟踪,上一次提交里没有这个文件')
|
||||
|
||||
const read = vi.fn()
|
||||
const empty = headDiffBase(repository('/ws', { indexChanges: [at('/ws/new.ts', GIT_STATUS.INDEX_ADDED)] }, {}), '/ws/new.ts', folders, read)
|
||||
expect(empty?.missing).toBe('仓库里还没有提交')
|
||||
expect(await empty?.read()).toBeNull()
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { GitStatus } from '../../git/gitApi'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { ChangedFile, FileChange } from '../explorer/changedTree'
|
||||
import { posix } from 'node:path'
|
||||
import { GIT_STATUS } from '../../git/gitApi'
|
||||
import { toPathKey } from '../pathMap'
|
||||
import { isTestFile, withHiddenTests } from './testFiles'
|
||||
import { isTestFile } from './testFiles'
|
||||
|
||||
/** 一组改动里的一项:文件在哪(Uri.path)、git 状态。 */
|
||||
interface StatusEntry {
|
||||
@@ -147,9 +148,9 @@ export function withoutTestFiles<T extends UncommittedFiles>(roots: readonly T[]
|
||||
/** 「更改」区里文件行的 contextValue 带的标记(见 entryContext):这些行上挂「暂存」按钮。 */
|
||||
export const UNSTAGED_MARKER = 'unstaged'
|
||||
|
||||
/** 开着时视图标题后面淡色的字(TreeView.description):藏了测试文件时注明藏了几个。 */
|
||||
export function uncommittedDescription(hiddenTests: number): string {
|
||||
return withHiddenTests('未提交的更改', hiddenTests)
|
||||
/** 这一栏标题后面淡色的字(TreeView.description):藏了测试文件时注明藏了几个,没藏的不写(标题本身就是「未提交的更改」)。 */
|
||||
export function uncommittedDescription(hiddenTests: number): string | undefined {
|
||||
return hiddenTests > 0 ? `已隐藏 ${hiddenTests} 个测试文件` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +164,7 @@ export function uncommittedMessage(state: { hasFiles: boolean, hiddenTests: numb
|
||||
return state.loading ? '正在读取 git 状态…' : '没有未提交的更改'
|
||||
}
|
||||
|
||||
/** 「未提交的更改」开着时预览对比的基准:上一次提交(HEAD)。 */
|
||||
/** 工作区仓库里有改动的文件,预览对比的基准:上一次提交(HEAD)。 */
|
||||
export const HEAD_BASE_LABEL = '上一次提交'
|
||||
|
||||
/** 文件是不是未跟踪的:设置 git.untrackedChanges 是 mixed(默认)时未跟踪的混在没暂存的那组里,按状态认。 */
|
||||
@@ -177,3 +178,37 @@ export function headMissingText(state: { hasCommit: boolean, untracked: boolean
|
||||
return '仓库里还没有提交'
|
||||
return state.untracked ? '还没被 git 跟踪,上一次提交里没有这个文件' : '上一次提交里没有这个文件'
|
||||
}
|
||||
|
||||
/** 仓库里用到的,再加上当前检出的提交。 */
|
||||
interface HeadRepositoryLike extends RepositoryLike {
|
||||
readonly state: ChangeGroups & { readonly HEAD: { readonly commit?: string } | undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作区仓库里的文件对比上一次提交(HEAD)时的基准。仓库算工作区里的(见 workspaceOrder)、
|
||||
* 这个文件有没提交的改动(暂存的、没暂存的、未跟踪的、冲突的)才比;没改动的、不算工作区里的仓库(审查的工作副本、别处的仓库)里的没有。
|
||||
* @param repository 文件所在的仓库
|
||||
* @param filePath 文件在哪(Uri.path)
|
||||
* @param folders 工作区文件夹
|
||||
* @param read 取 head 这个提交上 relative(相对仓库根、`/` 分隔)这个文件的内容,那个提交里没有时是 null
|
||||
*/
|
||||
export function headDiffBase(
|
||||
repository: HeadRepositoryLike,
|
||||
filePath: string,
|
||||
folders: readonly FolderLike[],
|
||||
read: (head: string, relative: string) => Promise<string | null>,
|
||||
): DiffBase | undefined {
|
||||
const { rootUri, state } = repository
|
||||
const relative = toPathKey(rootUri.path, filePath)
|
||||
const changed = [state.mergeChanges, state.indexChanges, state.workingTreeChanges, state.untrackedChanges]
|
||||
.some(group => group.some(entry => entry.uri.path === filePath))
|
||||
if (relative === undefined || !changed || workspaceOrder(rootUri.path, folders) < 0)
|
||||
return undefined
|
||||
const head = state.HEAD?.commit
|
||||
return {
|
||||
label: HEAD_BASE_LABEL,
|
||||
key: `${head ?? ''}:${relative}`,
|
||||
read: () => head ? read(head, relative) : Promise.resolve(null),
|
||||
missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(state, filePath) }),
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import type { ExtensionContext, TreeView, Uri, WorkspaceFolder } from 'vscode'
|
||||
import type { GitApi, GitRepository } from '../../git/gitApi'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { ChangedFile } from '../explorer/changedTree'
|
||||
import type { ChangedSection, FileNode, FileTreeProvider, TreeNode } from '../explorer/fileTree'
|
||||
import type { TreeFilter, TreeFilters } from '../explorer/treeFilters'
|
||||
import type { UncommittedRoot } from './uncommittedFiles'
|
||||
import * as path from 'node:path'
|
||||
import { Disposable, window, workspace } from 'vscode'
|
||||
import { fileAtRef } from '../../git/fileAt'
|
||||
import { loadGitApi } from '../../git/gitExtension'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { buildChangedTree } from '../explorer/changedTree'
|
||||
import { toPathKey } from '../pathMap'
|
||||
import { HEAD_BASE_LABEL, headMissingText, isUntracked, uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
|
||||
/** workspaceState:开着「未提交的更改」,重开窗口接着显示。 */
|
||||
const UNCOMMITTED_KEY = 'superpowers.codeReader.uncommitted'
|
||||
/** 两个分组的名字,照 VS Code 源代码管理视图的中文界面。 */
|
||||
const STAGED_LABEL = '暂存的更改'
|
||||
const CHANGES_LABEL = '更改'
|
||||
/** git 状态连着变(保存、暂存、切分支)时合成一次重摆。 */
|
||||
const REBUILD_DELAY_MS = 300
|
||||
|
||||
/** 怎么进的:enter 点按钮(出错时说一声)、restore 重开窗口接着显示(出错只记日志)。 */
|
||||
type How = 'enter' | 'restore'
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏只看未提交的更改:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。
|
||||
* 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。
|
||||
* 可以隐藏测试文件(和审查共用一个开关,见 TreeFilters.testsHidden)。开着时盯着 git 状态自动重摆,打开的预览对比上一次提交(diffBase);
|
||||
* 「显示全部」回到工作区文件树。开着没开按工作区记住;和审查工单互斥,见 TreeFilters。
|
||||
*/
|
||||
export class UncommittedMode implements TreeFilter, Disposable {
|
||||
private readonly disposables: Disposable[]
|
||||
/** 开着时的 git API;没开时 undefined */
|
||||
private api: GitApi | undefined
|
||||
/** 开着时盯 git 状态的监听 */
|
||||
private watching: Disposable | undefined
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
/** 树上现在摆的改动(序列化),没变就不重画 */
|
||||
private shown: string | undefined
|
||||
/** 每进一次、退一次都加一:等 git 扩展回来时已经退出或又进了一次的,结果丢掉。 */
|
||||
private generation = 0
|
||||
|
||||
/** viewId 是侧栏视图的 id:刷新时在这个视图上转进度。 */
|
||||
constructor(
|
||||
private readonly context: ExtensionContext,
|
||||
private readonly viewId: string,
|
||||
private readonly view: TreeView<TreeNode>,
|
||||
private readonly files: FileTreeProvider,
|
||||
private readonly filters: TreeFilters,
|
||||
) {
|
||||
filters.add(this)
|
||||
this.disposables = [filters.whenShown(() => void this.restore())]
|
||||
}
|
||||
|
||||
/** 「未提交的更改」:别的筛选退出,树里换成未提交的文件,之后跟着 git 状态变。 */
|
||||
async enter(): Promise<void> {
|
||||
await this.open('enter')
|
||||
}
|
||||
|
||||
/** 「显示全部」或换成别的筛选:不再盯 git、不再记着,正在进的作废;开着的回到工作区文件树。 */
|
||||
async exit(): Promise<void> {
|
||||
this.generation++
|
||||
this.stopWatching()
|
||||
await Promise.all([
|
||||
this.filters.leave(this),
|
||||
this.context.workspaceState.update(UNCOMMITTED_KEY, undefined),
|
||||
])
|
||||
}
|
||||
|
||||
/** 开着时的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆。 */
|
||||
async reload(): Promise<void> {
|
||||
const api = this.api
|
||||
if (!api)
|
||||
return
|
||||
const folders = workspace.workspaceFolders ?? []
|
||||
const repositories = api.repositories.filter(repository => workspaceOrder(repository.rootUri.path, folders) >= 0)
|
||||
await window.withProgress({ location: { viewId: this.viewId } }, () => Promise.all(repositories.map(repository => this.status(repository))))
|
||||
if (this.api !== api)
|
||||
return
|
||||
this.shown = undefined
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/** 隐藏测试文件的开关换了:两组都剔除 / 放回测试文件。 */
|
||||
testsToggled(): void {
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/** 「更改」组里文件行上的「暂存」:把这个文件放进暂存区(新文件、删掉的、冲突的都行),树跟着 git 状态变。 */
|
||||
async stage(node: FileNode | undefined): Promise<void> {
|
||||
const api = this.api
|
||||
if (!node || !api)
|
||||
return // 只挂在开着时「更改」组的文件行上
|
||||
const name = path.basename(node.uri.fsPath)
|
||||
const repository = api.getRepository(node.uri)
|
||||
if (!repository) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${name} 不在打开的 git 仓库里,没法暂存`, details: node.uri.fsPath })
|
||||
void window.showErrorMessage(`没法暂存 ${name}:找不到它所在的 git 仓库`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await repository.add([node.uri.fsPath])
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'error', source: 'codeReader', message: `未提交的更改:暂存 ${name} 失败`, details: `${node.uri.fsPath}\n${errorText(err)}` })
|
||||
void window.showErrorMessage(`没能暂存 ${name}:${errorText(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 开着时,工作区仓库里的文件对比上一次提交(HEAD);不在工作区的仓库里(审查的工作副本、别处的文件)的不比。 */
|
||||
diffBase(uri: Uri): DiffBase | undefined {
|
||||
const repository = this.api?.getRepository(uri)
|
||||
if (!repository || workspaceOrder(repository.rootUri.path, workspace.workspaceFolders ?? []) < 0)
|
||||
return undefined
|
||||
const relative = toPathKey(repository.rootUri.path, uri.path)
|
||||
if (relative === undefined)
|
||||
return undefined
|
||||
const head = repository.state.HEAD?.commit
|
||||
const root = repository.rootUri.fsPath
|
||||
return {
|
||||
label: HEAD_BASE_LABEL,
|
||||
key: `${head ?? ''}:${relative}`,
|
||||
read: () => head ? fileAtRef(root, head, relative) : Promise.resolve(null),
|
||||
missing: headMissingText({ hasCommit: head !== undefined, untracked: isUntracked(repository.state, uri.path) }),
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopWatching()
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private async restore(): Promise<void> {
|
||||
if (this.context.workspaceState.get<boolean>(UNCOMMITTED_KEY))
|
||||
await this.open('restore')
|
||||
}
|
||||
|
||||
private async open(how: How): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
let api: GitApi | undefined
|
||||
try {
|
||||
api = await loadGitApi()
|
||||
}
|
||||
catch (err) {
|
||||
this.fail(how, '激活 vscode.git 扩展失败', errorText(err))
|
||||
return
|
||||
}
|
||||
if (generation !== this.generation)
|
||||
return
|
||||
if (!api) {
|
||||
this.fail(how, 'vscode.git 扩展没装或被禁用了')
|
||||
return
|
||||
}
|
||||
this.stopWatching()
|
||||
this.api = api
|
||||
this.watching = this.watch(api)
|
||||
const entering = this.filters.enter(this, 'uncommitted')
|
||||
this.rebuild()
|
||||
await Promise.all([entering, this.context.workspaceState.update(UNCOMMITTED_KEY, true)])
|
||||
}
|
||||
|
||||
private fail(how: How, message: string, details?: string): void {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${message}`, ...(details ? { details } : {}) })
|
||||
if (how === 'enter')
|
||||
void window.showErrorMessage(`没法显示未提交的更改:${message}`)
|
||||
}
|
||||
|
||||
private async status(repository: GitRepository): Promise<void> {
|
||||
try {
|
||||
await repository.status()
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: '未提交的更改:重读 git 状态失败', details: `${repository.rootUri.fsPath}\n${errorText(err)}` })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 盯着 git:各仓库的状态变了、开了 / 关了仓库、git 扩展找完仓库、工作区文件夹变了,都合成一次重摆;
|
||||
* 打开着的预览也跟着重新比(提交后 HEAD 动了,对比的上一次提交就换了)。
|
||||
*/
|
||||
private watch(api: GitApi): Disposable {
|
||||
const repositories = new Map<GitRepository, Disposable>()
|
||||
const schedule = (): void => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.rebuild()
|
||||
this.filters.baseChanged(this)
|
||||
}, REBUILD_DELAY_MS)
|
||||
}
|
||||
const add = (repository: GitRepository): void => {
|
||||
if (!repositories.has(repository))
|
||||
repositories.set(repository, repository.state.onDidChange(schedule))
|
||||
}
|
||||
api.repositories.forEach(add)
|
||||
return Disposable.from(
|
||||
api.onDidOpenRepository((repository) => {
|
||||
add(repository)
|
||||
schedule()
|
||||
}),
|
||||
api.onDidCloseRepository((repository) => {
|
||||
repositories.get(repository)?.dispose()
|
||||
repositories.delete(repository)
|
||||
schedule()
|
||||
}),
|
||||
api.onDidChangeState(schedule),
|
||||
workspace.onDidChangeWorkspaceFolders(schedule),
|
||||
{ dispose: () => repositories.forEach(listener => listener.dispose()) },
|
||||
)
|
||||
}
|
||||
|
||||
private stopWatching(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.watching?.dispose()
|
||||
this.watching = undefined
|
||||
this.api = undefined
|
||||
this.shown = undefined
|
||||
}
|
||||
|
||||
/** 按 git 现在的状态(和隐不隐藏测试文件)重摆树、标题后面的字、标题下面的一行;树上摆的没变就不重画。 */
|
||||
private rebuild(): void {
|
||||
const api = this.api
|
||||
if (!api)
|
||||
return
|
||||
const folders = workspace.workspaceFolders ?? []
|
||||
const all = uncommittedRoots(api.repositories, folders)
|
||||
const { roots, hidden } = this.filters.testsHidden ? withoutTestFiles(all) : { roots: all, hidden: 0 }
|
||||
this.view.description = uncommittedDescription(hidden)
|
||||
this.view.message = uncommittedMessage({ hasFiles: roots.length > 0, hiddenTests: hidden, loading: api.state !== 'initialized' })
|
||||
const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.staged, root.changes]))
|
||||
if (shown === this.shown)
|
||||
return
|
||||
this.shown = shown
|
||||
// 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字
|
||||
const placed = roots.map(root => ({ root, folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length } }))
|
||||
this.files.showChanges([
|
||||
section(STAGED_LABEL, placed, root => root.staged),
|
||||
section(CHANGES_LABEL, placed, root => root.changes, UNSTAGED_MARKER),
|
||||
].filter(group => group.roots.length > 0))
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个分组:有这个区的文件的仓库各一个根,分组行后面写这个区一共几个文件。 */
|
||||
function section(
|
||||
label: string,
|
||||
placed: readonly { root: UncommittedRoot<GitRepository, WorkspaceFolder>, folder: WorkspaceFolder }[],
|
||||
filesOf: (root: UncommittedRoot<GitRepository, WorkspaceFolder>) => ChangedFile[],
|
||||
fileMarker?: string,
|
||||
): ChangedSection {
|
||||
const withFiles = placed.filter(({ root }) => filesOf(root).length > 0)
|
||||
const count = withFiles.reduce((sum, { root }) => sum + filesOf(root).length, 0)
|
||||
return {
|
||||
label,
|
||||
description: String(count),
|
||||
...(fileMarker ? { fileMarker } : {}),
|
||||
roots: withFiles.map(({ root, folder }) => ({ folder, tree: buildChangedTree(filesOf(root)) })),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Disposable, ExtensionContext, TreeView, WorkspaceFolder } from 'vscode'
|
||||
import type { GitApi, GitRepository } from '../../git/gitApi'
|
||||
import type { ChangedFile } from '../explorer/changedTree'
|
||||
import type { ChangedSection, FileNode, FileTreeDecorator, TreeNode } from '../explorer/fileTree'
|
||||
import type { UncommittedRoot } from './uncommittedFiles'
|
||||
import * as path from 'node:path'
|
||||
import { window, workspace } from 'vscode'
|
||||
import { loadGitApi } from '../../git/gitExtension'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { buildChangedTree } from '../explorer/changedTree'
|
||||
import { FileTreeProvider } from '../explorer/fileTree'
|
||||
import { TestsToggle } from '../explorer/testsToggle'
|
||||
import { whenShown } from '../explorer/viewShown'
|
||||
import { watchGit } from './gitWatch'
|
||||
import { uncommittedDescription, uncommittedMessage, uncommittedRoots, UNSTAGED_MARKER, withoutTestFiles, workspaceOrder } from './uncommittedFiles'
|
||||
|
||||
/** 「未提交的更改」这一栏的视图 id */
|
||||
const UNCOMMITTED_VIEW = 'superpowers.codeReader.uncommitted'
|
||||
/** 这一栏隐藏测试文件的开关:记在 workspaceState 的键、标题栏按它换按钮的上下文键 */
|
||||
const UNCOMMITTED_TESTS = { state: 'superpowers.codeReader.uncommittedHideTests', context: 'superpowers.codeReader.uncommittedTestsHidden' }
|
||||
/** 两个分组的名字,照 VS Code 源代码管理视图的中文界面。 */
|
||||
const STAGED_LABEL = '暂存的更改'
|
||||
const CHANGES_LABEL = '更改'
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏「未提交的更改」一栏:照源代码管理视图分「暂存的更改」「更改」两组(空的不摆),每组下面每个有改动的仓库一个根。
|
||||
* 文件就是工作区里的文件,颜色和 M / A / D 徽标由 VS Code 自带的 git 装饰加上;「更改」组里的文件行上有「暂存」按钮。
|
||||
* 这一栏第一次露出来时才去读 git,之后盯着 git 状态自动重摆;隐藏测试文件的开关是这一栏自己的,按工作区记住。
|
||||
*/
|
||||
export class UncommittedView implements Disposable {
|
||||
private readonly files: FileTreeProvider
|
||||
private readonly view: TreeView<TreeNode>
|
||||
private readonly tests: TestsToggle
|
||||
private readonly disposables: Disposable[]
|
||||
/** 读到的 git API;还没读到时 undefined */
|
||||
private api: GitApi | undefined
|
||||
/** 树上现在摆的改动(序列化),没变就不重画 */
|
||||
private shown: string | undefined
|
||||
|
||||
/**
|
||||
* @param context 隐藏测试文件的开关记在它的 workspaceState 里
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
* @param decorator 给行添信息(文件注释),和别的栏共用
|
||||
*/
|
||||
constructor(context: ExtensionContext, openCommand: string, decorator: FileTreeDecorator) {
|
||||
this.files = new FileTreeProvider(openCommand, 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())]
|
||||
}
|
||||
|
||||
/** 标题栏的「隐藏 / 显示测试文件」 */
|
||||
setTestsHidden(hidden: boolean): Promise<void> {
|
||||
return this.tests.set(hidden)
|
||||
}
|
||||
|
||||
/** 标题栏的「刷新」:让 git 扩展重读工作区各仓库的状态,再整棵重摆;之前没读到 git 的再去读一次。 */
|
||||
async reload(): Promise<void> {
|
||||
const api = this.api
|
||||
if (!api) {
|
||||
await this.open()
|
||||
return
|
||||
}
|
||||
const folders = workspace.workspaceFolders ?? []
|
||||
const repositories = api.repositories.filter(repository => workspaceOrder(repository.rootUri.path, folders) >= 0)
|
||||
await window.withProgress({ location: { viewId: UNCOMMITTED_VIEW } }, () => Promise.all(repositories.map(repository => this.status(repository))))
|
||||
this.shown = undefined
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/** 「更改」组里文件行上的「暂存」:把这个文件放进暂存区(新文件、删掉的、冲突的都行),树跟着 git 状态变。 */
|
||||
async stage(node: FileNode | undefined): Promise<void> {
|
||||
const api = this.api
|
||||
if (!node || !api)
|
||||
return // 只挂在「更改」组的文件行上,摆得出这些行就已经读到了 git
|
||||
const name = path.basename(node.uri.fsPath)
|
||||
const repository = api.getRepository(node.uri)
|
||||
if (!repository) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${name} 不在打开的 git 仓库里,没法暂存`, details: node.uri.fsPath })
|
||||
void window.showErrorMessage(`没法暂存 ${name}:找不到它所在的 git 仓库`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await repository.add([node.uri.fsPath])
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'error', source: 'codeReader', message: `未提交的更改:暂存 ${name} 失败`, details: `${node.uri.fsPath}\n${errorText(err)}` })
|
||||
void window.showErrorMessage(`没能暂存 ${name}:${errorText(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
/** 读 git API、盯着 git 状态、摆上树;读不到时这一栏写原因(点刷新再读)。 */
|
||||
private async open(): Promise<void> {
|
||||
this.view.message = uncommittedMessage({ hasFiles: false, hiddenTests: 0, loading: true })
|
||||
let api: GitApi | undefined
|
||||
try {
|
||||
api = await loadGitApi()
|
||||
}
|
||||
catch (err) {
|
||||
this.fail('激活 vscode.git 扩展失败', errorText(err))
|
||||
return
|
||||
}
|
||||
if (!api) {
|
||||
this.fail('vscode.git 扩展没装或被禁用了')
|
||||
return
|
||||
}
|
||||
if (this.api)
|
||||
return // 第一次露出来和刷新同时在读,先回来的已经接上了
|
||||
this.api = api
|
||||
this.disposables.push(watchGit(api, () => this.rebuild()))
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private fail(message: string, details?: string): void {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `未提交的更改:${message}`, ...(details ? { details } : {}) })
|
||||
this.view.message = `没法读取未提交的更改:${message}`
|
||||
}
|
||||
|
||||
private async status(repository: GitRepository): Promise<void> {
|
||||
try {
|
||||
await repository.status()
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: '未提交的更改:重读 git 状态失败', details: `${repository.rootUri.fsPath}\n${errorText(err)}` })
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 git 现在的状态(和隐不隐藏测试文件)重摆树、标题后面的字、标题下面的一行;树上摆的没变就不重画。 */
|
||||
private rebuild(): void {
|
||||
const api = this.api
|
||||
if (!api)
|
||||
return
|
||||
const folders = workspace.workspaceFolders ?? []
|
||||
const all = uncommittedRoots(api.repositories, folders)
|
||||
const { roots, hidden } = this.tests.hidden ? withoutTestFiles(all) : { roots: all, hidden: 0 }
|
||||
this.view.description = uncommittedDescription(hidden)
|
||||
this.view.message = uncommittedMessage({ hasFiles: roots.length > 0, hiddenTests: hidden, loading: api.state !== 'initialized' })
|
||||
const shown = JSON.stringify(roots.map(root => [root.repository.rootUri.toString(), root.name, root.staged, root.changes]))
|
||||
if (shown === this.shown)
|
||||
return
|
||||
this.shown = shown
|
||||
// 仓库根不是工作区文件夹时(嵌套的仓库、包着工作区文件夹的仓库)拿仓库根当根,名字用仓库根目录的名字
|
||||
const placed = roots.map(root => ({ root, folder: root.folder ?? { uri: root.repository.rootUri, name: root.name, index: folders.length } }))
|
||||
this.files.show({
|
||||
kind: 'changes',
|
||||
view: [
|
||||
section(STAGED_LABEL, placed, root => root.staged),
|
||||
section(CHANGES_LABEL, placed, root => root.changes, UNSTAGED_MARKER),
|
||||
].filter(group => group.roots.length > 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个分组:有这一组文件的各仓库各一个根,分组行后面写这一组一共几个文件。 */
|
||||
function section(
|
||||
label: string,
|
||||
placed: readonly { root: UncommittedRoot<GitRepository, WorkspaceFolder>, folder: WorkspaceFolder }[],
|
||||
filesOf: (root: UncommittedRoot<GitRepository, WorkspaceFolder>) => ChangedFile[],
|
||||
fileMarker?: string,
|
||||
): ChangedSection {
|
||||
const withFiles = placed.filter(({ root }) => filesOf(root).length > 0)
|
||||
const count = withFiles.reduce((sum, { root }) => sum + filesOf(root).length, 0)
|
||||
return {
|
||||
label,
|
||||
description: String(count),
|
||||
...(fileMarker ? { fileMarker } : {}),
|
||||
roots: withFiles.map(({ root, folder }) => ({ folder, tree: buildChangedTree(filesOf(root)) })),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user