✨ 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

@@ -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
}