✨ feat(vscode): 预览里代码的上色和卡片种类色跟着当前颜色主题走
This commit is contained in:
1 parent
19302bbed0
commit
dff38ee0c5
26 files changed
+1286
-75
No files matched your search
@@ -2104,27 +2104,33 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
|
||||
|
||||
## 颜色
|
||||
|
||||
词性(暗色取 VS Code Dark+,亮色取 Light+):
|
||||
词性和内置种类的颜色跟着当前颜色主题走,和编辑器里看到的代码颜色一致(`theme/`):
|
||||
|
||||
| 词性 | 暗色 | 亮色 |
|
||||
| --- | --- | --- |
|
||||
| keyword | #c586c0 | #af00db |
|
||||
| storage / operator | #569cd6 | #0000ff |
|
||||
| variable | #9cdcfe | #001080 |
|
||||
| function | #dcdcaa | #795e26 |
|
||||
| type | #4ec9b0 | #267f99 |
|
||||
| string | #ce9178 | #a31515 |
|
||||
| number / literal | #b5cea8 | #098658 |
|
||||
| comment | #6a9955 | #008000 |
|
||||
| hint | `--vscode-descriptionForeground` | 同左 |
|
||||
| constant | #4fc1ff | #0070c1 |
|
||||
| plain | `--vscode-editor-foreground` | 同左 |
|
||||
- 当前主题照 VS Code 自己的规则定:开着 `window.autoDetectHighContrast` 且当前是高对比主题时取两个高对比的设置,开着 `window.autoDetectColorScheme` 时按当前主题的明暗取 `workbench.preferredDarkColorTheme` / `preferredLightColorTheme`,否则是 `workbench.colorTheme`;老名字照 VS Code 换成现在的。在 `vscode.extensions.all`(内置主题也在里面)各扩展贡献的 `themes` 里按 id(没有用 label,再没有用文件名)找到主题文件。
|
||||
- 读主题文件:JSON 带注释、尾逗号;`include` 的在前、自己的在后;`tokenColors` 可以是数组,也可以是指向 `.tmTheme` 的路径;主题本身也可以是 `.tmTheme`(plist,`plist` 包解析)。
|
||||
- 叠用户设置:`editor.tokenColorCustomizations`(几组颜色、`textMateRules`、`"[主题名]"` 专属块)排在主题后面;`editor.semanticHighlighting.enabled`、主题的 `semanticHighlighting`、`semanticTokenColors` 和 `editor.semanticTokenColorCustomizations`(`enabled`、`rules`、专属块)定开没开语义上色、语义规则。先后照 VS Code 的 `colorThemeData.ts`。
|
||||
- 每种颜色查什么在 `theme/codeColors.ts` 的对照表:开了语义上色时先看语义规则(函数 `function`、类型 `class`、变量 `variable`、常量 `variable.readonly`、枚举 `enum`、构建阶段 / 模块 `namespace`),没有再依次查几个 TextMate 范围。范围照 vscode-textmate 匹配:规则的范围按点分段是查的范围的前缀才算,段数多的胜,一样具体时后写的胜,逗号分隔的选择器拆开,带父级的选择器不算。都没配的用编辑器前景色(`--vscode-editor-foreground`)。语义规则不分语言(所有预览共用一份),带语言的规则不算。
|
||||
- 宿主共用一份(`ThemeColors`),换主题(`onDidChangeActiveColorTheme`)、改上面这些设置时作废重读,发 `codeColors` 给所有 ready 的预览;页面 ready 时先于第一份文档发。页面设成 body 上的 `--cr-<词性>`、`--cr-kind-<种类>`(`codeColors.ts`)。主题找不到、读不出来时记一条警告,全用编辑器前景色。
|
||||
|
||||
| 词性 | 查什么 |
|
||||
| --- | --- |
|
||||
| keyword | `keyword.control` |
|
||||
| storage / operator | `storage.type` |
|
||||
| variable | 语义 `variable`;`variable.other.readwrite`、`entity.name.variable` |
|
||||
| function | 语义 `function`;`entity.name.function`、`support.function` |
|
||||
| type | 语义 `class`;`entity.name.type.class`、`support.class` |
|
||||
| string | `string` |
|
||||
| number / literal | `constant.numeric` |
|
||||
| comment | `comment` |
|
||||
| hint | `--vscode-descriptionForeground` |
|
||||
| constant | 语义 `variable.readonly`;`variable.other.constant`、`variable.other.readwrite`、`entity.name.variable` |
|
||||
| plain | `--vscode-editor-foreground` |
|
||||
|
||||
属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。
|
||||
|
||||
带自己颜色的段(`Segment.color`,库规则按库的配色给,例如 FastAPI 的 HTTP 方法、成功的状态码):暗色、亮色两个写在元素的 style 上(`--color-dark` / `--color-light`),`.cr-colored` 按主题挑一个,盖过词性的颜色;词性照旧报(`role`)。
|
||||
|
||||
种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数、impl 实现 #dcdcaa;class 类、type 类型 #4ec9b0;trait 特质同声明关键字的蓝;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);Shell 的 interpreter 解释器同语句 #9d9d9d,options 选项 #e5c07b(亮色 #9a6a00),case 分情况同条件 #ce9178,command 命令、pipeline 管道 #d7ba7d(亮色 #7c5f12);Dockerfile 的 directive 解析指令同语句,stage 阶段、module 模块 #56b6c2(亮色 #127f8c),run 执行同命令,copy 复制同导入,setting 设置同选项,label 标签同变量,health 健康检查同尝试 / 捕获,start 启动同返回(原型 DOCKER_KINDS 的颜色);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
|
||||
种类(种类图标的颜色 + 块底 7% + 边框 35%;图标见「预览(代码块树)」):同一类语义共用一个 `--cr-kind-*`,查主题的什么见下;主题给几类配了同一个色就同色,靠图标区分。import 导入、copy 复制查 `keyword.control.import`;function 函数、impl 实现同函数名;class 类、type 类型同类型名;trait 特质同 storage;attribute 同 constant;enum 枚举查语义 `enum`、`entity.name.type.enum`;exception 异常查 `support.type.exception`;field 字段、const 常量、variable 变量、label 标签同变量名;if 条件、else 否则、case 分情况查 `keyword.control.conditional`;loop 循环查 `keyword.control.loop`;try 尝试、catch 捕获、finally 最终、health 健康检查查 `keyword.control.trycatch`;return 返回、start 启动查 `keyword.control.flow`;statement 语句、interpreter 解释器、directive 解析指令查 `source`(一般就是编辑器前景色);comment 注释查 `comment`(虚线框);options 选项、setting 设置查 `constant.other.option`;command 命令、pipeline 管道、run 执行查 `entity.name.command`,没配落回 `entity.name.function.call`;stage 阶段、module 模块查语义 `namespace`、`entity.name.namespace`。查不到具体的会落回更泛的(`keyword.control.loop` 落回 `keyword.control`)。库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
|
||||
|
||||
## 代码位置
|
||||
|
||||
@@ -2240,8 +2246,12 @@ vscode/src/codeReader/
|
||||
editor/openTarget.ts 打开别的文件用哪种编辑器:语言有代码阅读的用预览(滚到定义处),其余用默认的打开方式(选中名字);侧栏点文件、跳到定义、注释里的链接共用
|
||||
editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights)
|
||||
editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键
|
||||
theme/currentTheme.ts 当前正在用的颜色主题:照 VS Code 的规则定设置里的名字,在装着的扩展里找主题文件
|
||||
theme/themeFile.ts 读颜色主题文件:JSON(带注释、include 链、tokenColors 指向 .tmTheme)或 .tmTheme(不碰 VS Code)
|
||||
theme/tokenStyles.ts 主题叠上用户设置后一个 TextMate 范围、一种语义记号是什么颜色,照 VS Code 和 vscode-textmate 的规则(不碰 VS Code)
|
||||
theme/codeColors.ts 词性、种类 → 查什么的对照表;ThemeColors:所有预览共用的一份代码配色,换主题、改设置时重读、通知预览
|
||||
index.ts registerCodeReader(context): CodeReaderApi
|
||||
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts;钉住卡片:钉哪几张、顶上去多少在 stickyScroll.ts,量页面、收设置在 useStickyScroll.ts,钉住的几行在 StickyLines.tsx)
|
||||
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,扩展端发来的代码配色设成颜色变量在 codeColors.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts;钉住卡片:钉哪几张、顶上去多少在 stickyScroll.ts,量页面、收设置在 useStickyScroll.ts,钉住的几行在 StickyLines.tsx)
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -3004,7 +3004,9 @@
|
||||
"diff": "^9.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"ignore": "^7.0.10",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"minimatch": "^10.2.6",
|
||||
"plist": "^5.0.0",
|
||||
"toml": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+27
@@ -28,9 +28,15 @@ importers:
|
||||
ignore:
|
||||
specifier: ^7.0.10
|
||||
version: 7.0.10
|
||||
jsonc-parser:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
minimatch:
|
||||
specifier: ^10.2.6
|
||||
version: 10.2.6
|
||||
plist:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
toml:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
@@ -1494,6 +1500,10 @@ packages:
|
||||
'@vue/shared@3.5.39':
|
||||
resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==}
|
||||
|
||||
'@xmldom/xmldom@0.9.12':
|
||||
resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -3189,6 +3199,10 @@ packages:
|
||||
platform@1.3.6:
|
||||
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
|
||||
|
||||
plist@5.0.0:
|
||||
resolution: {integrity: sha512-20N+g1DvMm/DFRbsvER7tT4wDryq0WunK7VMkDaiJcKNapAnUMkTsAnacFYf8n420F4Hf6/hefgmJRkMb1M0fg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
pluralize@2.0.0:
|
||||
resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==}
|
||||
|
||||
@@ -3888,6 +3902,10 @@ packages:
|
||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
xmlbuilder@15.1.1:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -5264,6 +5282,8 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.39': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.12': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -7219,6 +7239,11 @@ snapshots:
|
||||
|
||||
platform@1.3.6: {}
|
||||
|
||||
plist@5.0.0:
|
||||
dependencies:
|
||||
'@xmldom/xmldom': 0.9.12
|
||||
xmlbuilder: 15.1.1
|
||||
|
||||
pluralize@2.0.0: {}
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
@@ -7926,6 +7951,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@11.0.1: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
@@ -891,7 +891,8 @@ export type KindIconName = (typeof KIND_ICON_NAMES)[number]
|
||||
|
||||
/**
|
||||
* 内置种类用哪个图标。宿主排预览时写进概念列表,webview 只按名字画。
|
||||
* 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。
|
||||
* 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理;
|
||||
* 按当前颜色主题取色,见 theme/codeColors.ts,主题给几类配了同一个色就同色),图标区分具体是哪种。
|
||||
*/
|
||||
export const KIND_ICONS: Record<BlockKind, KindIconName> = {
|
||||
import: 'import',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { DocumentDiff } from '../diff/previewDiff'
|
||||
import type { ShownName } from '../nameResolution'
|
||||
import type { ProfileChoice } from '../qa/askProfiles'
|
||||
import type { AskRound } from '../qa/asks'
|
||||
import type { CodeColors } from '../theme/codeColors'
|
||||
import type { Excluded, FilterState } from './blockFilter'
|
||||
|
||||
export type { Excluded, ShownName }
|
||||
@@ -138,6 +139,8 @@ export interface StickyScrollSettings {
|
||||
* stickyScroll:VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先),见 StickyScrollSettings;ready 后发一次,设置改了再发
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
|
||||
* codeColors:当前颜色主题(叠上用户设置里改的)给代码的配色,见 CodeColors,页面设成词性、种类的颜色变量;
|
||||
* ready 后先于第一份文档发一次,换了颜色主题、改了代码配色的设置再发给所有预览
|
||||
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
|
||||
* profiles:kind 那种对话输入框旁边的 profile 下拉:选项和选中的那项;ready 后两种各发一次,任何一个预览改了哪种就把哪种发给所有预览
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
@@ -170,6 +173,7 @@ export type ExtensionToWebview
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
| ({ type: 'filter' } & FilterState)
|
||||
| ({ type: 'codeColors' } & CodeColors)
|
||||
| QaMessage
|
||||
| ({ type: 'profiles', kind: ChatKind } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DiffBase, OpenedFrom } from '../diff/diffBase'
|
||||
import type { ProfileChoice } from '../qa/askProfiles'
|
||||
import type { RewriteRequest } from '../qa/codeQa'
|
||||
import type { SymbolNode } from '../symbolLinks'
|
||||
import type { CodeColors } from '../theme/codeColors'
|
||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { window, workspace } from 'vscode'
|
||||
@@ -62,6 +63,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
|
||||
{},
|
||||
{},
|
||||
{ subscribe },
|
||||
{ subscribe },
|
||||
async () => [],
|
||||
qa,
|
||||
async () => [],
|
||||
@@ -162,8 +164,27 @@ function profileSet(selected: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const COLORS = { roles: { keyword: '#C586C0' }, kinds: { loop: '#C586C0' } } as unknown as CodeColors
|
||||
|
||||
/** 共用的代码配色:眼下是 colors,publish 模拟换了颜色主题、通知订阅的预览 */
|
||||
function themeColors(colors: CodeColors) {
|
||||
const listeners = new Set<(value: CodeColors) => void>()
|
||||
let current = colors
|
||||
return {
|
||||
current: async () => current,
|
||||
subscribe: (listener: (value: CodeColors) => void) => {
|
||||
listeners.add(listener)
|
||||
return { dispose: () => listeners.delete(listener) }
|
||||
},
|
||||
publish: (value: CodeColors) => {
|
||||
current = value
|
||||
listeners.forEach(listener => listener(value))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 对比基准只认从「未提交的更改」点开的:从那儿点开的对比上一次提交,别处打开的不比 */
|
||||
function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }) {
|
||||
function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, colors = themeColors(COLORS)) {
|
||||
const subscribe = () => ({ dispose: () => {} })
|
||||
const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined)
|
||||
const qa = {
|
||||
@@ -179,6 +200,7 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }) {
|
||||
{},
|
||||
{},
|
||||
{ subscribe, current: { excluded: {}, onlyChanges: false } },
|
||||
colors,
|
||||
async () => [],
|
||||
qa,
|
||||
async () => [],
|
||||
@@ -314,6 +336,28 @@ describe('预览对比什么看这个标签页是从哪儿打开的', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('代码配色跟着当前颜色主题', () => {
|
||||
it('ready 后先于第一份文档发;换了主题发给 ready 了的预览,还没 ready 的等 ready 时发眼下的', async () => {
|
||||
const colors = themeColors(COLORS)
|
||||
const { provider, document } = opening(undefined, colors)
|
||||
const page = tab()
|
||||
provider.resolveCustomTextEditor(document as never, page.panel)
|
||||
const light = { roles: { keyword: '#AF00DB' }, kinds: { loop: '#AF00DB' } } as unknown as CodeColors
|
||||
colors.publish(light)
|
||||
expect(page.posted()).toEqual([])
|
||||
|
||||
await page.send({ type: 'ready' })
|
||||
const types = page.posted().map(message => message.type)
|
||||
expect(page.posted()).toContainEqual({ type: 'codeColors', ...light })
|
||||
expect(types.indexOf('codeColors')).toBeGreaterThanOrEqual(0)
|
||||
expect(types.indexOf('codeColors')).toBeLessThan(types.indexOf('document'))
|
||||
|
||||
page.clear()
|
||||
colors.publish(COLORS)
|
||||
expect(page.posted()).toEqual([{ type: 'codeColors', ...COLORS }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('问 AI 和重写注释的 profile 下拉各管各的', () => {
|
||||
it('ready 后两套各发一次;哪一套变了只发哪一套', async () => {
|
||||
const profiles = { ask: profileSet('sonnet'), rewrite: profileSet('dsflash') }
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CodeReaderRegistry } from '../registry'
|
||||
import type { LookupSources } from '../ruleLookups'
|
||||
import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
|
||||
import type { ThemeColors } from '../theme/codeColors'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { FoundLocation } from './definitions'
|
||||
import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages'
|
||||
@@ -82,6 +83,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
private readonly registry: CodeReaderRegistry,
|
||||
private readonly runtime: TreeSitterRuntime,
|
||||
private readonly filter: BlockFilter,
|
||||
/** 代码配色:各预览共用当前颜色主题的那一份,换了主题、改了代码配色的设置时发给订阅的 */
|
||||
readonly codeColors: Pick<ThemeColors, 'current' | 'subscribe'>,
|
||||
/** 库规则标的引用去哪儿查定义(交给标它的规则) */
|
||||
readonly resolveReference: (reference: RuleReference) => Promise<RuleLocation[]>,
|
||||
/** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */
|
||||
@@ -284,6 +287,11 @@ class Preview {
|
||||
if (this.ready)
|
||||
this.post({ type: 'filter', ...state })
|
||||
}),
|
||||
// 同上:没 ready 的等 ready 时在第一份文档之前发眼下的
|
||||
owner.codeColors.subscribe((colors) => {
|
||||
if (this.ready)
|
||||
this.post({ type: 'codeColors', ...colors })
|
||||
}),
|
||||
// 项目的入口变了:重新发文档,卡片左边的入口图标跟着变
|
||||
owner.entries.onDidChange((uris) => {
|
||||
if (uris.some(uri => uri.toString() === document.uri.toString()))
|
||||
@@ -605,6 +613,8 @@ class Preview {
|
||||
this.postQa()
|
||||
for (const kind of CHAT_KINDS)
|
||||
void this.postProfiles(kind)
|
||||
// 也先于文档发:第一份文档一画出来就是主题的颜色,不会先没颜色再闪一下
|
||||
this.post({ type: 'codeColors', ...await this.owner.codeColors.current() })
|
||||
await this.push()
|
||||
if (this.pendingPathFocus)
|
||||
this.focusPathAsk()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
// 被 include 的主题:它的规则排在 include 它的主题前面
|
||||
"name": "Base",
|
||||
"semanticHighlighting": true,
|
||||
"tokenColors": [
|
||||
{ "scope": "keyword", "settings": { "foreground": "#111111" } },
|
||||
{ "scope": "string", "settings": { "foreground": "#222222" } }
|
||||
],
|
||||
"semanticTokenColors": {
|
||||
"function": "#333333"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Classic</string>
|
||||
<key>settings</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>background</key>
|
||||
<string>#000000</string>
|
||||
<key>foreground</key>
|
||||
<string>#FFFFFF</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Comment</string>
|
||||
<key>scope</key>
|
||||
<string>comment</string>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key>
|
||||
<string>#666666</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Control</string>
|
||||
<key>scope</key>
|
||||
<string>keyword.control, storage</string>
|
||||
<key>settings</key>
|
||||
<dict>
|
||||
<key>foreground</key>
|
||||
<string>#777777</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Child",
|
||||
/* include 的路径从这个文件所在的目录算起 */
|
||||
"include": "../base.json",
|
||||
"tokenColors": [
|
||||
{ "scope": "string", "settings": { "foreground": "#444444" } }
|
||||
],
|
||||
"semanticTokenColors": {
|
||||
"function": { "foreground": "#555555" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>settings</key>
|
||||
<string>not an array</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Split",
|
||||
"tokenColors": "./classic.tmTheme"
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { thiserrorRule } from './rules/thiserror'
|
||||
import { bundledWasmDir, indexGrammar, TreeSitterRuntime } from './runtime'
|
||||
import { SlotPool } from './slots/slotPool'
|
||||
import { SLOTS } from './slots/slots'
|
||||
import { readCodeColors, ThemeColors } from './theme/codeColors'
|
||||
import { bashZhTranslator } from './translators/bashZh'
|
||||
import { dockerfileZhTranslator } from './translators/dockerfileZh'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
@@ -121,11 +122,14 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const heads = new HeadBases()
|
||||
// 预览对比什么:审查工作副本里的对比 PR 的目标分支,从「未提交的更改」一栏点开的有改动的文件对比上一次提交,别处打开的不比
|
||||
const diffBases = previewDiffBases(review, heads)
|
||||
// 预览里代码的上色、卡片的种类色跟着当前颜色主题走
|
||||
const codeColors = new ThemeColors(readCodeColors)
|
||||
const previews = new PreviewEditorProvider(
|
||||
context.extensionUri,
|
||||
registry,
|
||||
runtime,
|
||||
new BlockFilter(context.workspaceState),
|
||||
codeColors,
|
||||
resolve,
|
||||
qa,
|
||||
namesIn,
|
||||
@@ -143,6 +147,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
codeColors,
|
||||
notes,
|
||||
entries,
|
||||
indexes,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { CodeColors } from './codeColors'
|
||||
import type { ColorCustomizations } from './tokenStyles'
|
||||
import * as path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { codeColorsOf, readCodeColors, ThemeColors } from './codeColors'
|
||||
import { TokenStyles } from './tokenStyles'
|
||||
|
||||
/** VS Code 那头:用户设置(键写全)、装着的扩展,换主题、改设置时要通知的 */
|
||||
const host = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
extensions: [] as unknown[],
|
||||
themeChanged: new Set<() => void>(),
|
||||
configChanged: new Set<(e: { affectsConfiguration: (key: string) => boolean }) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 },
|
||||
window: {
|
||||
activeColorTheme: { kind: 2 },
|
||||
onDidChangeActiveColorTheme: (listener: () => void) => {
|
||||
host.themeChanged.add(listener)
|
||||
return { dispose: () => host.themeChanged.delete(listener) }
|
||||
},
|
||||
},
|
||||
extensions: { get all() { return host.extensions } },
|
||||
workspace: {
|
||||
getConfiguration: (section?: string) => ({
|
||||
get: (key: string) => host.settings[section ? `${section}.${key}` : key],
|
||||
inspect: () => undefined,
|
||||
}),
|
||||
onDidChangeConfiguration: (listener: (e: { affectsConfiguration: (key: string) => boolean }) => void) => {
|
||||
host.configChanged.add(listener)
|
||||
return { dispose: () => host.configChanged.delete(listener) }
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
|
||||
|
||||
const FOREGROUND = 'var(--vscode-editor-foreground)'
|
||||
const NO_CUSTOM: ColorCustomizations = { tokenColors: undefined, semanticTokenColors: undefined, semanticHighlighting: undefined }
|
||||
|
||||
function rule(scope: string, foreground: string): unknown {
|
||||
return { scope, settings: { foreground } }
|
||||
}
|
||||
|
||||
/** 一套主题:TextMate 规则只配了这几种,语义规则只配了函数 */
|
||||
function styles(semanticHighlighting: boolean): TokenStyles {
|
||||
return new TokenStyles('T', {
|
||||
textMateRules: [
|
||||
rule('keyword', '#000001'),
|
||||
rule('keyword.control', '#000002'),
|
||||
rule('keyword.control.loop', '#000003'),
|
||||
rule('storage', '#000004'),
|
||||
rule('entity.name.function', '#000005'),
|
||||
rule('entity.name.type', '#000006'),
|
||||
rule('comment', '#000007'),
|
||||
],
|
||||
semanticHighlighting,
|
||||
semanticRules: [['function', '#000008']],
|
||||
}, NO_CUSTOM)
|
||||
}
|
||||
|
||||
function fireThemeChange(): void {
|
||||
host.themeChanged.forEach(listener => listener())
|
||||
}
|
||||
|
||||
function fireConfigChange(changed: string): void {
|
||||
host.configChanged.forEach(listener => listener({ affectsConfiguration: key => changed === key || changed.startsWith(`${key}.`) }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host.settings = { 'workbench.colorTheme': 'Child' }
|
||||
host.extensions = [{
|
||||
extensionPath: path.join(__dirname, '../fixtures/themes'),
|
||||
packageJSON: { contributes: { themes: [{ id: 'Child', path: './nested/child.json' }, { id: 'Gone', path: './gone.json' }] } },
|
||||
}]
|
||||
vi.mocked(logger.add).mockClear()
|
||||
})
|
||||
|
||||
describe('对照表:每种颜色查哪些范围', () => {
|
||||
it('关了语义上色:只查 TextMate 范围,具体的查不到落回更泛的(分支落回 keyword.control),种类和词性查一样的就一样;都没配的是编辑器前景色', () => {
|
||||
const colors = codeColorsOf(styles(false))
|
||||
|
||||
expect(colors.roles).toMatchObject({ keyword: '#000002', storage: '#000004', function: '#000005', type: '#000006', comment: '#000007', variable: FOREGROUND, string: FOREGROUND })
|
||||
expect(colors.kinds).toMatchObject({ import: '#000002', branch: '#000002', loop: '#000003', return: '#000002', function: '#000005', command: '#000005', type: '#000006', enum: '#000006', comment: '#000007', data: FOREGROUND, statement: FOREGROUND })
|
||||
})
|
||||
|
||||
it('开了语义上色:函数、类这些先看语义规则,没有的再看 TextMate 范围', () => {
|
||||
const colors = codeColorsOf(styles(true))
|
||||
|
||||
expect(colors.roles.function).toBe('#000008')
|
||||
expect(colors.kinds.function).toBe('#000008')
|
||||
expect(colors.roles.type).toBe('#000006')
|
||||
expect(colors.kinds.command).toBe('#000005')
|
||||
})
|
||||
|
||||
it('没有主题可查时全是编辑器前景色', () => {
|
||||
const colors = codeColorsOf(undefined)
|
||||
|
||||
expect(new Set([...Object.values(colors.roles), ...Object.values(colors.kinds)])).toEqual(new Set([FOREGROUND]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('读当前主题的代码配色', () => {
|
||||
it('读当前主题的文件(连 include 的),叠上用户设置里改的、这个主题专属的', async () => {
|
||||
host.settings['editor.tokenColorCustomizations'] = { '[Child]': { comments: '#ABCDEF' } }
|
||||
|
||||
const colors = await readCodeColors()
|
||||
|
||||
expect(colors.roles).toMatchObject({ keyword: '#111111', storage: FOREGROUND, string: '#444444', comment: '#ABCDEF', function: '#555555' })
|
||||
expect(logger.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('用户关了语义上色:函数不看语义规则', async () => {
|
||||
host.settings['editor.semanticHighlighting.enabled'] = false
|
||||
|
||||
expect((await readCodeColors()).roles.function).toBe(FOREGROUND)
|
||||
})
|
||||
|
||||
it('找不到主题、主题文件读不到:记一条警告,全用编辑器前景色', async () => {
|
||||
for (const name of ['Missing', 'Gone']) {
|
||||
host.settings['workbench.colorTheme'] = name
|
||||
vi.mocked(logger.add).mockClear()
|
||||
|
||||
expect(await readCodeColors()).toEqual(codeColorsOf(undefined))
|
||||
expect(logger.add).toHaveBeenCalledWith(expect.objectContaining({ level: 'warn', source: 'codeReader', message: expect.stringContaining(name) }))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 一份能认出来是第几次读的配色 */
|
||||
function colorsNumbered(n: number): CodeColors {
|
||||
return { roles: { keyword: `#00000${n}` }, kinds: {} } as unknown as CodeColors
|
||||
}
|
||||
|
||||
describe('所有预览共用的一份配色', () => {
|
||||
it('只读一次;换了颜色主题、改了代码配色的设置时重读,读好了发给订阅的;无关的设置不管,停用后不再管', async () => {
|
||||
let reads = 0
|
||||
const read = vi.fn(async () => colorsNumbered(++reads))
|
||||
const colors = new ThemeColors(read)
|
||||
const got: CodeColors[] = []
|
||||
colors.subscribe(value => got.push(value))
|
||||
|
||||
expect(await colors.current()).toEqual(colorsNumbered(1))
|
||||
expect(await colors.current()).toEqual(colorsNumbered(1))
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireThemeChange()
|
||||
await vi.waitFor(() => expect(got).toEqual([colorsNumbered(2)]))
|
||||
fireConfigChange('editor.fontSize')
|
||||
fireConfigChange('editor.semanticHighlighting.enabled')
|
||||
await vi.waitFor(() => expect(got).toEqual([colorsNumbered(2), colorsNumbered(3)]))
|
||||
for (const key of ['workbench.colorTheme', 'workbench.preferredDarkColorTheme', 'window.autoDetectColorScheme', 'editor.tokenColorCustomizations', 'editor.semanticTokenColorCustomizations'])
|
||||
fireConfigChange(key)
|
||||
await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(8))
|
||||
expect(await colors.current()).toEqual(colorsNumbered(8))
|
||||
|
||||
colors.dispose()
|
||||
fireThemeChange()
|
||||
expect(read).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('接连换了两次:先前读的过时了不发,current 等到最新的那份', async () => {
|
||||
const pending: ((value: CodeColors) => void)[] = []
|
||||
const colors = new ThemeColors(() => new Promise<CodeColors>(resolve => pending.push(resolve)))
|
||||
const got: CodeColors[] = []
|
||||
colors.subscribe(value => got.push(value))
|
||||
|
||||
const first = colors.current()
|
||||
fireThemeChange()
|
||||
fireThemeChange()
|
||||
pending[2]!(colorsNumbered(3))
|
||||
pending[0]!(colorsNumbered(1))
|
||||
pending[1]!(colorsNumbered(2))
|
||||
|
||||
expect(await first).toEqual(colorsNumbered(3))
|
||||
await Promise.resolve()
|
||||
expect(got).toEqual([colorsNumbered(3)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Disposable } from 'vscode'
|
||||
import { window, workspace } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { currentTheme } from './currentTheme'
|
||||
import { readThemeTokens } from './themeFile'
|
||||
import { TokenStyles } from './tokenStyles'
|
||||
|
||||
/** 代码上色的几种词性(codeReader.css 的 --cr-<词性>),源码和译文共用;operator 用 storage 的,literal 用 number 的 */
|
||||
export type RoleColor = 'keyword' | 'storage' | 'variable' | 'constant' | 'function' | 'type' | 'string' | 'number' | 'comment'
|
||||
|
||||
/** 卡片的几类种类色(codeReader.css 的 --cr-kind-<种类>):同一类语义共用一个颜色,图标区分具体是哪种 */
|
||||
export type KindColor = 'import' | 'function' | 'type' | 'enum' | 'exception' | 'data' | 'branch' | 'loop' | 'error' | 'return' | 'statement' | 'comment' | 'options' | 'command' | 'stage'
|
||||
|
||||
/** 当前颜色主题给代码的配色,值是 CSS 颜色:主题(连同用户设置)配了的是十六进制色,没配的是编辑器前景色 */
|
||||
export interface CodeColors {
|
||||
roles: Record<RoleColor, string>
|
||||
kinds: Record<KindColor, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 一种颜色从主题里怎么取:开了语义上色时先查语义规则里的 semantic(`类型.修饰`,语言扩展给名字标的那种),
|
||||
* 没有规则配色再依次查 scopes 这几个 TextMate 范围,取第一个配了色的。
|
||||
* 查一个范围时规则配的是它的前缀也算(照 vscode-textmate),所以 keyword.control.loop 查不到会落回 keyword.control、keyword。
|
||||
* 带 semantic 的,scopes 就是 VS Code 在语义规则没配色时替这种记号查的那几个范围(tokenClassificationRegistry.ts),两条路一致。
|
||||
*/
|
||||
interface ColorSource {
|
||||
semantic?: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
/** 函数名:语义记号 function,查不到看函数定义和内置函数 */
|
||||
const FUNCTION: ColorSource = { semantic: 'function', scopes: ['entity.name.function', 'support.function'] }
|
||||
/** 类型名:语义记号 class(Python、TypeScript 的类型名大多是类),查不到看类名和内置类 */
|
||||
const TYPE: ColorSource = { semantic: 'class', scopes: ['entity.name.type.class', 'support.class'] }
|
||||
/** 变量名:语义记号 variable,查不到看普通变量 */
|
||||
const VARIABLE: ColorSource = { semantic: 'variable', scopes: ['variable.other.readwrite', 'entity.name.variable'] }
|
||||
|
||||
/** 每种词性从主题里怎么取色 */
|
||||
const ROLE_SOURCES: Record<RoleColor, ColorSource> = {
|
||||
// 流程关键字:import if for return try await …
|
||||
keyword: { scopes: ['keyword.control'] },
|
||||
// 声明关键字和字面量关键字:def class const function async True None …(语法里都是 storage.type)
|
||||
storage: { scopes: ['storage.type'] },
|
||||
variable: VARIABLE,
|
||||
// 当常量用的名字:语义记号是只读的变量,查不到时 VS Code 先看常量、再看普通变量
|
||||
constant: { semantic: 'variable.readonly', scopes: ['variable.other.constant', 'variable.other.readwrite', 'entity.name.variable'] },
|
||||
function: FUNCTION,
|
||||
type: TYPE,
|
||||
string: { scopes: ['string'] },
|
||||
number: { scopes: ['constant.numeric'] },
|
||||
comment: { scopes: ['comment'] },
|
||||
}
|
||||
|
||||
/** 每类卡片从主题里怎么取色:取写这类代码打头的那个关键字、或这类代码定义的名字的颜色 */
|
||||
const KIND_SOURCES: Record<KindColor, ColorSource> = {
|
||||
// 导入:import / from
|
||||
import: { scopes: ['keyword.control.import'] },
|
||||
// 函数、方法:同函数名
|
||||
function: FUNCTION,
|
||||
// 类、类型:同类型名
|
||||
type: TYPE,
|
||||
// 枚举:语义记号 enum,查不到看枚举名,再落回类型名的 entity.name.type
|
||||
enum: { semantic: 'enum', scopes: ['entity.name.type.enum'] },
|
||||
// 异常类:内置异常类型(Python 的 ValueError 这些)
|
||||
exception: { scopes: ['support.type.exception'] },
|
||||
// 字段、常量、变量:同变量名
|
||||
data: VARIABLE,
|
||||
// 条件、分支:if / else / case
|
||||
branch: { scopes: ['keyword.control.conditional'] },
|
||||
// 循环:for / while
|
||||
loop: { scopes: ['keyword.control.loop'] },
|
||||
// 异常处理:try / catch / finally(TypeScript 语法的叫法)
|
||||
error: { scopes: ['keyword.control.trycatch'] },
|
||||
// 返回、启动:return 这类跳走的关键字
|
||||
return: { scopes: ['keyword.control.flow'] },
|
||||
// 普通语句:就是一段普通代码,取整段源码的范围,主题一般不配,即编辑器前景色
|
||||
statement: { scopes: ['source'] },
|
||||
comment: { scopes: ['comment'] },
|
||||
// 选项、设置:命令行的 -e 这类选项
|
||||
options: { scopes: ['constant.other.option'] },
|
||||
// 命令:shell 里的命令名,语法标成命令、外面套着函数调用,主题没配命令时落回函数调用的颜色
|
||||
command: { scopes: ['entity.name.command', 'entity.name.function.call'] },
|
||||
// 构建阶段、模块:语义记号 namespace,查不到看命名空间名
|
||||
stage: { semantic: 'namespace', scopes: ['entity.name.namespace'] },
|
||||
}
|
||||
|
||||
/** 主题没配色时用的编辑器前景色(webview 里 VS Code 给的变量,用户改过的也算上了) */
|
||||
const EDITOR_FOREGROUND = 'var(--vscode-editor-foreground)'
|
||||
|
||||
function colorOf(styles: TokenStyles | undefined, source: ColorSource): string {
|
||||
if (!styles)
|
||||
return EDITOR_FOREGROUND
|
||||
const semantic = styles.semanticHighlighting && source.semantic !== undefined ? styles.semanticColor(source.semantic) : undefined
|
||||
return semantic ?? source.scopes.map(scope => styles.scopeColor(scope)).find(color => color !== undefined) ?? EDITOR_FOREGROUND
|
||||
}
|
||||
|
||||
function colorsOf<K extends string>(styles: TokenStyles | undefined, sources: Record<K, ColorSource>): Record<K, string> {
|
||||
return Object.fromEntries(Object.entries<ColorSource>(sources).map(([name, source]) => [name, colorOf(styles, source)])) as Record<K, string>
|
||||
}
|
||||
|
||||
/** 按对照表从 styles 取代码配色;没有 styles(主题读不出来)时全是编辑器前景色。 */
|
||||
export function codeColorsOf(styles: TokenStyles | undefined): CodeColors {
|
||||
return { roles: colorsOf(styles, ROLE_SOURCES), kinds: colorsOf(styles, KIND_SOURCES) }
|
||||
}
|
||||
|
||||
/** 读当前颜色主题的文件,叠上用户设置里改的代码配色,取代码配色。主题找不到、读不出来时记一条警告,全用编辑器前景色。 */
|
||||
export async function readCodeColors(): Promise<CodeColors> {
|
||||
const theme = currentTheme()
|
||||
const warn = (reason: string): CodeColors => {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `代码配色用编辑器前景色:颜色主题「${theme.name}」${reason}` })
|
||||
return codeColorsOf(undefined)
|
||||
}
|
||||
if (theme.file === undefined)
|
||||
return warn('在装着的扩展里找不到')
|
||||
try {
|
||||
const tokens = await readThemeTokens(theme.file)
|
||||
const editor = workspace.getConfiguration('editor')
|
||||
return codeColorsOf(new TokenStyles(theme.name, tokens, {
|
||||
tokenColors: editor.get('tokenColorCustomizations'),
|
||||
semanticTokenColors: editor.get('semanticTokenColorCustomizations'),
|
||||
semanticHighlighting: editor.get('semanticHighlighting.enabled'),
|
||||
}))
|
||||
}
|
||||
catch (err) {
|
||||
return warn(`的主题文件读不出来:${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 改了会让当前主题、代码配色变的设置 */
|
||||
const THEME_SETTINGS = [
|
||||
'workbench.colorTheme',
|
||||
'workbench.preferredDarkColorTheme',
|
||||
'workbench.preferredLightColorTheme',
|
||||
'workbench.preferredHighContrastColorTheme',
|
||||
'workbench.preferredHighContrastLightColorTheme',
|
||||
'window.autoDetectColorScheme',
|
||||
'window.autoDetectHighContrast',
|
||||
'editor.tokenColorCustomizations',
|
||||
'editor.semanticTokenColorCustomizations',
|
||||
'editor.semanticHighlighting',
|
||||
]
|
||||
|
||||
/**
|
||||
* 所有预览共用的一份代码配色:头一回要时读,之后换了颜色主题、改了上面这些设置就作废重读,读好了发给订阅的。
|
||||
* 接连变了几次时只发最后读好的那份,先前读的过时了不发。
|
||||
*/
|
||||
export class ThemeColors implements Disposable {
|
||||
private colors: Promise<CodeColors> | undefined
|
||||
private readonly listeners = new Set<(colors: CodeColors) => void>()
|
||||
private readonly disposables: Disposable[]
|
||||
|
||||
constructor(private readonly read: () => Promise<CodeColors>) {
|
||||
this.disposables = [
|
||||
window.onDidChangeActiveColorTheme(() => this.reread()),
|
||||
workspace.onDidChangeConfiguration((e) => {
|
||||
if (THEME_SETTINGS.some(key => e.affectsConfiguration(key)))
|
||||
this.reread()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/** 眼下的配色:正在重读时等最新的那份读好。 */
|
||||
async current(): Promise<CodeColors> {
|
||||
let pending: Promise<CodeColors>
|
||||
let colors: CodeColors
|
||||
do {
|
||||
pending = this.colors ??= this.read()
|
||||
colors = await pending
|
||||
} while (pending !== this.colors)
|
||||
return colors
|
||||
}
|
||||
|
||||
/** 重读好了就调 listener,带上新的配色。 */
|
||||
subscribe(listener: (colors: CodeColors) => void): Disposable {
|
||||
this.listeners.add(listener)
|
||||
return { dispose: () => this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.forEach(disposable => disposable.dispose())
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
private reread(): void {
|
||||
const pending = this.colors = this.read()
|
||||
void pending.then((colors) => {
|
||||
if (pending === this.colors)
|
||||
this.listeners.forEach(listener => listener(colors))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { currentTheme } from './currentTheme'
|
||||
|
||||
/** VS Code 那头:用户设置、设置的默认值、当前主题是哪一类(1 亮、2 暗、3 高对比暗、4 高对比亮)、装着的扩展 */
|
||||
const host = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
defaults: {} as Record<string, unknown>,
|
||||
kind: 2,
|
||||
extensions: [] as unknown[],
|
||||
}))
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 },
|
||||
window: { get activeColorTheme() { return { kind: host.kind } } },
|
||||
extensions: { get all() { return host.extensions } },
|
||||
workspace: {
|
||||
getConfiguration: () => ({
|
||||
get: (key: string) => host.settings[key] ?? host.defaults[key],
|
||||
inspect: (key: string) => ({ key, defaultValue: host.defaults[key] }),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
function extension(extensionPath: string, themes: unknown[]): unknown {
|
||||
return { extensionPath, packageJSON: { contributes: { themes } } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host.settings = {}
|
||||
host.defaults = {
|
||||
'workbench.colorTheme': 'Dark 2026',
|
||||
'workbench.preferredDarkColorTheme': 'Dark 2026',
|
||||
'workbench.preferredLightColorTheme': 'Light 2026',
|
||||
'workbench.preferredHighContrastColorTheme': 'Default High Contrast',
|
||||
'workbench.preferredHighContrastLightColorTheme': 'Default High Contrast Light',
|
||||
'window.autoDetectColorScheme': false,
|
||||
'window.autoDetectHighContrast': true,
|
||||
}
|
||||
host.kind = 2
|
||||
host.extensions = []
|
||||
})
|
||||
|
||||
describe('当前用的是哪个主题(照 VS Code 的 themeConfiguration.ts)', () => {
|
||||
it('没开自动跟随系统:就是 workbench.colorTheme', () => {
|
||||
host.settings = { 'workbench.colorTheme': 'Gerry Oceanic', 'workbench.preferredDarkColorTheme': 'Dark+' }
|
||||
expect(currentTheme().name).toBe('Gerry Oceanic')
|
||||
})
|
||||
|
||||
it('开了 window.autoDetectColorScheme:当前主题是暗色取 preferredDarkColorTheme,亮色取 preferredLightColorTheme', () => {
|
||||
host.settings = { 'window.autoDetectColorScheme': true, 'workbench.colorTheme': 'Gerry Oceanic', 'workbench.preferredDarkColorTheme': 'Dark+', 'workbench.preferredLightColorTheme': 'Light+' }
|
||||
expect(currentTheme().name).toBe('Dark+')
|
||||
host.kind = 1
|
||||
expect(currentTheme().name).toBe('Light+')
|
||||
})
|
||||
|
||||
it('高对比:开着 window.autoDetectHighContrast 取两个高对比的;关了按明暗走', () => {
|
||||
host.settings = { 'window.autoDetectColorScheme': true, 'workbench.preferredDarkColorTheme': 'Dark+', 'workbench.preferredLightColorTheme': 'Light+' }
|
||||
host.kind = 3
|
||||
expect(currentTheme().name).toBe('Default High Contrast')
|
||||
host.kind = 4
|
||||
expect(currentTheme().name).toBe('Default High Contrast Light')
|
||||
host.settings['window.autoDetectHighContrast'] = false
|
||||
expect(currentTheme().name).toBe('Light+')
|
||||
})
|
||||
|
||||
it('设置里写的老名字照 VS Code 换成现在的:Default Dark+ 是 Dark+,VS Code Light 是默认的亮色主题', () => {
|
||||
host.settings = { 'workbench.colorTheme': 'Default Dark+' }
|
||||
expect(currentTheme().name).toBe('Dark+')
|
||||
host.settings = { 'workbench.colorTheme': 'VS Code Light' }
|
||||
expect(currentTheme().name).toBe('Light 2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('在装着的扩展贡献的主题里找主题文件', () => {
|
||||
it('设置里的名字对的是 id,没有 id 对 label,再没有对文件名;路径从扩展目录算起', () => {
|
||||
host.extensions = [
|
||||
{ extensionPath: '/ext/plain', packageJSON: {} },
|
||||
extension('/ext/defaults', [{ id: 'Dark+', label: '%darkPlus%', path: './themes/dark_plus.json' }]),
|
||||
extension('/ext/gerry', [{ label: 'Gerry Oceanic', path: './gerryOceanic.json' }, { path: './themes/Nameless.tmTheme' }]),
|
||||
]
|
||||
|
||||
host.settings = { 'workbench.colorTheme': 'Dark+' }
|
||||
expect(currentTheme()).toEqual({ name: 'Dark+', file: path.join('/ext/defaults', 'themes/dark_plus.json') })
|
||||
host.settings = { 'workbench.colorTheme': 'Gerry Oceanic' }
|
||||
expect(currentTheme()).toEqual({ name: 'Gerry Oceanic', file: path.join('/ext/gerry', 'gerryOceanic.json') })
|
||||
host.settings = { 'workbench.colorTheme': 'Nameless.tmTheme' }
|
||||
expect(currentTheme().file).toBe(path.join('/ext/gerry', 'themes/Nameless.tmTheme'))
|
||||
host.settings = { 'workbench.colorTheme': '%darkPlus%' }
|
||||
expect(currentTheme()).toEqual({ name: '%darkPlus%' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { WorkspaceConfiguration } from 'vscode'
|
||||
import * as path from 'node:path'
|
||||
import { ColorThemeKind, extensions, window, workspace } from 'vscode'
|
||||
|
||||
/** 当前正在用的颜色主题:设置里写的名字,和它的主题文件;装着的扩展里找不到这个主题时没有 file */
|
||||
export interface CurrentTheme {
|
||||
name: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题名写在哪个设置里(themeConfiguration.ts 的 getColorThemeSettingId):开着 window.autoDetectHighContrast、系统是高对比时取两个高对比的;
|
||||
* 开着 window.autoDetectColorScheme 时按系统明暗取 preferredDark / preferredLight;都没有就是 workbench.colorTheme。
|
||||
* 扩展看不到系统的明暗,按当前主题是哪一类算(VS Code 正是按系统明暗挑的这个主题)。
|
||||
*/
|
||||
function themeSettingKey(kind: ColorThemeKind, config: WorkspaceConfiguration): string {
|
||||
const light = kind === ColorThemeKind.Light || kind === ColorThemeKind.HighContrastLight
|
||||
const highContrast = kind === ColorThemeKind.HighContrast || kind === ColorThemeKind.HighContrastLight
|
||||
if (highContrast && config.get<boolean>('window.autoDetectHighContrast'))
|
||||
return light ? 'workbench.preferredHighContrastLightColorTheme' : 'workbench.preferredHighContrastColorTheme'
|
||||
if (config.get<boolean>('window.autoDetectColorScheme'))
|
||||
return light ? 'workbench.preferredLightColorTheme' : 'workbench.preferredDarkColorTheme'
|
||||
return 'workbench.colorTheme'
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置里写的老名字换成现在的(workbenchThemeService.ts 的 migrateThemeSettingsId):内置主题去掉了 Default 前缀,
|
||||
* 早先的 Experimental / VS Code 两个主题换成默认的暗色、亮色主题(就是 preferredDark / preferredLight 两个设置的默认值)。
|
||||
*/
|
||||
function migratedName(name: string, config: WorkspaceConfiguration): string {
|
||||
switch (name) {
|
||||
case 'Default Dark Modern':
|
||||
return 'Dark Modern'
|
||||
case 'Default Light Modern':
|
||||
return 'Light Modern'
|
||||
case 'Default Dark+':
|
||||
return 'Dark+'
|
||||
case 'Default Light+':
|
||||
return 'Light+'
|
||||
case 'Experimental Dark':
|
||||
case 'VS Code Dark':
|
||||
return config.inspect<string>('workbench.preferredDarkColorTheme')?.defaultValue ?? name
|
||||
case 'Experimental Light':
|
||||
case 'VS Code Light':
|
||||
return config.inspect<string>('workbench.preferredLightColorTheme')?.defaultValue ?? name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* 装着的扩展(内置的也在里面)贡献的主题里设置名是 name 的那个的主题文件。设置名照 VS Code(colorThemeData.ts 的 fromExtensionTheme):
|
||||
* 是主题的 id,没有 id 用 label,再没有用文件名。
|
||||
*/
|
||||
function themeFile(name: string): string | undefined {
|
||||
for (const extension of extensions.all) {
|
||||
const packageJSON: unknown = extension.packageJSON
|
||||
const themes = isRecord(packageJSON) && isRecord(packageJSON.contributes) ? packageJSON.contributes.themes : undefined
|
||||
if (!Array.isArray(themes))
|
||||
continue
|
||||
for (const theme of themes) {
|
||||
if (!isRecord(theme) || typeof theme.path !== 'string')
|
||||
continue
|
||||
const settingsName = theme.id || theme.label || path.basename(theme.path)
|
||||
if (settingsName === name)
|
||||
return path.join(extension.extensionPath, theme.path)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 照 VS Code 自己的规则定当前正在用的颜色主题,找到它的主题文件。 */
|
||||
export function currentTheme(): CurrentTheme {
|
||||
const config = workspace.getConfiguration()
|
||||
const name = migratedName(config.get<string>(themeSettingKey(window.activeColorTheme.kind, config)) ?? '', config)
|
||||
const file = themeFile(name)
|
||||
return { name, ...(file !== undefined && { file }) }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readThemeTokens } from './themeFile'
|
||||
|
||||
const THEMES = path.join(__dirname, '../fixtures/themes')
|
||||
|
||||
function rule(scope: string, foreground: string): unknown {
|
||||
return { scope, settings: { foreground } }
|
||||
}
|
||||
|
||||
describe('读颜色主题文件', () => {
|
||||
it('主题 JSON 带注释;include 的路径从这个文件所在的目录算起,被 include 的规则在前、自己的在后;有一个说开语义上色就开', async () => {
|
||||
const tokens = await readThemeTokens(path.join(THEMES, 'nested/child.json'))
|
||||
|
||||
expect(tokens.textMateRules).toEqual([rule('keyword', '#111111'), rule('string', '#222222'), rule('string', '#444444')])
|
||||
expect(tokens.semanticRules).toEqual([['function', '#333333'], ['function', { foreground: '#555555' }]])
|
||||
expect(tokens.semanticHighlighting).toBe(true)
|
||||
})
|
||||
|
||||
it('tokenColors 可以是指向 .tmTheme 的路径;主题本身也可以是 .tmTheme,规则照 settings 原样读,没有范围的全局设置也留着', async () => {
|
||||
const global = { settings: { background: '#000000', foreground: '#FFFFFF' } }
|
||||
const comment = { name: 'Comment', scope: 'comment', settings: { foreground: '#666666' } }
|
||||
const control = { name: 'Control', scope: 'keyword.control, storage', settings: { foreground: '#777777' } }
|
||||
|
||||
expect(await readThemeTokens(path.join(THEMES, 'split.json'))).toEqual({ textMateRules: [global, comment, control], semanticHighlighting: false, semanticRules: [] })
|
||||
expect((await readThemeTokens(path.join(THEMES, 'classic.tmTheme'))).textMateRules).toEqual([global, comment, control])
|
||||
})
|
||||
|
||||
it('读不到、不是合法的 JSON、不是对象、.tmTheme 的 settings 不是数组时报错', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'theme-'))
|
||||
await writeFile(path.join(dir, 'broken.json'), '{ "tokenColors": [')
|
||||
await writeFile(path.join(dir, 'list.json'), '[]')
|
||||
|
||||
await expect(readThemeTokens(path.join(THEMES, 'missing.json'))).rejects.toThrow()
|
||||
await expect(readThemeTokens(path.join(dir, 'broken.json'))).rejects.toThrow('broken.json')
|
||||
await expect(readThemeTokens(path.join(dir, 'list.json'))).rejects.toThrow('list.json')
|
||||
await expect(readThemeTokens(path.join(THEMES, 'noSettings.tmTheme'))).rejects.toThrow('noSettings.tmTheme')
|
||||
})
|
||||
|
||||
it('主题 JSON 里的尾逗号照 VS Code 允许', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'theme-'))
|
||||
await writeFile(path.join(dir, 'trailing.json'), '{ "tokenColors": [{ "scope": "string", "settings": { "foreground": "#123456" } },], }')
|
||||
|
||||
expect((await readThemeTokens(path.join(dir, 'trailing.json'))).textMateRules).toEqual([rule('string', '#123456')])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ParseError } from 'jsonc-parser'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { parse, printParseErrorCode } from 'jsonc-parser'
|
||||
|
||||
/**
|
||||
* 一个颜色主题里跟代码上色有关的部分,按 VS Code 读主题的顺序排(colorThemeData.ts 的 _loadColorTheme):
|
||||
* include 进来的在前、自己的在后,后面的盖前面的。
|
||||
*/
|
||||
export interface ThemeTokens {
|
||||
/** TextMate 上色规则(tokenColors、.tmTheme 的 settings),原样留着,怎么认见 tokenStyles.ts */
|
||||
textMateRules: unknown[]
|
||||
/** 主题自己说开不开语义上色:include 链上有一个说开就开 */
|
||||
semanticHighlighting: boolean
|
||||
/** 语义上色规则(semanticTokenColors):选择器和它的值 */
|
||||
semanticRules: [string, unknown][]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** 读 .tmTheme(plist):settings 就是它的 TextMate 规则,没有范围的那几条是全局设置,照 VS Code 也留着 */
|
||||
async function readTmTheme(file: string, into: ThemeTokens): Promise<void> {
|
||||
// plist 只发了 ES 模块,扩展是 CommonJS,只能动态导入
|
||||
const { parse: parsePlist } = await import('plist')
|
||||
const theme = parsePlist(await readFile(file, 'utf8'))
|
||||
if (!isRecord(theme) || !Array.isArray(theme.settings))
|
||||
throw new Error(`${file} 里的 settings 不是数组`)
|
||||
into.textMateRules.push(...theme.settings)
|
||||
}
|
||||
|
||||
async function readInto(file: string, into: ThemeTokens): Promise<void> {
|
||||
if (path.extname(file) !== '.json')
|
||||
return readTmTheme(file, into)
|
||||
const errors: ParseError[] = []
|
||||
const theme: unknown = parse(await readFile(file, 'utf8'), errors, { allowTrailingComma: true })
|
||||
if (errors.length > 0)
|
||||
throw new Error(`${file} 不是合法的 JSON:${errors.map(error => printParseErrorCode(error.error)).join('、')}`)
|
||||
if (!isRecord(theme))
|
||||
throw new Error(`${file} 不是 JSON 对象`)
|
||||
if (typeof theme.include === 'string')
|
||||
await readInto(path.join(path.dirname(file), theme.include), into)
|
||||
// 老格式:整个文件就是 .tmTheme 的 settings,别的不看
|
||||
if (Array.isArray(theme.settings)) {
|
||||
into.textMateRules.push(...theme.settings)
|
||||
return
|
||||
}
|
||||
into.semanticHighlighting ||= Boolean(theme.semanticHighlighting)
|
||||
const tokenColors = theme.tokenColors
|
||||
if (Array.isArray(tokenColors))
|
||||
into.textMateRules.push(...tokenColors)
|
||||
else if (typeof tokenColors === 'string')
|
||||
await readTmTheme(path.join(path.dirname(file), tokenColors), into)
|
||||
else if (tokenColors)
|
||||
throw new Error(`${file} 的 tokenColors 既不是规则数组,也不是 .tmTheme 的路径`)
|
||||
if (isRecord(theme.semanticTokenColors))
|
||||
into.semanticRules.push(...Object.entries(theme.semanticTokenColors))
|
||||
}
|
||||
|
||||
/** 读 file 这个颜色主题(JSON 或 .tmTheme)连同它 include 的;读不到、格式不对时抛错。 */
|
||||
export async function readThemeTokens(file: string): Promise<ThemeTokens> {
|
||||
const tokens: ThemeTokens = { textMateRules: [], semanticHighlighting: false, semanticRules: [] }
|
||||
await readInto(file, tokens)
|
||||
return tokens
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ThemeTokens } from './themeFile'
|
||||
import type { ColorCustomizations } from './tokenStyles'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TokenStyles } from './tokenStyles'
|
||||
|
||||
function theme(textMateRules: unknown[], extra: Partial<ThemeTokens> = {}): ThemeTokens {
|
||||
return { textMateRules, semanticHighlighting: false, semanticRules: [], ...extra }
|
||||
}
|
||||
|
||||
const NO_CUSTOM: ColorCustomizations = { tokenColors: undefined, semanticTokenColors: undefined, semanticHighlighting: undefined }
|
||||
|
||||
function rule(scope: unknown, foreground: string): unknown {
|
||||
return { scope, settings: { foreground } }
|
||||
}
|
||||
|
||||
describe('按 TextMate 范围取色(同 vscode-textmate)', () => {
|
||||
it('规则的范围按点分段是查的范围的前缀才算;段数多的胜,一样具体时后写的胜;只给字体样式的规则不改颜色', () => {
|
||||
const styles = new TokenStyles('T', theme([
|
||||
rule('keyword', '#111111'),
|
||||
rule('keyword.control', '#222222'),
|
||||
rule('keyword.control', '#333333'),
|
||||
rule('keyword.con', '#444444'),
|
||||
rule('keyword.control.flow.python', '#555555'),
|
||||
{ scope: 'keyword.control.loop', settings: { fontStyle: 'italic' } },
|
||||
]), NO_CUSTOM)
|
||||
|
||||
expect(styles.scopeColor('keyword.control.conditional')).toBe('#333333')
|
||||
expect(styles.scopeColor('keyword.control.loop')).toBe('#333333')
|
||||
expect(styles.scopeColor('keyword.control.flow')).toBe('#333333')
|
||||
expect(styles.scopeColor('keyword.other')).toBe('#111111')
|
||||
expect(styles.scopeColor('keywords')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('逗号分隔的选择器拆开;数组里的每一项各是一个选择器', () => {
|
||||
const styles = new TokenStyles('T', theme([
|
||||
rule(',string , comment,', '#123456'),
|
||||
rule(['entity.name.function', 'support.function'], '#654321'),
|
||||
]), NO_CUSTOM)
|
||||
|
||||
expect(styles.scopeColor('string.quoted')).toBe('#123456')
|
||||
expect(styles.scopeColor('comment.line')).toBe('#123456')
|
||||
expect(styles.scopeColor('support.function.builtin')).toBe('#654321')
|
||||
})
|
||||
|
||||
it('带父级的选择器(空格分隔)对单独一个范围的查询不生效', () => {
|
||||
const styles = new TokenStyles('T', theme([
|
||||
rule('keyword', '#00FF00'),
|
||||
rule('source.python keyword.control', '#FF0000'),
|
||||
]), NO_CUSTOM)
|
||||
|
||||
expect(styles.scopeColor('keyword.control')).toBe('#00FF00')
|
||||
})
|
||||
|
||||
it('不是十六进制色的、没有范围的规则不算;颜色统一成大写、补全简写、去掉不透明的 FF', () => {
|
||||
const styles = new TokenStyles('T', theme([
|
||||
{ settings: { foreground: '#FFFFFF' } },
|
||||
rule('string', 'red'),
|
||||
rule('comment', '#abcf'),
|
||||
rule('constant', '#12345678'),
|
||||
rule('variable', '#123456ff'),
|
||||
]), NO_CUSTOM)
|
||||
|
||||
expect(styles.scopeColor('string')).toBeUndefined()
|
||||
expect(styles.scopeColor('comment')).toBe('#AABBCC')
|
||||
expect(styles.scopeColor('constant')).toBe('#12345678')
|
||||
expect(styles.scopeColor('variable')).toBe('#123456')
|
||||
})
|
||||
})
|
||||
|
||||
describe('叠用户设置 editor.tokenColorCustomizations', () => {
|
||||
const THEME = theme([
|
||||
rule('entity.name.function', '#AAAAAA'),
|
||||
rule('string', '#BBBBBB'),
|
||||
rule('comment', '#CCCCCC'),
|
||||
rule('keyword', '#DDDDDD'),
|
||||
])
|
||||
|
||||
it('全局的几组颜色和 textMateRules 排在主题的后面、盖过主题;这个主题的专属块再排在后面;别的主题的专属块不管', () => {
|
||||
const styles = new TokenStyles('Gerry Oceanic', THEME, {
|
||||
...NO_CUSTOM,
|
||||
tokenColors: {
|
||||
'functions': '#000001',
|
||||
'comments': { foreground: '#000002', fontStyle: 'italic' },
|
||||
'textMateRules': [rule('string', '#000003')],
|
||||
'[Gerry Oceanic]': { strings: '#000004', textMateRules: [rule('string.quoted', '#000005')] },
|
||||
'[Dark+]': { functions: '#999999' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(styles.scopeColor('entity.name.function')).toBe('#000001')
|
||||
expect(styles.scopeColor('support.function')).toBe('#000001')
|
||||
expect(styles.scopeColor('comment.line')).toBe('#000002')
|
||||
expect(styles.scopeColor('string')).toBe('#000004')
|
||||
expect(styles.scopeColor('string.quoted.double')).toBe('#000005')
|
||||
})
|
||||
|
||||
it('专属块的主题名可以用 * 通配开头、结尾或两头,几个主题可以写在一个键里;几块都对上时数组接起来、别的后面的盖前面的', () => {
|
||||
const custom = (key: string): ColorCustomizations => ({ ...NO_CUSTOM, tokenColors: { [key]: { functions: '#000001' } } })
|
||||
for (const key of ['[Gerry*]', '[*Oceanic]', '[*rry Oce*]', '[Dark+][Gerry Oceanic]'])
|
||||
expect(new TokenStyles('Gerry Oceanic', THEME, custom(key)).scopeColor('entity.name.function'), key).toBe('#000001')
|
||||
expect(new TokenStyles('Gerry Oceanic', THEME, custom('[Gerry]')).scopeColor('entity.name.function')).toBe('#AAAAAA')
|
||||
|
||||
const merged = new TokenStyles('Gerry Oceanic', THEME, {
|
||||
...NO_CUSTOM,
|
||||
tokenColors: {
|
||||
'[Gerry*]': { functions: '#000001', textMateRules: [rule('string', '#000002')] },
|
||||
'[*Oceanic]': { functions: '#000003', textMateRules: [rule('comment', '#000004')] },
|
||||
},
|
||||
})
|
||||
expect(merged.scopeColor('entity.name.function')).toBe('#000003')
|
||||
expect(merged.scopeColor('string')).toBe('#000002')
|
||||
expect(merged.scopeColor('comment')).toBe('#000004')
|
||||
})
|
||||
|
||||
it('keywords 那组里的 `keyword - keyword.operator` 是带排除的选择器,对单独一个范围的查询不生效,同组别的范围照常', () => {
|
||||
const styles = new TokenStyles('T', THEME, { ...NO_CUSTOM, tokenColors: { keywords: '#000001' } })
|
||||
|
||||
expect(styles.scopeColor('keyword.control.flow')).toBe('#000001')
|
||||
expect(styles.scopeColor('storage.type')).toBe('#000001')
|
||||
expect(styles.scopeColor('keyword.other')).toBe('#DDDDDD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('语义上色', () => {
|
||||
it('开没开:设置给了 true / false 就按设置;configuredByTheme 时先看 semanticTokenColorCustomizations.enabled(专属块优先),再看老的 tokenColorCustomizations.semanticHighlighting,最后看主题', () => {
|
||||
const on = theme([], { semanticHighlighting: true })
|
||||
const off = theme([])
|
||||
|
||||
expect(new TokenStyles('T', on, NO_CUSTOM).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', on, { ...NO_CUSTOM, semanticHighlighting: 'configuredByTheme' }).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', off, NO_CUSTOM).semanticHighlighting).toBe(false)
|
||||
expect(new TokenStyles('T', on, { ...NO_CUSTOM, semanticHighlighting: false }).semanticHighlighting).toBe(false)
|
||||
expect(new TokenStyles('T', off, { ...NO_CUSTOM, semanticHighlighting: true, semanticTokenColors: { enabled: false } }).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', off, { ...NO_CUSTOM, semanticTokenColors: { enabled: true } }).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', off, { ...NO_CUSTOM, semanticTokenColors: { 'enabled': false, '[T]': { enabled: true } } }).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', off, { ...NO_CUSTOM, tokenColors: { semanticHighlighting: true } }).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', off, { ...NO_CUSTOM, tokenColors: { 'semanticHighlighting': false, '[T]': { semanticHighlighting: true } } }).semanticHighlighting).toBe(true)
|
||||
// 照 VS Code:专属块合起来时 false 这类假值被跳过,专属块里写 false 不起作用
|
||||
expect(new TokenStyles('T', on, { ...NO_CUSTOM, semanticTokenColors: { '[T]': { enabled: false } } }).semanticHighlighting).toBe(true)
|
||||
expect(new TokenStyles('T', off, { ...NO_CUSTOM, tokenColors: { semanticHighlighting: false }, semanticTokenColors: { enabled: true } }).semanticHighlighting).toBe(true)
|
||||
})
|
||||
|
||||
it('语义规则:主题的在前,用户的 rules、专属块的 rules 在后;修饰词越多越具体,一样具体时后写的胜;带语言的、只给字体样式的不算', () => {
|
||||
const styles = new TokenStyles('T', theme([], {
|
||||
semanticRules: [
|
||||
['function', '#000001'],
|
||||
['function', { fontStyle: 'bold' }],
|
||||
['variable', '#000002'],
|
||||
['*.readonly', '#000003'],
|
||||
['class:python', '#000009'],
|
||||
],
|
||||
}), {
|
||||
...NO_CUSTOM,
|
||||
semanticTokenColors: {
|
||||
'rules': { variable: { foreground: '#000004' } },
|
||||
'[T]': { rules: { 'variable.readonly': '#000005' } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(styles.semanticColor('function')).toBe('#000001')
|
||||
expect(styles.semanticColor('variable')).toBe('#000004')
|
||||
expect(styles.semanticColor('variable.readonly')).toBe('#000005')
|
||||
expect(styles.semanticColor('class')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { ThemeTokens } from './themeFile'
|
||||
|
||||
/**
|
||||
* 用户设置里改的代码配色,原样从设置里读来:editor.tokenColorCustomizations、editor.semanticTokenColorCustomizations、
|
||||
* editor.semanticHighlighting.enabled(true / false / configuredByTheme)。
|
||||
*/
|
||||
export interface ColorCustomizations {
|
||||
tokenColors: unknown
|
||||
semanticTokenColors: unknown
|
||||
semanticHighlighting: unknown
|
||||
}
|
||||
|
||||
/** editor.tokenColorCustomizations 里几组颜色各管哪些范围(colorThemeData.ts 的 tokenGroupToScopesMap) */
|
||||
const TOKEN_GROUP_SCOPES: Record<string, string[]> = {
|
||||
comments: ['comment', 'punctuation.definition.comment'],
|
||||
strings: ['string', 'meta.embedded.assembly'],
|
||||
keywords: ['keyword - keyword.operator', 'keyword.control', 'storage', 'storage.type'],
|
||||
numbers: ['constant.numeric'],
|
||||
types: ['entity.name.type', 'entity.name.class', 'support.type', 'support.class'],
|
||||
functions: ['entity.name.function', 'support.function'],
|
||||
variables: ['variable', 'entity.name.variable'],
|
||||
}
|
||||
|
||||
/** 一条只有一个范围、不带父级的 TextMate 规则:范围,前景色 */
|
||||
interface ScopeRule { scope: string, foreground: string }
|
||||
|
||||
/** 一条有前景色的语义规则:选择器(`类型.修饰.修饰:语言`),前景色 */
|
||||
interface SemanticRule { selector: string, foreground: string }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** 颜色照 VS Code 规整(colorThemeData.ts 的 normalizeColor):#RGB、#RGBA、#RRGGBB、#RRGGBBAA 统一成大写的六位或八位,不透明的去掉 FF;别的写法不算颜色。 */
|
||||
function normalizeColor(color: unknown): string | undefined {
|
||||
if (typeof color !== 'string' || !/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color))
|
||||
return undefined
|
||||
const hex = (color.length <= 5 ? [...color.slice(1)].map(digit => digit + digit).join('') : color.slice(1)).toUpperCase()
|
||||
return `#${hex.length === 8 && hex.endsWith('FF') ? hex.slice(0, 6) : hex}`
|
||||
}
|
||||
|
||||
/** 设置里的键是不是专属某几个主题的块(`"[主题名]"`) */
|
||||
function isThemeScope(key: string): boolean {
|
||||
return key.startsWith('[') && key.endsWith(']')
|
||||
}
|
||||
|
||||
/** 专属块写的主题名对不对得上 themeName:可以用 * 通配开头、结尾或两头(colorThemeData.ts 的 isThemeScopeMatch) */
|
||||
function themeMatches(pattern: string, themeName: string): boolean {
|
||||
const wildStart = pattern.startsWith('*')
|
||||
const wildEnd = pattern.endsWith('*')
|
||||
return pattern === themeName
|
||||
|| (wildStart && wildEnd && themeName.includes(pattern.slice(1, -1)))
|
||||
|| (wildEnd && themeName.startsWith(pattern.slice(0, -1)))
|
||||
|| (wildStart && themeName.endsWith(pattern.slice(1)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置里专属 themeName 的那几块合成一块(colorThemeData.ts 的 getThemeSpecificColors):一个键里能写几个主题(`"[A][B]"`),
|
||||
* 几块都对上时数组接起来,别的后面的盖前面的。一块都没对上是 undefined。
|
||||
*/
|
||||
function themeScoped(settings: Record<string, unknown>, themeName: string): Record<string, unknown> | undefined {
|
||||
let merged: Record<string, unknown> | undefined
|
||||
for (const [key, block] of Object.entries(settings)) {
|
||||
if (!isThemeScope(key) || !isRecord(block))
|
||||
continue
|
||||
for (const [, pattern] of key.matchAll(/\[(.+?)\]/g)) {
|
||||
if (!themeMatches(pattern!, themeName))
|
||||
continue
|
||||
merged ??= {}
|
||||
for (const [name, value] of Object.entries(block)) {
|
||||
const earlier = merged[name]
|
||||
if (Array.isArray(earlier) && Array.isArray(value))
|
||||
merged[name] = [...earlier, ...value]
|
||||
else if (value)
|
||||
merged[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* editor.tokenColorCustomizations 一块里加的 TextMate 规则(colorThemeData.ts 的 addCustomTokenColors):
|
||||
* 先几组颜色,再 textMateRules,好让 textMateRules 里更具体的盖过几组颜色。
|
||||
*/
|
||||
function customTextMateRules(customizations: Record<string, unknown>): unknown[] {
|
||||
const rules: unknown[] = []
|
||||
for (const [group, scopes] of Object.entries(TOKEN_GROUP_SCOPES)) {
|
||||
const value = customizations[group]
|
||||
if (value) {
|
||||
const settings = typeof value === 'string' ? { foreground: value } : value
|
||||
rules.push(...scopes.map(scope => ({ scope, settings })))
|
||||
}
|
||||
}
|
||||
if (Array.isArray(customizations.textMateRules))
|
||||
rules.push(...customizations.textMateRules)
|
||||
return rules
|
||||
}
|
||||
|
||||
/** editor.semanticTokenColorCustomizations 一块里的 rules;键是专属块的不算 */
|
||||
function customSemanticRules(customizations: Record<string, unknown>): [string, unknown][] {
|
||||
return isRecord(customizations.rules) ? Object.entries(customizations.rules).filter(([key]) => !isThemeScope(key)) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* TextMate 规则按写的先后排好,拆成一个个范围(照 vscode-textmate 的 parseTheme):字符串的范围去掉首尾的逗号、按逗号拆,
|
||||
* 数组的每项各是一个选择器;选择器按空格分段,最后一段是范围、前面的是父级。查的是单独一个范围,带父级的规则不会生效,不留。
|
||||
* 没有范围、前景色不是十六进制色的也不留(VS Code 交给 vscode-textmate 之前就去掉了)。
|
||||
*/
|
||||
function scopeRules(rules: unknown[]): ScopeRule[] {
|
||||
return rules.flatMap((rule) => {
|
||||
if (!isRecord(rule) || !rule.scope || !isRecord(rule.settings))
|
||||
return []
|
||||
const foreground = normalizeColor(rule.settings.foreground)
|
||||
if (!foreground)
|
||||
return []
|
||||
const selectors: unknown[] = typeof rule.scope === 'string' ? rule.scope.replace(/^,+|,+$/g, '').split(',') : Array.isArray(rule.scope) ? rule.scope : []
|
||||
return selectors.flatMap((selector) => {
|
||||
const scope = typeof selector === 'string' ? selector.trim() : ''
|
||||
return scope !== '' && !scope.includes(' ') ? [{ scope, foreground }] : []
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 语义规则的值里的前景色:直接写的颜色,或 `{ foreground }`;只给字体样式的没有 */
|
||||
function semanticForeground(value: unknown): string | undefined {
|
||||
return normalizeColor(isRecord(value) ? value.foreground : value)
|
||||
}
|
||||
|
||||
/** 语义记号选择器拆成类型、修饰词、语言:`类型.修饰.修饰:语言`(tokenClassificationRegistry.ts 的 parseClassifierString) */
|
||||
function parseClassifier(selector: string): { type: string, modifiers: string[], language?: string } {
|
||||
let end = selector.length
|
||||
let language: string | undefined
|
||||
const modifiers: string[] = []
|
||||
for (let i = end - 1; i >= 0; i--) {
|
||||
if (selector[i] !== ':' && selector[i] !== '.')
|
||||
continue
|
||||
const segment = selector.slice(i + 1, end)
|
||||
if (selector[i] === ':')
|
||||
language = segment
|
||||
else
|
||||
modifiers.push(segment)
|
||||
end = i
|
||||
}
|
||||
return { type: selector.slice(0, end), modifiers, ...(language !== undefined && { language }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 语义规则的选择器对这种记号有多具体(tokenClassificationRegistry.ts 的 parseTokenSelector):对不上是 -1;
|
||||
* 类型对上了(不是 * 通配)加 100,选择器写的修饰词这种记号都得有,每个加 100。
|
||||
* 配色所有预览共用、不分语言,带语言的规则不算;查的都是没有父类型的基本类型,类型层级用不上。
|
||||
*/
|
||||
function selectorScore(selector: string, token: { type: string, modifiers: string[] }): number {
|
||||
const { type, modifiers, language } = parseClassifier(selector)
|
||||
if (type === '' || language !== undefined || (type !== '*' && type !== token.type) || modifiers.some(modifier => !token.modifiers.includes(modifier)))
|
||||
return -1
|
||||
return (type === '*' ? 0 : 100) + modifiers.length * 100
|
||||
}
|
||||
|
||||
/** 几个设置值里最后一个写了 true / false 的 */
|
||||
function lastBoolean(...values: unknown[]): boolean | undefined {
|
||||
return values.filter((value): value is boolean => typeof value === 'boolean').at(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个颜色主题叠上用户设置之后,代码里的记号是什么颜色。规则和先后照 VS Code(colorThemeData.ts):
|
||||
* 主题的规则在前,用户设置里全局的在后,专属这个主题的块再在后;同样具体时后写的胜。
|
||||
* 只管前景色,字体样式不管。
|
||||
*/
|
||||
export class TokenStyles {
|
||||
/**
|
||||
* 编辑器开没开语义上色(semanticTokensConfig.ts 的 isSemanticColoringEnabled):editor.semanticHighlighting.enabled 给了 true / false 就按它;
|
||||
* 否则先看 semanticTokenColorCustomizations.enabled,再看老的 tokenColorCustomizations.semanticHighlighting(都是专属块优先),最后看主题
|
||||
*/
|
||||
readonly semanticHighlighting: boolean
|
||||
private readonly scopeRules: ScopeRule[]
|
||||
private readonly semanticRules: SemanticRule[]
|
||||
|
||||
/** themeName:设置里写的主题名,专属块按它对 */
|
||||
constructor(themeName: string, theme: ThemeTokens, customizations: ColorCustomizations) {
|
||||
const tokenColors = isRecord(customizations.tokenColors) ? customizations.tokenColors : {}
|
||||
const tokenColorsScoped = themeScoped(tokenColors, themeName) ?? {}
|
||||
const semanticColors = isRecord(customizations.semanticTokenColors) ? customizations.semanticTokenColors : {}
|
||||
const semanticScoped = themeScoped(semanticColors, themeName) ?? {}
|
||||
this.scopeRules = scopeRules([...theme.textMateRules, ...customTextMateRules(tokenColors), ...customTextMateRules(tokenColorsScoped)])
|
||||
this.semanticRules = [...theme.semanticRules, ...customSemanticRules(semanticColors), ...customSemanticRules(semanticScoped)].flatMap(([selector, value]) => {
|
||||
const foreground = semanticForeground(value)
|
||||
return foreground ? [{ selector, foreground }] : []
|
||||
})
|
||||
this.semanticHighlighting = lastBoolean(customizations.semanticHighlighting)
|
||||
?? lastBoolean(semanticColors.enabled, semanticScoped.enabled)
|
||||
?? lastBoolean(tokenColors.semanticHighlighting, tokenColorsScoped.semanticHighlighting)
|
||||
?? theme.semanticHighlighting
|
||||
}
|
||||
|
||||
/**
|
||||
* TextMate 范围 scope 的前景色,照 vscode-textmate 查单独一个范围:规则的范围按点分段是 scope 的前缀才算,
|
||||
* 段数多的胜,一样具体时后写的胜。没有规则配色是 undefined(编辑器用前景色)。
|
||||
*/
|
||||
scopeColor(scope: string): string | undefined {
|
||||
let best: ScopeRule | undefined
|
||||
for (const rule of this.scopeRules) {
|
||||
if ((rule.scope === scope || scope.startsWith(`${rule.scope}.`)) && (!best || rule.scope.length >= best.scope.length))
|
||||
best = rule
|
||||
}
|
||||
return best?.foreground
|
||||
}
|
||||
|
||||
/** 语义记号 classifier(`类型.修饰`)的前景色,只看语义规则:最具体的胜,一样具体时后写的胜。没有规则配色是 undefined。 */
|
||||
semanticColor(classifier: string): string | undefined {
|
||||
const { type, modifiers } = parseClassifier(classifier)
|
||||
let best: { score: number, foreground: string } | undefined
|
||||
for (const rule of this.semanticRules) {
|
||||
const score = selectorScore(rule.selector, { type, modifiers })
|
||||
if (score >= 0 && (!best || score >= best.score))
|
||||
best = { score, foreground: rule.foreground }
|
||||
}
|
||||
return best?.foreground
|
||||
}
|
||||
}
|
||||
@@ -156,3 +156,18 @@ describe('标签页切走再切回来,调用树弹框照旧开着', () => {
|
||||
expect(saved?.callTree).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('代码配色跟着当前颜色主题', () => {
|
||||
it('扩展端发来的配色设成 body 上的变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>;换了主题再发来就换', async () => {
|
||||
const style = document.body.style
|
||||
await load()
|
||||
await deliver({ type: 'codeColors', roles: { keyword: '#C586C0', function: '#DCDCAA' }, kinds: { loop: 'var(--vscode-editor-foreground)' } } as ExtensionToWebview)
|
||||
expect(style.getPropertyValue('--cr-keyword')).toBe('#C586C0')
|
||||
expect(style.getPropertyValue('--cr-function')).toBe('#DCDCAA')
|
||||
expect(style.getPropertyValue('--cr-kind-loop')).toBe('var(--vscode-editor-foreground)')
|
||||
|
||||
await deliver({ type: 'codeColors', roles: { keyword: '#AF00DB', function: '#795E26' }, kinds: { loop: '#AF00DB' } } as ExtensionToWebview)
|
||||
expect(style.getPropertyValue('--cr-keyword')).toBe('#AF00DB')
|
||||
expect(style.getPropertyValue('--cr-kind-loop')).toBe('#AF00DB')
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AskThread, FEATURE_THREAD } from './AskThread'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { CallTreeDialog } from './CallTreeDialog'
|
||||
import { applyCodeColors } from './codeColors'
|
||||
import { DefinitionPeekPopup } from './DefinitionPeekPopup'
|
||||
import { DiffBar, NewFileBanner } from './DiffBar'
|
||||
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
|
||||
@@ -196,6 +197,9 @@ export function App(): ReactElement | null {
|
||||
setKeep(NONE)
|
||||
setOpenedRuns(NONE)
|
||||
return
|
||||
case 'codeColors':
|
||||
applyCodeColors(document.body.style, msg)
|
||||
return
|
||||
case 'diff':
|
||||
setDiff(msg.diff)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CodeColors } from './types'
|
||||
|
||||
/** 扩展端按当前颜色主题算好的代码配色设成 style 上的颜色变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>(codeReader.css 里用)。 */
|
||||
export function applyCodeColors(style: CSSStyleDeclaration, { roles, kinds }: CodeColors): void {
|
||||
for (const [role, color] of Object.entries(roles))
|
||||
style.setProperty(`--cr-${role}`, color)
|
||||
for (const [kind, color] of Object.entries(kinds))
|
||||
style.setProperty(`--cr-kind-${kind}`, color)
|
||||
}
|
||||
@@ -1,33 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 词性与块种类的配色:暗色(含高对比)取 VS Code Dark+,亮色取 Light+。其余界面颜色一律用 --vscode-* 变量。 */
|
||||
/* 词性与块种类的配色(--cr-keyword 这些、--cr-kind-*)由扩展端按当前颜色主题算好发来,设在 body 上(codeColors.ts)。其余界面颜色一律用 --vscode-* 变量。 */
|
||||
body {
|
||||
--cr-keyword: #c586c0;
|
||||
--cr-storage: #569cd6;
|
||||
--cr-variable: #9cdcfe;
|
||||
--cr-function: #dcdcaa;
|
||||
--cr-type: #4ec9b0;
|
||||
--cr-string: #ce9178;
|
||||
--cr-number: #b5cea8;
|
||||
--cr-comment: #6a9955;
|
||||
--cr-constant: #4fc1ff;
|
||||
|
||||
--cr-kind-import: #c586c0;
|
||||
--cr-kind-function: #dcdcaa;
|
||||
--cr-kind-type: #4ec9b0;
|
||||
--cr-kind-enum: #b180d7;
|
||||
--cr-kind-exception: #f14c4c;
|
||||
--cr-kind-data: #9cdcfe;
|
||||
--cr-kind-branch: #ce9178;
|
||||
--cr-kind-loop: #8f9cff;
|
||||
--cr-kind-error: #f48771;
|
||||
--cr-kind-return: #89d185;
|
||||
--cr-kind-statement: #9d9d9d;
|
||||
--cr-kind-comment: #6a9955;
|
||||
--cr-kind-options: #e5c07b;
|
||||
--cr-kind-command: #d7ba7d;
|
||||
--cr-kind-stage: #56b6c2;
|
||||
|
||||
margin: 0;
|
||||
font-family: var(--vscode-font-family, system-ui, sans-serif);
|
||||
font-size: var(--vscode-font-size, 13px);
|
||||
@@ -35,36 +9,8 @@ body {
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
body.vscode-light,
|
||||
body.vscode-high-contrast-light {
|
||||
--cr-keyword: #af00db;
|
||||
--cr-storage: #0000ff;
|
||||
--cr-variable: #001080;
|
||||
--cr-function: #795e26;
|
||||
--cr-type: #267f99;
|
||||
--cr-string: #a31515;
|
||||
--cr-number: #098658;
|
||||
--cr-comment: #008000;
|
||||
--cr-constant: #0070c1;
|
||||
|
||||
--cr-kind-import: #af00db;
|
||||
--cr-kind-function: #795e26;
|
||||
--cr-kind-type: #267f99;
|
||||
--cr-kind-enum: #652d90;
|
||||
--cr-kind-exception: #cd3131;
|
||||
--cr-kind-data: #001080;
|
||||
--cr-kind-branch: #a31515;
|
||||
--cr-kind-loop: #4f56d6;
|
||||
--cr-kind-error: #b5452f;
|
||||
--cr-kind-return: #388a34;
|
||||
--cr-kind-statement: #6f6f6f;
|
||||
--cr-kind-comment: #008000;
|
||||
--cr-kind-options: #9a6a00;
|
||||
--cr-kind-command: #7c5f12;
|
||||
--cr-kind-stage: #127f8c;
|
||||
}
|
||||
|
||||
/* 种类颜色:同一类语义共用一个颜色,图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */
|
||||
/* 种类颜色:同一类语义共用一个颜色(每类查主题的哪个范围见扩展端 theme/codeColors.ts,主题给几类配了同一个色就同色),
|
||||
图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */
|
||||
:is([data-kind="import"], [data-kind="copy"]) { --kind: var(--cr-kind-import); }
|
||||
[data-kind="function"] { --kind: var(--cr-kind-function); }
|
||||
:is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); }
|
||||
|
||||
@@ -464,6 +464,18 @@ export interface StickyScrollSettings {
|
||||
/** 这个标签页是从哪儿打开的:从侧栏「未提交的更改」一栏点开的是 `uncommitted`,别处打开的是 `other`;页面只记着,重启 VS Code 后恢复时报给扩展端。 */
|
||||
export type OpenedFrom = 'uncommitted' | 'other'
|
||||
|
||||
/** 代码上色的几种词性(codeReader.css 的 --cr-<词性>),源码和译文共用;operator 用 storage 的,literal 用 number 的 */
|
||||
export type RoleColor = 'keyword' | 'storage' | 'variable' | 'constant' | 'function' | 'type' | 'string' | 'number' | 'comment'
|
||||
|
||||
/** 卡片的几类种类色(codeReader.css 的 --cr-kind-<种类>):同一类语义共用一个颜色,图标区分具体是哪种 */
|
||||
export type KindColor = 'import' | 'function' | 'type' | 'enum' | 'exception' | 'data' | 'branch' | 'loop' | 'error' | 'return' | 'statement' | 'comment' | 'options' | 'command' | 'stage'
|
||||
|
||||
/** 当前颜色主题给代码的配色,值是 CSS 颜色:主题(连同用户设置)配了的是十六进制色,没配的是编辑器前景色 */
|
||||
export interface CodeColors {
|
||||
roles: Record<RoleColor, string>
|
||||
kinds: Record<KindColor, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
@@ -472,6 +484,7 @@ export type OpenedFrom = 'uncommitted' | 'other'
|
||||
* stickyScroll:VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先),见 StickyScrollSettings;ready 后来一次,设置改了再来
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
* codeColors:当前颜色主题给代码的配色,设成 body 上的颜色变量(见 codeColors.ts);ready 后先于第一份文档来一次,换了颜色主题、改了代码配色的设置再来
|
||||
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
|
||||
* profiles:kind 那种对话的 profile 下拉;ready 后两种各来一次,任何一个预览改了哪种就来哪种
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
@@ -541,6 +554,7 @@ export type ExtensionToWebview
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
| { type: 'filter', excluded: Excluded, onlyChanges: boolean }
|
||||
| ({ type: 'codeColors' } & CodeColors)
|
||||
| QaMessage
|
||||
| ({ type: 'profiles', kind: ChatKind } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
|
||||
Reference in new issue
Block a user