♻️ refactor(vscode): 看板的悬停提示也改走 data-tooltip,插件网页只剩一条提示的路;lint 禁止原生 title

- 看板剩下的原生 title(顶栏按钮、工单卡片、工单详情、会话、Profile 表、改动文件树和 diff、属性表)全部改成 data-tooltip,
  禁用的按钮照样弹;只有图标、原来只靠 title 当名字的(审查勾、生成说明、属性说明的 i 图标、没头像的归属人)补上 aria-label
- 提示框按原文换行:摘修复弹框里「迁移」列的几个文件一行一个
- 代码阅读 Markdown 链接的标题写成 data-tooltip
- eslint 规则:网页 TSX 的 DOM 元素上写 title 报错,iframe 和组件自己的 title 属性不算
This commit is contained in:
cruldra committed 2026-09-27 21:39:15 +08:00
1 parent 1f7bf63e27
commit c8620e6e1b
15 files changed
+86 -46

No files matched your search

@@ -39,4 +39,9 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('[相对](./x.md)')).not.toContain('<a')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
})
})
@@ -8,6 +8,20 @@ import MarkdownIt from 'markdown-it'
const md = new MarkdownIt({ html: false, linkify: true }).disable('image')
md.validateLink = url => /^(?:https?|mailto):/i.test(url)
/** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */
md.core.ruler.push('link_title_tooltip', (state) => {
for (const block of state.tokens) {
for (const token of block.children ?? []) {
const title = token.type === 'link_open' ? token.attrGet('title') : null
if (title === null)
continue
token.attrs = token.attrs!.filter(([name]) => name !== 'title')
if (title)
token.attrSet('data-tooltip', title)
}
}
})
/** 最近渲染过的文字 → HTML,最多留 CACHE_SIZE 段,最久没用的先丢。 */
const CACHE_SIZE = 500
const cache = new Map<string, string>()
@@ -227,7 +227,7 @@ function TabButton({ active, onClick, icon, title, badge }: TabButtonProps) {
<button
type="button"
onClick={onClick}
title={title}
data-tooltip={title}
aria-label={title}
aria-pressed={active}
className={`relative flex h-9 w-full items-center justify-center transition-colors ${
@@ -209,7 +209,7 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
<span className="flex min-w-0 items-center gap-1.5">
{c.migration && (
<span
title={c.migration.files.join('\n')}
data-tooltip={c.migration.files.join('\n')}
className="shrink-0 rounded border border-current px-1 text-[10px] leading-4 opacity-80"
>
迁移
@@ -35,13 +35,15 @@ function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
<img
src={avatar}
alt={login}
title={`${login} 的工单(只读)`}
data-tooltip={`${login} 的工单(只读)`}
className="size-4 shrink-0 rounded-full ring-1 ring-[var(--vscode-panel-border)]"
/>
)
: (
<span
title={`${login} 的工单(只读)`}
data-tooltip={`${login} 的工单(只读)`}
role="img"
aria-label={`${login} 的工单(只读)`}
className="grid size-4 shrink-0 place-items-center rounded-full bg-[var(--vscode-badge-background)] text-[8px] uppercase text-[var(--vscode-badge-foreground)]"
>
{login.slice(0, 1)}
@@ -90,7 +92,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
? (
<span
className="grid place-items-center text-[var(--vscode-disabledForeground)]"
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
data-tooltip={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
>
<Lock className="size-3.5" />
@@ -103,7 +105,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
? (
<span
className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
title={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
>
待接管
</span>
@@ -230,7 +230,7 @@ export function IssueDetailPanel({
<button
type="button"
onClick={onOpenLogs}
title="查看扩展日志"
data-tooltip="查看扩展日志"
aria-label="查看扩展日志"
className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
>
@@ -803,7 +803,7 @@ export function IssueDetailPanel({
{issue.source === 'youtrack' && (
<span
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
title="此工单来自 YouTrack"
data-tooltip="此工单来自 YouTrack"
>
YouTrack
</span>
@@ -814,7 +814,7 @@ export function IssueDetailPanel({
? (
<span
className="ml-2 inline-flex items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 align-middle text-[10px] font-normal text-[var(--vscode-disabledForeground)]"
title={lockTitle}
data-tooltip={lockTitle}
>
等待 #
{prerequisiteNumber}
@@ -828,7 +828,7 @@ export function IssueDetailPanel({
? (
<span
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-[11px] opacity-80"
title={`${ownerLogin} 的工单,只能查看`}
data-tooltip={`${ownerLogin} 的工单,只能查看`}
>
{avatars?.[ownerLogin]
? <img src={avatars[ownerLogin]} alt={ownerLogin} className="size-4 rounded-full" />
@@ -930,7 +930,7 @@ export function IssueDetailPanel({
key={att.url}
type="button"
onClick={() => onOpenInBrowser(att.url)}
title={att.name}
data-tooltip={att.name}
className="inline-flex max-w-[200px] items-center gap-1 truncate rounded border border-[var(--vscode-panel-border)] px-2 py-1 hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3 shrink-0" />
@@ -177,7 +177,7 @@ export function ManagedSessionsPanel({
<button
type="button"
onClick={openImport}
title="导入已 /rename 的命名会话"
data-tooltip="导入已 /rename 的命名会话"
className="flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-button-secondaryBackground,var(--vscode-input-background))] px-2 py-1 text-xs text-[var(--vscode-button-secondaryForeground,var(--vscode-foreground))] hover:bg-[var(--vscode-button-secondaryHoverBackground,var(--vscode-toolbar-hoverBackground))]"
>
<Download className="size-3.5" />
@@ -367,7 +367,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
<button
type="button"
onClick={resume}
title="点击恢复会话(新终端 resume)"
data-tooltip="点击恢复会话(新终端 resume)"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
aria-label="恢复会话"
>
@@ -411,7 +411,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
beginEdit()
}}
title="双击改名"
data-tooltip="双击改名"
>
{session.name || <span className="opacity-40">(未命名)</span>}
</span>
@@ -462,7 +462,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
onHandoff(session.id)
}}
title="交接给同事"
data-tooltip="交接给同事"
aria-label="交接给同事"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)] group-hover:opacity-70"
>
@@ -476,7 +476,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
onCloseTab(session.id)
}}
title="关闭会话 tab"
data-tooltip="关闭会话 tab"
aria-label="关闭会话 tab"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-70 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
>
@@ -490,7 +490,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
onDelete(session.id)
}}
title="从列表移除(不删 jsonl)"
data-tooltip="从列表移除(不删 jsonl)"
aria-label="从列表移除"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-state-red group-hover:opacity-70"
>
@@ -139,7 +139,7 @@ export function PanelHeader({
<button
type="button"
onClick={() => onFilterChange('')}
title="清空筛选"
data-tooltip="清空筛选"
aria-label="清空筛选"
className="absolute right-1 flex h-4 w-4 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -152,7 +152,7 @@ export function PanelHeader({
type="button"
onClick={() => setReleaseMenuOpen(open => !open)}
disabled={releaseDisabled}
title={title}
data-tooltip={title}
aria-label={title}
aria-haspopup="menu"
aria-expanded={releaseMenuOpen}
@@ -196,7 +196,7 @@ export function PanelHeader({
type="button"
onClick={onToggleEnvLock}
disabled={envLockDisabled}
title={envLockTitle}
data-tooltip={envLockTitle}
aria-label={envLockTitle}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -211,7 +211,7 @@ export function PanelHeader({
type="button"
onClick={onCommit}
disabled={commitRunning}
title="提交代码"
data-tooltip="提交代码"
aria-label="提交代码"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -224,7 +224,7 @@ export function PanelHeader({
<button
type="button"
onClick={onImportYouTrack}
title="导入 YouTrack 工单"
data-tooltip="导入 YouTrack 工单"
aria-label="导入 YouTrack 工单"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -234,7 +234,7 @@ export function PanelHeader({
<button
type="button"
onClick={onToggleScope}
title={scope === 'mine' ? '当前:只看我的工单,点击切换为全部' : '当前:团队全部工单,点击切换为只看我的'}
data-tooltip={scope === 'mine' ? '当前:只看我的工单,点击切换为全部' : '当前:团队全部工单,点击切换为只看我的'}
aria-label={scope === 'mine' ? '切换为全部工单' : '切换为只看我的'}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -244,7 +244,7 @@ export function PanelHeader({
type="button"
onClick={onRefresh}
disabled={refreshing}
title={refreshing ? '正在刷新…' : '刷新'}
data-tooltip={refreshing ? '正在刷新…' : '刷新'}
aria-label="刷新"
aria-busy={refreshing}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed"
@@ -254,7 +254,7 @@ export function PanelHeader({
<button
type="button"
onClick={onEditAuth}
title="重新配置 Gitea Token"
data-tooltip="重新配置 Gitea Token"
aria-label="重新配置 Gitea Token"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -272,7 +272,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
className={`${headerBase} border-t px-2`}
onDoubleClick={() => beginEdit({ kind: 'header', col })}
onContextMenu={e => openHeaderMenu(e, col)}
title="双击改名,右键删除"
data-tooltip="双击改名,右键删除"
>
{editing && editing.kind === 'header' && editing.col === col
? (
@@ -304,7 +304,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
<button
type="button"
onClick={addProfile}
title="新增 profile 列"
data-tooltip="新增 profile 列"
aria-label="新增 profile 列"
className="grid size-5 place-items-center rounded opacity-60 hover:bg-[var(--vscode-list-hoverBackground)] hover:opacity-100"
>
@@ -342,7 +342,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
<button
type="button"
onClick={addKey}
title="新增 key 行"
data-tooltip="新增 key 行"
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] hover:text-[var(--vscode-foreground)]"
>
<Plus className="size-3.5" />
@@ -434,7 +434,7 @@ function RowFragment({
className={`${cellBase} ${editableHover} border-l px-2 font-medium`}
onDoubleClick={() => beginEdit({ kind: 'key', row: rIdx })}
onContextMenu={e => openKeyMenu(e, rIdx)}
title="双击编辑 key,右键删除"
data-tooltip="双击编辑 key,右键删除"
>
{editing && editing.kind === 'key' && editing.row === rIdx
? (
@@ -473,7 +473,7 @@ function RowFragment({
key={`r${rIdx}-c${col}`}
className={`${cellBase} ${editableHover} group px-2`}
onDoubleClick={() => onEditCell(profile)}
title="双击编辑(多行 + 图片)"
data-tooltip="双击编辑(多行 + 图片)"
>
<div className="flex w-full min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate">
@@ -492,7 +492,7 @@ function RowFragment({
e.stopPropagation()
onOpen(value)
}}
title={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
data-tooltip={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
aria-label={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
className="grid size-5 shrink-0 place-items-center rounded opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:opacity-100 group-hover:opacity-70"
>
@@ -140,7 +140,7 @@ export function FileDiffPane({
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
style={{ color: confirmed ? 'var(--vscode-descriptionForeground)' : 'var(--vscode-foreground)' }}
title={path}
data-tooltip={path}
>
{path}
</span>
@@ -148,7 +148,8 @@ export function FileDiffPane({
<button
type="button"
onClick={() => onToggleConfirm(!confirmed)}
title={confirmed ? '已审查,点击取消' : '标记为已审查'}
data-tooltip={confirmed ? '已审查,点击取消' : '标记为已审查'}
aria-label={confirmed ? '已审查,点击取消' : '标记为已审查'}
className="shrink-0"
style={{
color: confirmed
@@ -163,7 +164,9 @@ export function FileDiffPane({
type="button"
onClick={onGenerate}
disabled={generating}
title={generating ? '生成中…' : '生成本文件改动说明'}
data-tooltip={generating ? '生成中…' : '生成本文件改动说明'}
aria-label="生成本文件改动说明"
aria-busy={generating}
className="shrink-0 text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] disabled:opacity-60"
>
{generating ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
@@ -70,7 +70,8 @@ export function FileTree({
e.stopPropagation()
onToggleDirConfirm(row.descendantFiles, !isConfirmed)
}}
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
data-tooltip={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
aria-label={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
@@ -87,13 +88,13 @@ export function FileTree({
const isConfirmed = confirmed.has(row.path)
const isSelected = selectedPath === row.path
return (
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;title 用完整路径。
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;悬停提示是状态和完整路径。
<li
key={row.path}
onMouseEnter={() => setHoveredPath(row.path)}
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
onClick={() => onSelectFile(row.path)}
title={`${row.status} · ${row.path}`}
data-tooltip={`${row.status} · ${row.path}`}
className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 ${
isSelected
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
@@ -114,7 +115,8 @@ export function FileTree({
e.stopPropagation()
onToggleFileConfirm(row.path, !isConfirmed)
}}
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
data-tooltip={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
aria-label={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
@@ -97,7 +97,7 @@ function RowActionButtons({ actions, className }: { actions: RowAction[] | undef
action.onClick()
}}
disabled={action.disabled}
title={action.title}
data-tooltip={action.title}
aria-label={action.title ?? '次要操作'}
className={`grid size-5 shrink-0 place-items-center ${className ?? ''} ${
action.disabled ? 'cursor-not-allowed opacity-30' : 'opacity-60 hover:opacity-100'
@@ -380,7 +380,7 @@ function PropertyRow({
type="button"
data-property-action="primary"
onDoubleClick={() => firePrimaryAction(() => propDef.onOpen?.(value as string))}
title={value as string}
data-tooltip={value as string}
className="flex min-w-0 select-none items-center gap-1.5 text-left text-xs font-mono text-[var(--vscode-textLink-foreground)] underline-offset-2 hover:text-[var(--vscode-textLink-activeForeground)] hover:underline"
>
{actionPending && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
@@ -468,12 +468,14 @@ function PropertyRow({
<div className="group flex min-h-[36px] border-b border-[var(--vscode-panel-border)] transition-colors last:border-b-0">
{/* 标签列 */}
<div className="flex w-[38%] flex-shrink-0 items-center gap-1.5 border-r border-[var(--vscode-panel-border)] px-3 py-1">
<span className="truncate text-xs font-medium" title={propDef.label}>
<span className="truncate text-xs font-medium" data-tooltip={propDef.label}>
{propDef.label}
</span>
{propDef.description && (
<span
title={propDef.description}
data-tooltip={propDef.description}
role="img"
aria-label={propDef.description}
className="grid size-3 cursor-help place-items-center opacity-60"
>
<Info className="size-3" />
@@ -100,7 +100,8 @@ function isOwnName(anchor: Element, text: string): boolean {
}
/**
* 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里)。
* 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里),提示里的换行照样换行。
* 网页里的原生 title 属性由 eslint.config.mjs 的规则拦下,只有 iframe 这类必须带 title 的元素例外。
* 不用原生 title:macOS 上 VS Code 的 Electron 只在鼠标压着元素自身边缘时才弹原生提示,停在里面的图标、文字上不弹
* (electron/electron#49843),样式也是系统的。这里画成 VS Code 悬停框的样子(--vscode-editorHoverWidget-*),
* portal 到 body 不被 overflow 容器裁掉;键盘聚焦(:focus-visible)立刻弹,并用 aria-describedby 挂到元素上。
@@ -173,7 +174,7 @@ export function TooltipHost(): ReactElement | null {
id={id}
role="tooltip"
style={{ top: 0, left: 0, visibility: 'hidden' }}
className="pointer-events-none fixed z-50 max-w-[22rem] rounded border border-[var(--vscode-editorHoverWidget-border)] bg-[var(--vscode-editorHoverWidget-background)] px-2 py-1 text-[11px] leading-tight break-words text-[var(--vscode-editorHoverWidget-foreground)] shadow-[0_2px_8px_var(--vscode-widget-shadow)]"
className="pointer-events-none fixed z-50 max-w-[22rem] rounded border border-[var(--vscode-editorHoverWidget-border)] bg-[var(--vscode-editorHoverWidget-background)] px-2 py-1 text-[11px] leading-tight break-words whitespace-pre-line text-[var(--vscode-editorHoverWidget-foreground)] shadow-[0_2px_8px_var(--vscode-widget-shadow)]"
>
{tip.text}
</div>,