✨ feat(vscode): 代码阅读侧栏行加复制菜单,块按钮只留图标,筛选菜单加全选 / 取消全选
This commit is contained in:
1 parent
631699a570
commit
c84e21ff57
16 files changed
+555
-119
No files matched your search
+112
-3
@@ -124,11 +124,36 @@
|
||||
"title": "编辑",
|
||||
"category": "代码阅读"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileName",
|
||||
"title": "复制文件名",
|
||||
"category": "代码阅读"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFolderName",
|
||||
"title": "复制文件夹名",
|
||||
"category": "代码阅读"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileStem",
|
||||
"title": "复制文件名(不带后缀)",
|
||||
"category": "代码阅读"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyRelativePath",
|
||||
"title": "复制相对路径",
|
||||
"category": "代码阅读"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyAbsolutePath",
|
||||
"title": "复制绝对路径",
|
||||
"category": "代码阅读"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.addNote",
|
||||
"title": "添加注释",
|
||||
"category": "代码阅读",
|
||||
"icon": "$(comment)"
|
||||
"icon": "$(note)"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.editNote",
|
||||
@@ -137,7 +162,41 @@
|
||||
"icon": "$(edit)"
|
||||
}
|
||||
],
|
||||
"submenus": [
|
||||
{
|
||||
"id": "superpowers.codeReader.copy",
|
||||
"label": "复制",
|
||||
"icon": "$(copy)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"superpowers.codeReader.copy": [
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileName",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/",
|
||||
"group": "copy@1"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFolderName",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^folder( noted)?$/",
|
||||
"group": "copy@1"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileStem",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/",
|
||||
"group": "copy@2"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyRelativePath",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/",
|
||||
"group": "copy@3"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyAbsolutePath",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/",
|
||||
"group": "copy@4"
|
||||
}
|
||||
],
|
||||
"view/title": [
|
||||
{
|
||||
"command": "superpowers.openKanban",
|
||||
@@ -161,15 +220,45 @@
|
||||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"submenu": "superpowers.codeReader.copy",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/",
|
||||
"group": "inline@1"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.addNote",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)$/",
|
||||
"group": "inline"
|
||||
"group": "inline@2"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.editNote",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder) noted$/",
|
||||
"group": "inline"
|
||||
"group": "inline@2"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileName",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/",
|
||||
"group": "copy@1"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFolderName",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^folder( noted)?$/",
|
||||
"group": "copy@1"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileStem",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^file( noted)?$/",
|
||||
"group": "copy@2"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyRelativePath",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/",
|
||||
"group": "copy@3"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyAbsolutePath",
|
||||
"when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)( noted)?$/",
|
||||
"group": "copy@4"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.addNote",
|
||||
@@ -207,6 +296,26 @@
|
||||
"command": "superpowers.codeReader.showSource",
|
||||
"when": "activeCustomEditorId == superpowers.codeReader.preview"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileName",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFolderName",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyFileStem",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyRelativePath",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.copyAbsolutePath",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "superpowers.codeReader.addNote",
|
||||
"when": "false"
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockTools } from './BlockTools'
|
||||
|
||||
const render = (editing: boolean): string => renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} />)
|
||||
|
||||
/** 标签之间的字:图标(svg)没有字,按钮上有字就会出现在这里 */
|
||||
const visibleText = (html: string): string => html.replace(/<[^>]*>/g, '').trim()
|
||||
|
||||
describe('块右上角的图标按钮', () => {
|
||||
it.each([[false, '编辑'], [true, '预览']] as const)('编辑中 = %s:只有图标不带字,名字在悬停提示和 aria-label 里', (editing, label) => {
|
||||
const html = render(editing)
|
||||
expect(html).toMatch(/^<div[^>]* role="group" aria-label="块操作"/)
|
||||
expect(visibleText(html)).toBe('')
|
||||
expect(html.match(/<button/g)).toHaveLength(1)
|
||||
expect(html).toContain(`title="${label}" aria-label="${label}"`)
|
||||
expect(html).toMatch(/<button[^>]*><svg/)
|
||||
expect(html.includes('data-active')).toBe(editing)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { Eye, Pencil } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(title)和读屏用的 aria-label 里。
|
||||
* 眼下只有「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
|
||||
*/
|
||||
export function BlockTools({ editing, onToggleEdit }: { editing: boolean, onToggleEdit: () => void }): ReactElement {
|
||||
const label = editing ? '预览' : '编辑'
|
||||
const Icon = editing ? Eye : Pencil
|
||||
return (
|
||||
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
|
||||
<button
|
||||
type="button"
|
||||
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
|
||||
data-active={editing ? '' : undefined}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={onToggleEdit}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { CSSProperties, ReactElement } from 'react'
|
||||
import type { BlockView } from './types'
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { BlockTools } from './BlockTools'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { PropertyGrid } from './PropertyGrid'
|
||||
import { TextPreview } from './TextPreview'
|
||||
@@ -86,7 +86,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState
|
||||
)
|
||||
}
|
||||
|
||||
/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */
|
||||
/** 一块:折叠箭头、种类标签、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */
|
||||
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
const editing = tree.editing.has(block.id)
|
||||
const collapsed = tree.collapsed.has(block.id)
|
||||
@@ -135,17 +135,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
已被筛选隐藏,临时显示
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1 rounded px-2 py-0.5 text-xs leading-4 text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground)]',
|
||||
editing && 'bg-[var(--vscode-toolbar-activeBackground)]',
|
||||
)}
|
||||
onClick={() => tree.onToggleEdit(block.id)}
|
||||
>
|
||||
{editing ? <Eye size={13} strokeWidth={1.8} /> : <Pencil size={13} strokeWidth={1.8} />}
|
||||
{editing ? '预览' : '编辑'}
|
||||
</button>
|
||||
<BlockTools editing={editing} onToggleEdit={() => tree.onToggleEdit(block.id)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { KeyboardEvent, ReactElement } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent, ReactElement } from 'react'
|
||||
import type { Facet, FilterView } from './filter'
|
||||
import type { Highlight } from './occurrences'
|
||||
import type { Excluded, FileAttribute, TranslatorOption } from './types'
|
||||
import { Check, ChevronDown, ListFilter } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { SelectMenu } from '@/components/ui/select-menu'
|
||||
import { facetLabel, facetsOf, toggleValue } from './filter'
|
||||
import { facetLabel, facetsOf, setChecked, toggleValue } from './filter'
|
||||
|
||||
export interface FilterBarProps {
|
||||
attributes: FileAttribute[]
|
||||
@@ -32,7 +32,12 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
|
||||
筛选
|
||||
</span>
|
||||
{facetsOf(attributes, excluded).map(facet => (
|
||||
<FacetMenu key={facet.id} facet={facet} onToggle={value => onChange(toggleValue(excluded, facet.id, value))} />
|
||||
<FacetMenu
|
||||
key={facet.id}
|
||||
facet={facet}
|
||||
onToggle={value => onChange(toggleValue(excluded, facet.id, value))}
|
||||
onSetAll={checked => onChange(setChecked(excluded, facet.id, facet.values.map(value => value.id), checked))}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-auto flex h-6 min-w-0 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
|
||||
{highlight && (
|
||||
@@ -71,10 +76,16 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开是勾选列表,每个值后面是块数。
|
||||
* 列表盖在代码块上面;点外面、Esc、Tab 关掉。键盘:按钮上 ↓ ↑ 回车 空格打开,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选。
|
||||
* 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开的菜单盖在代码块上面:
|
||||
* 顶上一行「全选 · 取消全选」,分隔线下面是勾选列表,每个值后面是块数。点外面、Tab 出去、Esc 关掉。
|
||||
* 键盘:按钮上 ↓ ↑ 回车 空格打开、焦点落在列表上,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选,Shift+Tab 回到「取消全选」「全选」。
|
||||
*/
|
||||
function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string) => void }): ReactElement {
|
||||
function FacetMenu({ facet, onToggle, onSetAll }: {
|
||||
facet: Facet
|
||||
onToggle: (value: string) => void
|
||||
/** 全选(true)/ 取消全选(false) */
|
||||
onSetAll: (checked: boolean) => void
|
||||
}): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIndex, setActive] = useState(0)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
@@ -86,17 +97,6 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
|
||||
// 列表开着时文档刷新、值变少了,高亮落回最后一项
|
||||
const active = Math.min(activeIndex, count - 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const onPointerDown = (event: MouseEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target))
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown, true)
|
||||
return () => document.removeEventListener('mousedown', onPointerDown, true)
|
||||
}, [open])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open)
|
||||
listRef.current?.focus()
|
||||
@@ -117,6 +117,12 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
|
||||
// 焦点离开按钮和菜单(点外面、Tab 出去、切到别的窗口)就关
|
||||
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!(event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
@@ -128,6 +134,14 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
|
||||
}
|
||||
}
|
||||
|
||||
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const move: Record<string, number> = { ArrowDown: (active + 1) % count, ArrowUp: (active - 1 + count) % count, Home: 0, End: count - 1 }
|
||||
if (event.key in move) {
|
||||
@@ -138,18 +152,10 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
|
||||
event.preventDefault()
|
||||
onToggle(facet.values[active].id)
|
||||
}
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
}
|
||||
else if (event.key === 'Tab') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<div ref={rootRef} className="relative" onBlur={open ? onBlur : undefined}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
@@ -167,37 +173,57 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
aria-label={facet.name}
|
||||
aria-activedescendant={`${listId}-${active}`}
|
||||
tabIndex={-1}
|
||||
className="cr-menu absolute top-[calc(100%+2px)] left-0 z-20 max-h-[min(360px,60vh)] w-[200px] overflow-y-auto rounded-md py-1"
|
||||
onKeyDown={onListKeyDown}
|
||||
className="cr-menu absolute top-[calc(100%+2px)] left-0 z-20 flex max-h-[min(360px,60vh)] w-[200px] flex-col rounded-md py-1"
|
||||
onKeyDown={onMenuKeyDown}
|
||||
>
|
||||
{facet.values.map((value, index) => (
|
||||
<div
|
||||
key={value.id}
|
||||
id={`${listId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={value.checked}
|
||||
data-index={index}
|
||||
data-active={index === active ? '' : undefined}
|
||||
className="cr-option flex cursor-pointer items-center gap-2 px-2.5 py-1 text-xs"
|
||||
// 焦点留在列表上,方向键接着能用
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onMouseMove={() => setActive(index)}
|
||||
onClick={() => onToggle(value.id)}
|
||||
>
|
||||
<span className="cr-check flex size-3.5 shrink-0 items-center justify-center" data-checked={value.checked ? '' : undefined} aria-hidden>
|
||||
{value.checked && <Check size={12} strokeWidth={2.4} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{value.name}</span>
|
||||
<span className="cr-option-count shrink-0 tabular-nums">{value.count}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex shrink-0 items-center gap-3 px-2.5 py-1 text-xs">
|
||||
{([[true, '全选'], [false, '取消全选']] as const).map(([checked, text]) => (
|
||||
<button
|
||||
key={text}
|
||||
type="button"
|
||||
className="cr-link"
|
||||
// 点鼠标时焦点留在列表上,方向键接着能用;键盘从列表 Shift+Tab 回到这两个
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => onSetAll(checked)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cr-menu-divider my-1 h-px shrink-0" aria-hidden />
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
aria-label={facet.name}
|
||||
aria-activedescendant={`${listId}-${active}`}
|
||||
tabIndex={0}
|
||||
className="cr-menu-list min-h-0 overflow-y-auto"
|
||||
onKeyDown={onListKeyDown}
|
||||
>
|
||||
{facet.values.map((value, index) => (
|
||||
<div
|
||||
key={value.id}
|
||||
id={`${listId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={value.checked}
|
||||
data-index={index}
|
||||
data-active={index === active ? '' : undefined}
|
||||
className="cr-option flex cursor-pointer items-center gap-2 px-2.5 py-1 text-xs"
|
||||
// 焦点留在列表上,方向键接着能用
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onMouseMove={() => setActive(index)}
|
||||
onClick={() => onToggle(value.id)}
|
||||
>
|
||||
<span className="cr-check flex size-3.5 shrink-0 items-center justify-center" data-checked={value.checked ? '' : undefined} aria-hidden>
|
||||
{value.checked && <Check size={12} strokeWidth={2.4} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{value.name}</span>
|
||||
<span className="cr-option-count shrink-0 tabular-nums">{value.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,21 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
background: color-mix(in srgb, var(--kind) 7%, var(--vscode-editor-background));
|
||||
}
|
||||
|
||||
/* 块右上角的图标按钮:悬停、按下(这一块在编辑)有底色,键盘聚焦描一圈聚焦色 */
|
||||
.cr-tool {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-tool:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
.cr-tool[data-active] {
|
||||
background: var(--vscode-toolbar-activeBackground);
|
||||
}
|
||||
.cr-tool:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止 */
|
||||
.cr-guide {
|
||||
background: var(--vscode-editorIndentGuide-background1);
|
||||
@@ -424,13 +439,18 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent));
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%));
|
||||
}
|
||||
.cr-menu-divider {
|
||||
background: var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border, var(--vscode-panel-border)));
|
||||
}
|
||||
.cr-menu-list {
|
||||
outline: none;
|
||||
}
|
||||
.cr-option[data-active] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
/* 键盘在列表里走时,高亮项再描一圈聚焦色 */
|
||||
.cr-menu:focus-visible .cr-option[data-active] {
|
||||
.cr-menu-list:focus-visible .cr-option[data-active] {
|
||||
outline: 1px solid var(--vscode-list-focusOutline, var(--vscode-focusBorder));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BlockKind, BlockView, FileAttribute } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, toggleValue } from './filter'
|
||||
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter'
|
||||
|
||||
function block(id: string, kind: BlockKind, extra: Record<string, string> = {}, children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] }
|
||||
@@ -111,6 +111,30 @@ describe('筛选栏上的下拉框', () => {
|
||||
expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'public')).toEqual({ access: ['private', 'public'], kind: ['if'] })
|
||||
expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'private')).toEqual({ kind: ['if'] })
|
||||
})
|
||||
|
||||
it('全选:这份文件里的值都勾上,别的文件才有的值照旧去掉,别的属性不动', () => {
|
||||
expect(setChecked({ access: ['private', 'protected'], kind: ['if'] }, 'access', ['private', 'public'], true))
|
||||
.toEqual({ access: ['protected'], kind: ['if'] })
|
||||
expect(setChecked({ access: ['private'], kind: ['if'] }, 'access', ['private', 'public'], true)).toEqual({ kind: ['if'] })
|
||||
expect(setChecked({}, 'access', ['private', 'public'], true)).toEqual({})
|
||||
})
|
||||
|
||||
it('取消全选:这份文件里的值都去掉,已经去掉的不重复记', () => {
|
||||
expect(setChecked({ access: ['protected'], kind: ['if'] }, 'access', ['private', 'public', 'protected'], false))
|
||||
.toEqual({ access: ['protected', 'private', 'public'], kind: ['if'] })
|
||||
const excluded = setChecked({}, 'access', ['private', 'public'], false)
|
||||
const [, access] = facetsOf(ATTRIBUTES, excluded)
|
||||
expect(access.values.every(value => !value.checked)).toBe(true)
|
||||
expect(facetLabel(access)).toEqual({ text: '访问级别:未选', active: true })
|
||||
})
|
||||
|
||||
it('取消全选后有这个属性的块全藏起来,没有这个属性的照常显示;再全选全回来', () => {
|
||||
const none = setChecked({}, 'access', ['private', 'public'], false)
|
||||
const view = applyFilter(BLOCKS, none, NONE)
|
||||
expect([...view.hidden].sort()).toEqual(['0', '1', '1.0', '1.0.0', '1.1', '2', '2.0'])
|
||||
expect(view.hiddenCount).toBe(3)
|
||||
expect(applyFilter(BLOCKS, setChecked(none, 'access', ['private', 'public'], true), NONE).hidden).toEqual(NONE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('临时显示的块', () => {
|
||||
|
||||
@@ -79,7 +79,22 @@ export function facetLabel(facet: Facet): { text: string, active: boolean } {
|
||||
/** 勾上 / 去掉属性的一个值后的筛选;一个值都没去掉的属性不留。 */
|
||||
export function toggleValue(excluded: Excluded, attribute: string, value: string): Excluded {
|
||||
const current = excluded[attribute] ?? []
|
||||
const next = current.includes(value) ? current.filter(item => item !== value) : [...current, value]
|
||||
return withExcluded(excluded, attribute, current.includes(value) ? current.filter(item => item !== value) : [...current, value])
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉菜单的「全选 / 取消全选」:把 values(这份文件里这个属性出现的值)一起勾上或去掉。
|
||||
* 筛选整个工作区共用,别的文件才有的值记着的去掉状态不动;取消全选后有这个属性的块全藏起来。
|
||||
*/
|
||||
export function setChecked(excluded: Excluded, attribute: string, values: readonly string[], checked: boolean): Excluded {
|
||||
const current = excluded[attribute] ?? []
|
||||
return withExcluded(excluded, attribute, checked
|
||||
? current.filter(item => !values.includes(item))
|
||||
: [...current, ...values.filter(item => !current.includes(item))])
|
||||
}
|
||||
|
||||
/** 换掉一个属性去掉的值;一个值都没去掉的属性不留。 */
|
||||
function withExcluded(excluded: Excluded, attribute: string, next: string[]): Excluded {
|
||||
const rest = Object.fromEntries(Object.entries(excluded).filter(([key]) => key !== attribute))
|
||||
return next.length > 0 ? { ...rest, [attribute]: next } : rest
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user