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 views: Record } } const { commands, submenus, menus, views } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes /** 上下文键的值:没设的是 undefined */ type Context = Record /** * 照 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) }) })