✨ feat(vscode): 代码阅读侧栏行加复制菜单,块按钮只留图标,筛选菜单加全选 / 取消全选
This commit is contained in:
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([])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user