✨ feat(vscode): 代码阅读侧栏拆成多栏:常驻未提交的更改、收藏目录各占一栏

This commit is contained in:
cruldra committed 2026-10-12 14:29:48 +11:00
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)
})
})
+33 -2
View File
@@ -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
},
}
}
+2 -2
View File
@@ -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>)
+5 -11
View File
@@ -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()
})
})
+64 -34
View File
@@ -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 })) : []
}
+271 -124
View File
@@ -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)
}
}
+32 -16
View File
@@ -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)),
+45 -23
View File
@@ -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)) })),
}
}