From ec34ace7cf0561996633a039b18b9187aae5880c Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 21:21:13 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E6=82=AC?= =?UTF-8?q?=E5=81=9C=E6=8F=90=E7=A4=BA=E4=B8=8D=E7=94=A8=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=20title=EF=BC=8Cwebview=20=E7=BB=9F=E4=B8=80=E7=94=A8=E8=87=AA?= =?UTF-8?q?=E7=BB=98=E6=8F=90=E7=A4=BA=E6=A1=86=EF=BC=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E7=9A=84=E7=A7=8D=E7=B1=BB=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=81=9C=E4=B8=8A=E5=8E=BB=E8=83=BD=E7=9C=8B=E5=88=B0=E7=A7=8D?= =?UTF-8?q?=E7=B1=BB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code 1.139 带的 Electron 43 在 macOS 上只在鼠标压着带 title 的元素自身边缘时才弹原生提示, 停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起)。种类图标 20px 的框 几乎被 16px 的图形占满,所以基本看不到种类名;块右上角按钮同样受影响。页面上核对过:图标中心点 命中的是图标里的线条,没有盖在上面的东西。 - components/ui/tooltip.tsx 改成整页只挂一次的 TooltipHost:元素只写 data-tooltip,事件统一交给它; 样子照 VS Code 悬停框(editorHoverWidget 那组变量、widget-shadow 阴影),停 500ms 才弹、弹着时换元素 立刻换,移开 / 按下 / 按键 / 滚动收起;键盘聚焦立刻弹,提示不是元素自己的名字时挂 aria-describedby - 代码阅读里所有 title(种类图标、块按钮、筛选栏、问答区、中文名的「原名」)改成 data-tooltip; SelectMenu 的 title 属性改名 tooltip;看板工单详情原来包一层的 Tooltip 也改成 data-tooltip - 何时弹 / 收的规则抽成纯函数加测试;更新代码阅读 spec --- docs/superpowers/specs/code-reader/spec.md | 12 +- .../src/codeReader/AskThread.test.tsx | 6 +- .../webview-ui/src/codeReader/AskThread.tsx | 20 +- .../src/codeReader/BlockTools.test.tsx | 7 +- .../webview-ui/src/codeReader/BlockTools.tsx | 6 +- .../webview-ui/src/codeReader/FilterBar.tsx | 6 +- .../webview-ui/src/codeReader/SegmentText.tsx | 2 +- .../src/codeReader/displayNames.test.tsx | 6 +- .../src/codeReader/kindIcons.test.tsx | 7 +- .../webview-ui/src/codeReader/kindIcons.tsx | 4 +- vscode/webview-ui/src/codeReader/main.tsx | 2 + .../src/components/IssueDetailPanel.tsx | 124 +++++---- .../src/components/ManagedSessionsPanel.tsx | 2 +- .../src/components/ui/select-menu.tsx | 10 +- .../src/components/ui/tooltip.test.ts | 123 +++++++++ .../webview-ui/src/components/ui/tooltip.tsx | 247 ++++++++++++------ vscode/webview-ui/src/main.tsx | 2 + 17 files changed, 399 insertions(+), 187 deletions(-) create mode 100644 vscode/webview-ui/src/components/ui/tooltip.test.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 7f11c8d..a561f0d 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -115,7 +115,7 @@ - 每一句是一块:导入、函数、类、枚举、异常、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类图标、内容、右上角一排图标按钮。 -- 种类图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边只画图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),颜色是种类色;种类的名字(翻译插件给 `kind` 值起的,见「筛选」;库规则的种类是规则给的 `badge.label`)写在悬停提示(`title`,同块右上角按钮的悬停提示)和 `aria-label` 里,`role="img"`。和折叠箭头、内容同一行顶对齐。每种内置种类由插件定图标、颜色、名字:图标从插件定义的一组图标里取(见下),内置种类 → 图标只在 webview 的一处(`kindIcons.tsx` 的 `KIND_ICONS`);颜色在 `codeReader.css` 的 `--cr-kind-*`(见「颜色」);名字由翻译插件给。同一类语义共用一个颜色,图标区分具体是哪种: +- 种类图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边只画图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),颜色是种类色;种类的名字(翻译插件给 `kind` 值起的,见「筛选」;库规则的种类是规则给的 `badge.label`)写在悬停提示(见下「悬停提示」,同块右上角按钮)和 `aria-label` 里,`role="img"`。和折叠箭头、内容同一行顶对齐。每种内置种类由插件定图标、颜色、名字:图标从插件定义的一组图标里取(见下),内置种类 → 图标只在 webview 的一处(`kindIcons.tsx` 的 `KIND_ICONS`);颜色在 `codeReader.css` 的 `--cr-kind-*`(见「颜色」);名字由翻译插件给。同一类语义共用一个颜色,图标区分具体是哪种: | 种类 | 图标 | 种类 | 图标 | 种类 | 图标 | | --- | --- | --- | --- | --- | --- | @@ -129,7 +129,8 @@ 库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`。 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 18 个加库规则用的 5 个,lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,`KindBadge.icon` 只能写其中一个,写别的是类型错误。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 -- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 +- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 +- 悬停提示(整个预览只走这一条路,看板 webview 共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。 - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 - 内容两种状态,每块单独切: - 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 @@ -1391,7 +1392,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 页面(`webview-ui/.../displayNames.test.tsx`,渲染成 HTML):只收这个版本的、下标和长度都对上才换、用到的地方写中文名和「原名 x」悬停提示、下标照旧、定义处后面淡淡的原名、框里也换、单击时筛选栏写中文名且当前那一处按下标认。 - 真 VS Code(Pylance):routes.py 的条件读成「如果 当前用户.role 不等于 UserRole.管理员」(管理员来自 models.py),参数表「当前用户 current_user」,docstring 兜底的「要删的那一项的编号」,单击高亮、⌘↓ 跳、⌘ 点击「管理员」跳进 models.py 的预览(「管理员 ADMIN」,没有说明列),改文档时中文名一帧都没少。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。 -- 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;`rules/` 下的非测试文件只 import contract。 +- 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;规则自己报的筛选属性名字取规则的 `attributeNames`(翻译插件给了的以它为准,`attributes.test.ts` 的合并、`documentMessage.test.ts` 的「请求方法」),规则起名时抛错记下来、照写 id;`rules/` 下的非测试文件只 import contract。 - 同一行不在两处能改(webview `editing.test.ts`):卡片编辑时盖住它留下的子块(字段中间的方法连同主体),跳到被盖住的块改闪卡片;只编辑头部的块不盖住子块,一行写完的块盖住同一行的子块;点「编辑」时重叠的块退出编辑;任意点法下正在编辑、画出来的块两两不重叠。 - SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、每行只在一块里;两个字段中间写着的方法照常是子块、卡片「编辑」写回整个类、字段前面的注释并进卡片、讲方法的注释留下;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。 - 库规则的引用:引用标的不是自己、规则没给 `resolve`、段没带下标时当替身不合约定;查引用交给标它的规则,规则不在、没给 `resolve`、出错时记下来、当作没有定义;视图数据里的引用(下标 → 引用)只从规则认领的块的属性表里取,查定义时是引用的问规则、其余问语言扩展,只对记下它的那个文档版本有效(`editor/definitionSources.test.ts`)。 @@ -1413,8 +1414,9 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 菜单(`explorer/rowMenus.test.ts`):标题栏先「选工单」、审查时才有「显示全部」、再「刷新」,字和图标;审查时注释按钮不出、复制照旧;命令面板里「显示全部」只在审查时有。 - 工作副本(`test/git/reviewWorktree.test.ts`,真的 git:bare 的 origin 上推 `refs/pull/7/head`):拉 PR 的提交建不挂分支的 worktree(父目录不在也建、本机不多出分支),目录手删后同一路径能再建;快进:PR 往前走了且干净 → 快进,已经最新 / 本地在它之上 → 不动,只有未跟踪的文件照样快进,有没提交的改动、本地另有提交 → 不动且改动留着,目录不是工作副本的根 → 报错、外面的仓库不动。 - 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 -- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 -- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应;这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,`role="img"`,`title` 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。 +- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、悬停提示(`data-tooltip`,没有原生 `title`)和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 +- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应;这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,`role="img"`,悬停提示(`data-tooltip`,没有原生 `title`) 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。 +- 悬停提示(webview `components/ui/tooltip.test.ts`):停 500ms 才弹、在同一个元素里移动不重新计时、没等到就移开不弹、弹着时移到另一个上立刻换、移到没提示的地方就收、按下鼠标 / 按键 / 滚动就收且鼠标不离开不再弹、等着弹时按下这次不弹、键盘聚焦立刻弹失焦收、提示是空的不弹;位置在上方、上方放不下翻到下方、左右钳在视口内。页面上(无头 Chrome,真的移鼠标、按 Tab):种类图标中心点命中的是图标里的线条、没有盖在上面的东西;停 250ms 还没弹、停满 500ms 在图标上方弹「函数」「接口」,颜色是悬停框的那组变量;Tab 到「编辑」立刻弹、Esc 收;页面上没有原生 `title`。亮、暗两套颜色都看过。 - 问 AI: - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。 - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。图片:文件名跟着这条存、读得回来,跑到别处的路径、不是字符串的名字跳过;删一次问答它的图片跟着删、别的留着;源文件删了图片跟着删,改名、挪目录图片不动。 diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx index 5f189cf..ada85de 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -42,14 +42,14 @@ describe('块下面的问答区', () => { expect(html.match(/data-history=""/g)).toHaveLength(2) expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前') expect(text(html)).toContain('UserRole 还有哪些角色?昨天') - expect(html.match(/title="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2) + expect(html.match(/data-tooltip="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2) expect(text(html)).toContain('这是权限控制:不是管理员、又不是查自己时抛 Forbidden。') expect(html).toContain('
') expect(html).toContain('data-current=""') expect(html).toContain('data-streaming="">

Forbidden 定义在 core

') expect(text(html)).toContain('读取 exceptions.py') expect(html).toContain('placeholder="继续问… Enter 发送,Esc 收起"') - expect(html).toContain('title="停止" aria-label="停止"') + expect(html).toContain('data-tooltip="停止" aria-label="停止"') expect(html).not.toContain('aria-label="发送"') }) @@ -80,7 +80,7 @@ describe('块下面的问答区', () => { const trigger = row.slice(row.lastIndexOf('', select)) expect(trigger).toContain('aria-haspopup="listbox"') expect(trigger).toContain('aria-expanded="false"') - expect(trigger).toContain('title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"') + expect(trigger).toContain('data-tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"') expect(text(trigger)).toBe('sonnet') const none = render({ open: true, profiles: { options: [], selected: '' } }) expect(text(none.slice(none.indexOf('cr-qa-profile')))).toContain('默认') diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 7cc63b7..9804ca4 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -19,11 +19,11 @@ function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: {urls.map((url, index) => ( // 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行 - {onRemove && ( - )} @@ -55,7 +55,7 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R return createPortal(
-
, @@ -71,16 +71,16 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
- - -
@@ -218,17 +218,17 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on options={profiles.options.map(option => ({ value: option.id, label: option.label }))} onChange={onSelectProfile} placeholder="默认" - title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它" + tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它" className="cr-qa-profile" /> {running ? ( - ) : ( - )} diff --git a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx index 72e6a93..b20b77b 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx @@ -15,7 +15,8 @@ describe('块右上角的图标按钮', () => { expect(html).toMatch(/^]* role="group" aria-label="块操作"/) expect(visibleText(html)).toBe('') expect(html.match(/ - + {/* Close/delete write to gitea by number — hide for youtrack cards to avoid acting on a gitea issue of the same number. */} {issue.source !== 'youtrack' && !readOnly && ( <> {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && ( - - - + )} {!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && ( - - - + )} {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( - - - 待接管 - - + + 待接管 + )} {(issue.column === 'in-progress' || issue.column === 'review') && ( - - - - )} - - - - - + )} + + )}
diff --git a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx index 3d0b405..9e67c33 100644 --- a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx +++ b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx @@ -424,7 +424,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa void placeholder?: string - title?: string + tooltip?: string disabled?: boolean variant?: 'input' | 'ghost' className?: string @@ -60,7 +60,7 @@ export function SelectMenu({ options, onChange, placeholder = '请选择', - title, + tooltip, disabled = false, variant = 'input', className, @@ -265,7 +265,7 @@ export function SelectMenu({ ref={triggerRef} type="button" disabled={disabled} - title={title} + data-tooltip={tooltip} aria-haspopup="listbox" aria-expanded={open} onClick={onTriggerClick} @@ -334,8 +334,8 @@ export function SelectMenu({
- {opt.label} - {opt.hint && {opt.hint}} + {opt.label} + {opt.hint && {opt.hint}} ))} diff --git a/vscode/webview-ui/src/components/ui/tooltip.test.ts b/vscode/webview-ui/src/components/ui/tooltip.test.ts new file mode 100644 index 0000000..e622958 --- /dev/null +++ b/vscode/webview-ui/src/components/ui/tooltip.test.ts @@ -0,0 +1,123 @@ +import type { Tip, TooltipController } from './tooltip' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTooltipController, placeTooltip, SHOW_DELAY_MS } from './tooltip' + +interface Anchor { text: string } + +const icon: Anchor = { text: '函数' } +const route: Anchor = { text: '接口' } +const blank: Anchor = { text: '' } + +function setup(): { controller: TooltipController, changes: Array | null> } { + const changes: Array | null> = [] + const controller = createTooltipController(anchor => anchor.text, tip => changes.push(tip)) + return { controller, changes } +} + +describe('悬停提示什么时候出、什么时候收', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('鼠标停在带提示的元素上,过一小会儿才弹', () => { + const { controller, changes } = setup() + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS - 1) + expect(changes).toEqual([]) + vi.advanceTimersByTime(1) + expect(changes).toEqual([{ anchor: icon, text: '函数' }]) + }) + + it('在同一个元素里移动(划过图标的线条和空白)不重新计时', () => { + const { controller, changes } = setup() + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS - 100) + controller.hover(icon) + vi.advanceTimersByTime(100) + expect(changes).toEqual([{ anchor: icon, text: '函数' }]) + }) + + it('没等到就移开,不弹', () => { + const { controller, changes } = setup() + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS / 2) + controller.hover(null) + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes).toEqual([]) + }) + + it('弹着时移到另一个带提示的元素上立刻换,移到没有提示的地方就收', () => { + const { controller, changes } = setup() + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS) + controller.hover(route) + expect(changes.at(-1)).toEqual({ anchor: route, text: '接口' }) + controller.hover(null) + expect(changes.at(-1)).toBeNull() + controller.hover(icon) + expect(changes.at(-1)).toBeNull() + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes.at(-1)).toEqual({ anchor: icon, text: '函数' }) + }) + + it('按下鼠标、按键、滚动就收;鼠标不离开这个元素不再弹,移出去再回来照常弹', () => { + const { controller, changes } = setup() + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS) + controller.dismiss() + expect(changes.at(-1)).toBeNull() + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes.at(-1)).toBeNull() + controller.hover(null) + controller.hover(icon) + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes.at(-1)).toEqual({ anchor: icon, text: '函数' }) + }) + + it('等着弹的时候按下鼠标,这次不弹了', () => { + const { controller, changes } = setup() + controller.hover(icon) + controller.dismiss() + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes).toEqual([]) + }) + + it('键盘聚焦立刻弹,焦点离开就收;别的元素失焦不影响', () => { + const { controller, changes } = setup() + controller.focus(route) + expect(changes).toEqual([{ anchor: route, text: '接口' }]) + controller.blur(icon) + expect(changes).toHaveLength(1) + controller.blur(route) + expect(changes.at(-1)).toBeNull() + }) + + it('提示文字是空的不弹', () => { + const { controller, changes } = setup() + controller.hover(blank) + vi.advanceTimersByTime(SHOW_DELAY_MS) + controller.focus(blank) + expect(changes).toEqual([]) + }) +}) + +describe('提示框放在哪', () => { + const size = { width: 40, height: 18 } + + it('上方放得下就在上方,水平对着元素中间', () => { + expect(placeTooltip({ top: 100, bottom: 120, left: 50, width: 20 }, size, 900)).toEqual({ top: 76, left: 40 }) + }) + + it('上方放不下翻到下方', () => { + expect(placeTooltip({ top: 10, bottom: 30, left: 50, width: 20 }, size, 900)).toEqual({ top: 36, left: 40 }) + }) + + it('左右钳在视口里,贴边留 8px', () => { + expect(placeTooltip({ top: 100, bottom: 120, left: 0, width: 20 }, size, 900).left).toBe(8) + expect(placeTooltip({ top: 100, bottom: 120, left: 890, width: 10 }, size, 900).left).toBe(852) + }) +}) diff --git a/vscode/webview-ui/src/components/ui/tooltip.tsx b/vscode/webview-ui/src/components/ui/tooltip.tsx index 294348c..1e200b9 100644 --- a/vscode/webview-ui/src/components/ui/tooltip.tsx +++ b/vscode/webview-ui/src/components/ui/tooltip.tsx @@ -1,95 +1,182 @@ -import type { ReactElement, ReactNode } from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import type { ReactElement } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { cn } from '@/lib/utils' -/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */ -const SHOW_DELAY_MS = 300 -/** 气泡与触发元素之间的间距。 */ +/** 鼠标停多久才弹,扫过一排按钮、一行代码时不闪一串提示。 */ +export const SHOW_DELAY_MS = 500 +/** 提示框与元素之间的间距。 */ const GAP_PX = 6 -/** 贴边留白,气泡不顶到 webview 边缘。 */ +/** 贴边留白,提示框不顶到 webview 边缘。 */ const GUTTER_PX = 8 -interface TooltipProps { - /** 气泡文案;为空则不弹。 */ - label: ReactNode - children: ReactNode - className?: string +/** 正在显示的提示:挂在哪个元素上、写什么。 */ +export interface Tip { + anchor: A + text: string +} + +export interface TooltipController { + /** 鼠标移到这个带提示的元素上;null 是移到了没有提示的地方或移出页面 */ + hover: (anchor: A | null) => void + /** 键盘把焦点移到这个带提示的元素上 */ + focus: (anchor: A) => void + /** 焦点离开这个元素 */ + blur: (anchor: A) => void + /** 按下鼠标、按键、滚动:收起,鼠标不离开眼下这个元素就不再弹 */ + dismiss: () => void + dispose: () => void } /** - * 悬停提示气泡。 - * - * 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀, - * 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方, - * 上方放不下就翻到下方,左右钳在视口内。 + * 悬停提示什么时候出、换成谁、什么时候收(不碰 DOM,TooltipHost 把页面事件喂进来): + * 鼠标停在带提示的元素上 SHOW_DELAY_MS 后出,已经出着时移到另一个上立刻换;键盘聚焦立刻出;移开、失焦、dismiss 就收。 */ -export function Tooltip({ label, children, className }: TooltipProps): ReactElement { - const [visible, setVisible] = useState(false) - const wrapRef = useRef(null) +export function createTooltipController(textOf: (anchor: A) => string, onChange: (tip: Tip | null) => void): TooltipController { + let hovered: A | null = null + let shown: Tip | null = null + let timer: ReturnType | undefined + + const set = (tip: Tip | null): void => { + clearTimeout(timer) + timer = undefined + if (tip?.anchor === shown?.anchor && tip?.text === shown?.text) + return + shown = tip + onChange(tip) + } + const show = (anchor: A): void => { + const text = textOf(anchor) + set(text ? { anchor, text } : null) + } + const schedule = (anchor: A): void => { + clearTimeout(timer) + timer = setTimeout(show, SHOW_DELAY_MS, anchor) + } + + return { + hover(anchor) { + if (anchor === hovered) + return + hovered = anchor + if (anchor === null) + set(null) + else if (shown) + show(anchor) + else + schedule(anchor) + }, + focus: show, + blur(anchor) { + if (shown?.anchor === anchor) + set(null) + }, + dismiss: () => set(null), + dispose: () => clearTimeout(timer), + } +} + +/** 提示框放在哪:优先在元素上方,上方放不下翻到下方;水平对着元素中间,钳在视口内。 */ +export function placeTooltip( + anchor: Pick, + size: Pick, + viewportWidth: number, +): { top: number, left: number } { + const above = anchor.top - size.height - GAP_PX + const centered = anchor.left + anchor.width / 2 - size.width / 2 + return { + top: above >= GUTTER_PX ? above : anchor.bottom + GAP_PX, + left: Math.max(GUTTER_PX, Math.min(centered, viewportWidth - GUTTER_PX - size.width)), + } +} + +/** 页面上带提示的元素:提示文字写在 data-tooltip 上。 */ +function anchorOf(target: EventTarget | null): Element | null { + return target instanceof Element ? target.closest('[data-tooltip]:not([data-tooltip=""])') : null +} + +/** 提示是不是已经是元素的名字(aria-label 或它的字):是就不再挂成描述,读屏不念两遍。 */ +function isOwnName(anchor: Element, text: string): boolean { + return anchor.getAttribute('aria-label') === text || anchor.textContent?.trim() === text +} + +/** + * 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里)。 + * 不用原生 title:macOS 上 VS Code 的 Electron 只在鼠标压着元素自身边缘时才弹原生提示,停在里面的图标、文字上不弹 + * (electron/electron#49843),样式也是系统的。这里画成 VS Code 悬停框的样子(--vscode-editorHoverWidget-*), + * portal 到 body 不被 overflow 容器裁掉;键盘聚焦(:focus-visible)立刻弹,并用 aria-describedby 挂到元素上。 + */ +export function TooltipHost(): ReactElement | null { + const id = useId() + const [tip, setTip] = useState | null>(null) const bubbleRef = useRef(null) - const timerRef = useRef | undefined>(undefined) - const hide = useCallback((): void => { - clearTimeout(timerRef.current) - setVisible(false) - }, []) - - const show = useCallback((): void => { - if (!label) - return - clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => { - if (wrapRef.current) - setVisible(true) - }, SHOW_DELAY_MS) - }, [label]) - - useEffect(() => () => clearTimeout(timerRef.current), []) - - // 气泡先在左上角量一次尺寸再落位。必须量在 left:0:fixed 元素的可用宽度是 - // 「视口宽 - left」,贴右边量会被挤窄、行数变多,按那个高度定位就会浮高一截。 useEffect(() => { - if (!visible) - return - const bubble = bubbleRef.current - const rect = wrapRef.current?.getBoundingClientRect() - if (!bubble || !rect) - return - const { width, height } = bubble.getBoundingClientRect() - const above = rect.top - height - GAP_PX - const top = above >= GUTTER_PX ? above : rect.bottom + GAP_PX - let left = rect.left + rect.width / 2 - width / 2 - left = Math.max(GUTTER_PX, Math.min(left, window.innerWidth - GUTTER_PX - width)) - bubble.style.top = `${top}px` - bubble.style.left = `${left}px` - bubble.style.visibility = 'visible' - }, [visible]) + let described: Element | null = null + const controller = createTooltipController( + anchor => anchor.getAttribute('data-tooltip') ?? '', + (next) => { + described?.removeAttribute('aria-describedby') + described = next && !isOwnName(next.anchor, next.text) ? next.anchor : null + described?.setAttribute('aria-describedby', id) + setTip(next) + }, + ) + const onPointerOver = (event: PointerEvent): void => controller.hover(anchorOf(event.target)) + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) + controller.hover(null) + } + const onFocusIn = (event: FocusEvent): void => { + const anchor = anchorOf(event.target) + if (anchor && event.target instanceof Element && event.target.matches(':focus-visible')) + controller.focus(anchor) + } + const onFocusOut = (event: FocusEvent): void => { + const anchor = anchorOf(event.target) + if (anchor) + controller.blur(anchor) + } + const dismiss = (): void => controller.dismiss() + document.addEventListener('pointerover', onPointerOver) + document.addEventListener('pointerout', onPointerOut) + document.addEventListener('focusin', onFocusIn) + document.addEventListener('focusout', onFocusOut) + document.addEventListener('pointerdown', dismiss, true) + document.addEventListener('keydown', dismiss, true) + window.addEventListener('scroll', dismiss, true) + return () => { + document.removeEventListener('pointerover', onPointerOver) + document.removeEventListener('pointerout', onPointerOut) + document.removeEventListener('focusin', onFocusIn) + document.removeEventListener('focusout', onFocusOut) + document.removeEventListener('pointerdown', dismiss, true) + document.removeEventListener('keydown', dismiss, true) + window.removeEventListener('scroll', dismiss, true) + controller.dispose() + described?.removeAttribute('aria-describedby') + } + }, [id]) - return ( - <> - - {children} - - {visible && label && createPortal( -
- {label} -
, - document.body, - )} - + // 先在左上角量尺寸再落位:fixed 元素的可用宽度是「视口宽 - left」,贴右边量会被挤窄、行数变多,按那个高度定位就浮高一截 + useLayoutEffect(() => { + const bubble = bubbleRef.current + if (!tip || !bubble) + return + Object.assign(bubble.style, { top: '0px', left: '0px' }) + const { top, left } = placeTooltip(tip.anchor.getBoundingClientRect(), bubble.getBoundingClientRect(), window.innerWidth) + Object.assign(bubble.style, { top: `${top}px`, left: `${left}px`, visibility: 'visible' }) + }, [tip]) + + return tip && createPortal( + , + document.body, ) } diff --git a/vscode/webview-ui/src/main.tsx b/vscode/webview-ui/src/main.tsx index 81d1024..1050d9c 100644 --- a/vscode/webview-ui/src/main.tsx +++ b/vscode/webview-ui/src/main.tsx @@ -2,12 +2,14 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { App } from './App' import { ErrorBoundary } from './components/ErrorBoundary' +import { TooltipHost } from './components/ui/tooltip' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( + , )