✨ feat(vscode): 工单详情头部按钮改用自绘悬停提示
那排图标按钮只靠原生 title 提示:延迟长、样式是系统的,暗色主题下很突兀, 「在 Gitea 打开」更是完全没有说明。 新增 Tooltip 组件(portal 到 body,优先弹在上方,空间不够翻到下方,左右钳在 视口内,300ms 延迟),工单详情头部的打开/接管/移交/待接管/打回待办/关闭/删除 全部改用它,文案顺带写清楚每个动作的实际后果。 Claude-Session: https://claude.ai/code/session_01REdhpbaNyqWASF2HWsnTFv
This commit is contained in:
1 parent
9cd5ab7e9d
commit
9fadcac12b
3 files changed
+113
-7
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"publisher": "clurdra",
|
"publisher": "clurdra",
|
||||||
"name": "superpowers-vscode-clurdra",
|
"name": "superpowers-vscode-clurdra",
|
||||||
"displayName": "Superpowers-clurdra",
|
"displayName": "Superpowers-clurdra",
|
||||||
"version": "0.2.129",
|
"version": "0.2.130",
|
||||||
"packageManager": "pnpm@10.27.0",
|
"packageManager": "pnpm@10.27.0",
|
||||||
"description": "Superpowers specs and plans Kanban explorer",
|
"description": "Superpowers specs and plans Kanban explorer",
|
||||||
"author": "clurdra",
|
"author": "clurdra",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
|
|||||||
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
||||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||||
import { PropertyGrid } from './property-grid'
|
import { PropertyGrid } from './property-grid'
|
||||||
|
import { Tooltip } from './ui/tooltip'
|
||||||
|
|
||||||
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
|
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
|
||||||
function matchProfileOption(
|
function matchProfileOption(
|
||||||
@@ -616,6 +617,7 @@ export function IssueDetailPanel({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
<Tooltip label={issue.source === 'youtrack' ? '在浏览器里打开这张 YouTrack 卡' : '在浏览器里打开这个 Gitea 工单'}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||||
@@ -624,69 +626,77 @@ export function IssueDetailPanel({
|
|||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
{/* Close/delete write to gitea by number — hide for youtrack cards to
|
{/* Close/delete write to gitea by number — hide for youtrack cards to
|
||||||
avoid acting on a gitea issue of the same number. */}
|
avoid acting on a gitea issue of the same number. */}
|
||||||
{issue.source !== 'youtrack' && !readOnly && (
|
{issue.source !== 'youtrack' && !readOnly && (
|
||||||
<>
|
<>
|
||||||
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||||||
|
<Tooltip label={`接管 ${issue.handoffFrom ?? '?'} 移交过来的工单:拉回会话记录和 worktree`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isHandoffRunning(issue.number)}
|
disabled={isHandoffRunning(issue.number)}
|
||||||
onClick={() => onAcceptHandoff(issue.number)}
|
onClick={() => onAcceptHandoff(issue.number)}
|
||||||
title={`接管(${issue.handoffFrom ?? '?'} 移交)`}
|
|
||||||
aria-label="接管"
|
aria-label="接管"
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||||
接管
|
接管
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
|
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
|
||||||
|
<Tooltip label="移交给同事:把会话记录打包传过去,对方接管后继续做">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isHandoffRunning(issue.number)}
|
disabled={isHandoffRunning(issue.number)}
|
||||||
onClick={() => onStartHandoff(issue.number)}
|
onClick={() => onStartHandoff(issue.number)}
|
||||||
title="移交给同事"
|
|
||||||
aria-label="移交"
|
aria-label="移交"
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
|
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
|
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
|
||||||
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
|
<Tooltip label={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
|
||||||
|
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70">
|
||||||
待接管
|
待接管
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(issue.column === 'in-progress' || issue.column === 'review') && (
|
{(issue.column === 'in-progress' || issue.column === 'review') && (
|
||||||
|
<Tooltip label="打回待办:卡片挪回待办列,会话和 worktree 不动">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onResetToTodo(issue.number)}
|
onClick={() => onResetToTodo(issue.number)}
|
||||||
title="重置为待办"
|
|
||||||
aria-label="重置为待办"
|
aria-label="重置为待办"
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-3.5" />
|
<RotateCcw className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip label="关闭工单:Gitea 上置为 closed,卡片从看板消失,可在 Gitea 里重开">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCloseIssue(issue.number)}
|
onClick={() => onCloseIssue(issue.number)}
|
||||||
title="关闭工单"
|
|
||||||
aria-label="关闭工单"
|
aria-label="关闭工单"
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||||
>
|
>
|
||||||
<CircleSlash className="size-3.5" />
|
<CircleSlash className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="删除工单:从 Gitea 彻底删掉,不可撤销">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => issue && onDeleteIssue?.(issue.number)}
|
onClick={() => issue && onDeleteIssue?.(issue.number)}
|
||||||
title="删除工单(不可撤销)"
|
|
||||||
aria-label="删除工单"
|
aria-label="删除工单"
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */
|
||||||
|
const SHOW_DELAY_MS = 300
|
||||||
|
/** 气泡与触发元素之间的间距。 */
|
||||||
|
const GAP_PX = 6
|
||||||
|
/** 贴边留白,气泡不顶到 webview 边缘。 */
|
||||||
|
const GUTTER_PX = 8
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
/** 气泡文案;为空则不弹。 */
|
||||||
|
label: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 悬停提示气泡。
|
||||||
|
*
|
||||||
|
* 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀,
|
||||||
|
* 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方,
|
||||||
|
* 上方放不下就翻到下方,左右钳在视口内。
|
||||||
|
*/
|
||||||
|
export function Tooltip({ label, children, className }: TooltipProps): ReactElement {
|
||||||
|
const [pos, setPos] = useState<{ top: number, left: number } | null>(null)
|
||||||
|
const wrapRef = useRef<HTMLSpanElement | null>(null)
|
||||||
|
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
|
||||||
|
const hide = useCallback((): void => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
setPos(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const show = useCallback((): void => {
|
||||||
|
if (!label)
|
||||||
|
return
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
const rect = wrapRef.current?.getBoundingClientRect()
|
||||||
|
if (rect)
|
||||||
|
setPos({ top: rect.top, left: rect.left + rect.width / 2 })
|
||||||
|
}, SHOW_DELAY_MS)
|
||||||
|
}, [label])
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(timerRef.current), [])
|
||||||
|
|
||||||
|
// 气泡渲染后才知道自身尺寸,这时才能定最终位置:默认在上方,
|
||||||
|
// 上方空间不够就翻到触发元素下方;水平居中后钳进视口。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pos)
|
||||||
|
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'
|
||||||
|
}, [pos])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={wrapRef}
|
||||||
|
className={cn('inline-flex shrink-0', className)}
|
||||||
|
onMouseEnter={show}
|
||||||
|
onMouseLeave={hide}
|
||||||
|
onMouseDown={hide}
|
||||||
|
onFocus={show}
|
||||||
|
onBlur={hide}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{pos && label && createPortal(
|
||||||
|
<div
|
||||||
|
ref={bubbleRef}
|
||||||
|
role="tooltip"
|
||||||
|
style={{ top: pos.top, left: pos.left, visibility: 'hidden' }}
|
||||||
|
className="pointer-events-none fixed z-50 max-w-64 rounded border border-[var(--vscode-editorHoverWidget-border,var(--vscode-panel-border))] bg-[var(--vscode-editorHoverWidget-background,var(--vscode-editorWidget-background))] px-2 py-1 text-xs leading-snug text-[var(--vscode-editorHoverWidget-foreground,var(--vscode-foreground))] shadow-md"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user