✨ feat(vscode): 代码阅读侧栏行加复制菜单,块按钮只留图标,筛选菜单加全选 / 取消全选

This commit is contained in:
cruldra committed 2026-09-26 20:39:21 +08:00
1 parent 631699a570
commit c84e21ff57
16 files changed
+555 -119

No files matched your search

@@ -0,0 +1,27 @@
import type { CopyKind } from './copyText'
import type { FileNode } from './fileTree'
import { commands, Disposable, env, window } from 'vscode'
import { COPY_COMMANDS, copyText } from './copyText'
/** 状态栏里「已复制:…」停多久 */
const STATUS_MS = 3000
/**
* 侧栏行上「复制」的几个命令:行尾「复制」按钮点开的菜单和右键菜单里都是它们(见 package.json 的 submenus)。
* 复制完在状态栏说一声复制了什么,连着复制时只留最新的一条。
*/
export function registerCopyCommands(): Disposable {
let status: Disposable | undefined
const copy = async (kind: CopyKind, node: FileNode | undefined): Promise<void> => {
if (!node)
return // 只挂在侧栏的行上,别处调用没有目标
const text = copyText(kind, { path: node.uri.path, folderPath: node.folder.uri.path, fsPath: node.uri.fsPath })
await env.clipboard.writeText(text)
status?.dispose()
status = window.setStatusBarMessage(`已复制:${text}`, STATUS_MS)
}
return Disposable.from(
...Object.entries(COPY_COMMANDS).map(([id, kind]) => commands.registerCommand(id, (node?: FileNode) => copy(kind, node))),
{ dispose: () => status?.dispose() },
)
}
@@ -0,0 +1,42 @@
import type { CopyKind, CopyTarget } from './copyText'
import { describe, expect, it } from 'vitest'
import { copyText } from './copyText'
/** 工作区文件夹 /work/app 下的一项 */
function at(relative: string): CopyTarget {
return { path: `/work/app/${relative}`, folderPath: '/work/app', fsPath: `/work/app/${relative}` }
}
const ALL: CopyKind[] = ['name', 'stem', 'relativePath', 'absolutePath']
const copies = (target: CopyTarget): string[] => ALL.map(kind => copyText(kind, target))
describe('复制出什么', () => {
it('文件:带后缀的名字、不带后缀的名字、相对工作区文件夹的路径、绝对路径', () => {
expect(copies(at('backend/campaign/order_bonus.py')))
.toEqual(['order_bonus.py', 'order_bonus', 'backend/campaign/order_bonus.py', '/work/app/backend/campaign/order_bonus.py'])
})
it('文件夹:名字和路径', () => {
const folder = at('backend/campaign')
expect(copyText('name', folder)).toBe('campaign')
expect(copyText('relativePath', folder)).toBe('backend/campaign')
expect(copyText('absolutePath', folder)).toBe('/work/app/backend/campaign')
})
it('不带后缀只去掉最后一个扩展名', () => {
expect(copyText('stem', at('src/a.test.ts'))).toBe('a.test')
expect(copyText('stem', at('archive.tar.gz'))).toBe('archive.tar')
})
it('点开头的名字:只有一个点的整个是名字,后面还有点的去掉最后一段', () => {
expect(copyText('stem', at('.env'))).toBe('.env')
expect(copyText('stem', at('.env.local'))).toBe('.env')
expect(copyText('stem', at('Makefile'))).toBe('Makefile')
})
it('相对路径总用 / 分隔,绝对路径照本机写法', () => {
const windows: CopyTarget = { path: '/c:/work/app/src/a.ts', folderPath: '/c:/work/app', fsPath: 'c:\\work\\app\\src\\a.ts' }
expect(copyText('relativePath', windows)).toBe('src/a.ts')
expect(copyText('absolutePath', windows)).toBe('c:\\work\\app\\src\\a.ts')
})
})
@@ -0,0 +1,41 @@
import * as path from 'node:path'
import { stem } from './fileEntries'
/** 复制菜单里的一种:名字、不带后缀的文件名、相对路径、绝对路径。 */
export type CopyKind = 'name' | 'stem' | 'relativePath' | 'absolutePath'
/**
* 复制菜单的命令 → 复制什么。文件名和文件夹名复制的是同一样东西,分成两个命令只为菜单上写对字
* (package.json 按行的 contextValue 挑);「不带后缀」只给文件。
*/
export const COPY_COMMANDS = {
'superpowers.codeReader.copyFileName': 'name',
'superpowers.codeReader.copyFolderName': 'name',
'superpowers.codeReader.copyFileStem': 'stem',
'superpowers.codeReader.copyRelativePath': 'relativePath',
'superpowers.codeReader.copyAbsolutePath': 'absolutePath',
} as const satisfies Record<string, CopyKind>
export interface CopyTarget {
/** 这一行的 Uri.path(`/` 分隔) */
path: string
/** 所在工作区文件夹的 Uri.path */
folderPath: string
/** 这一行的 Uri.fsPath:本机写法的绝对路径(Windows 上是反斜杠) */
fsPath: string
}
/** 相对路径相对所在的工作区文件夹、用 `/` 分隔(和文件注释的键一样);绝对路径照本机写法。 */
export function copyText(kind: CopyKind, target: CopyTarget): string {
const name = path.posix.basename(target.path)
switch (kind) {
case 'name':
return name
case 'stem':
return stem(name)
case 'relativePath':
return path.posix.relative(target.folderPath, target.path)
case 'absolutePath':
return target.fsPath
}
}
@@ -26,8 +26,11 @@ export type ExcludeSetting = Record<string, boolean | { when: string } | null |
*/
export type ExcludeMatcher = (relativePath: string, siblingNames: ReadonlySet<string>) => boolean
/** 去掉最后一个扩展名:`when` 里的 `$(basename)` 指的就是它。 */
function stem(name: string): string {
/**
* 去掉最后一个扩展名(`a.test.ts` → `a.test`);点开头、后面没有别的点的名字(`.env`)没有扩展名,原样。
* `files.exclude` 的 `when` 里的 `$(basename)` 和复制「文件名(不带后缀)」都是它。
*/
export function stem(name: string): string {
const dot = name.lastIndexOf('.')
return dot > 0 ? name.slice(0, dot) : name
}
@@ -0,0 +1,104 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { noteDecoration } from '../notes/noteItem'
import { COPY_COMMANDS } from './copyText'
import { entryContext } from './fileEntries'
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[]>
}
}
const { commands, submenus, menus } = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest).contributes
const COPY_MENU = 'superpowers.codeReader.copy'
const ADD_NOTE = 'superpowers.codeReader.addNote'
const EDIT_NOTE = 'superpowers.codeReader.editNote'
const file = entryContext({ isDirectory: false, isRoot: false })
const folder = entryContext({ isDirectory: true, isRoot: false })
const root = entryContext({ isDirectory: true, isRoot: true })
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
/** 某个菜单的某一组(行尾按钮 inline / 右键菜单的 copy、notes)对这一行显示哪些命令或子菜单,照 `@序号` 排 */
function itemsFor(menu: string, group: string, contextValue: string): 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)
})
.sort((a, b) => orderOf(a) - orderOf(b))
.map(item => item.command ?? item.submenu ?? '')
}
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('侧栏行尾按钮', () => {
it('先「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => {
for (const context of [file, folder]) {
expect(itemsFor('view/item/context', 'inline', context)).toEqual([COPY_MENU, ADD_NOTE])
expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([COPY_MENU, EDIT_NOTE])
}
expect(itemsFor('view/item/context', 'inline', root)).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)')
})
})
describe('复制菜单', () => {
it.each([COPY_MENU, 'view/item/context'])('%s:文件四项、文件夹没有「不带后缀」,有没有注释都一样', (menu) => {
for (const context of [file, noted(file)])
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('菜单上的字', () => {
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('复制文件夹名')
})
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(palette.get(id)).toBe('false')
}
expect(Object.keys(COPY_COMMANDS).sort()).toEqual([...new Set([...FILE_COPIES, ...FOLDER_COPIES])].sort())
})
})
describe('右键菜单的注释', () => {
it('没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根没有', () => {
for (const context of [file, folder]) {
expect(itemsFor('view/item/context', 'notes', context)).toEqual([ADD_NOTE])
expect(itemsFor('view/item/context', 'notes', noted(context))).toEqual([EDIT_NOTE])
}
expect(itemsFor('view/item/context', 'notes', root)).toEqual([])
})
})
+3 -1
View File
@@ -5,6 +5,7 @@ import { logger } from '../logging/logger'
import { BlockFilter } from './editor/blockFilter'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { registerCopyCommands } from './explorer/copyCommands'
import { FileTreeProvider } from './explorer/fileTree'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
@@ -47,7 +48,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
}
/**
* 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
* 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。
*/
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
@@ -69,6 +70,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
notes,
indexes,
registerNoteCommands(notes),
registerCopyCommands(),
registry.onDidChange(() => {
void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds())
previews.refresh()
@@ -1,7 +1,4 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { entryContext } from '../explorer/fileEntries'
import { escapeMarkdown, noteDecoration } from './noteItem'
describe('树上的一行', () => {
@@ -23,31 +20,3 @@ describe('树上的一行', () => {
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
})
})
describe('菜单按 contextValue 挂按钮', () => {
interface MenuItem { command: string, when: string, group: string }
const menus = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as {
contributes: { menus: Record<string, MenuItem[]> }
}).contributes.menus['view/item/context']
/** 这一行在某个组(行尾按钮 inline / 右键菜单)里有哪些注释命令 */
function commandsFor(contextValue: string, group: string): string[] {
return menus
.filter(item => item.group === 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)
})
.map(item => item.command)
}
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
it.each(['inline', 'notes'])('%s:没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根都没有', (group) => {
for (const context of [entryContext({ isDirectory: false, isRoot: false }), entryContext({ isDirectory: true, isRoot: false })]) {
expect(commandsFor(context, group)).toEqual(['superpowers.codeReader.addNote'])
expect(commandsFor(noted(context), group)).toEqual(['superpowers.codeReader.editNote'])
}
expect(commandsFor(entryContext({ isDirectory: true, isRoot: true }), group)).toEqual([])
})
})