Files
superwork/vscode/src/codeReader/explorer/rowMenus.test.ts
T

391 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { rowDecoration } from './rowDecoration'
interface MenuItem { command?: string, submenu?: string, when: string, group?: string }
interface Manifest {
contributes: {
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, 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 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)
/** 在 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 && 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',
'superpowers.codeReader.copyRelativePath',
'superpowers.codeReader.copyAbsolutePath',
]
const FOLDER_COPIES = [
'superpowers.codeReader.copyFolderName',
'superpowers.codeReader.copyRelativePath',
'superpowers.codeReader.copyAbsolutePath',
]
describe('菜单的 when 子句', () => {
it('每一条都读得懂(括号配对、正则写全)', () => {
for (const item of Object.values(menus).flat())
expect(() => evaluate(item.when, {})).not.toThrow()
})
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, 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', '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(titleOf(ADD_NOTE)?.icon).toBe('$(note)')
expect(titleOf(EDIT_NOTE)?.icon).toBe('$(edit)')
})
it('「问 AI」文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => {
for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)])
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 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('菜单上的字', () => {
expect(FILE_COPIES.map(id => titleOf(id)?.title)).toEqual(['复制文件名', '复制文件名(不带后缀)', '复制相对路径', '复制绝对路径'])
expect(titleOf('superpowers.codeReader.copyFolderName')?.title).toBe('复制文件夹名')
})
it('命令都登记了、都有注册,命令面板里藏起来', () => {
for (const id of [...new Set([...FILE_COPIES, ...FOLDER_COPIES])]) {
expect(id in COPY_COMMANDS).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())
})
})
describe('右键菜单的注释', () => {
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([])
}
})
})
describe('右键菜单的「在系统文件管理器中显示」', () => {
/** 菜单上的字跟着系统走,和 VS Code 自带资源管理器一样 */
const REVEALS = [
{ platform: 'isMac', command: 'superpowers.codeReader.revealInFinder', title: '在 Finder 中显示' },
{ platform: 'isWindows', command: 'superpowers.codeReader.revealInFileExplorer', title: '在文件资源管理器中显示' },
{ platform: 'isLinux', command: 'superpowers.codeReader.openContainingFolder', 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 自带的一样,只在右键菜单里,不挂行尾按钮,命令面板里藏起来', () => {
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.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', 'star'])
})
})
describe('主栏的审查工单', () => {
const PICK = 'superpowers.codeReader.pickReviewIssue'
const HIDE_TESTS = 'superpowers.codeReader.hideReviewTests'
const SHOW_TESTS = 'superpowers.codeReader.showReviewTests'
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
const REFRESH = 'superpowers.codeReader.refresh'
const TESTS_HIDDEN = 'superpowers.codeReader.reviewTestsHidden'
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 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('命令面板:「显示全部」、隐藏 / 显示测试文件只在审查时有', () => {
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(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)' })
})
})
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('「收藏目录」:行尾按钮和右键菜单同一项,只对目录行(不含根、文件),星标图标,命令面板里藏起来', () => {
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')
})
})
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 => /superpowers\.codeReader\.(?:filter|testsHidden)\b/.test(when))).toBe(false)
})
})