✨ feat(vscode): 属性格动作值改双击触发+spinner反馈,热区收窄到内容
This commit is contained in:
@@ -95,7 +95,7 @@ interface IssueDetailPanelProps {
|
|||||||
*
|
*
|
||||||
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
|
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
|
||||||
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
|
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
|
||||||
* 一个 action 类型字段,点击或选中卡按 Enter 都会触发
|
* 一个 action 类型字段,双击或选中卡按 Enter 都会触发
|
||||||
* `claude --resume <id>` 在新终端中恢复对话。
|
* `claude --resume <id>` 在新终端中恢复对话。
|
||||||
*/
|
*/
|
||||||
export function IssueDetailPanel({
|
export function IssueDetailPanel({
|
||||||
@@ -137,7 +137,7 @@ export function IssueDetailPanel({
|
|||||||
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
|
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
|
||||||
|
|
||||||
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表,
|
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表,
|
||||||
// 防止用户连续快速点击同一行时触发多次 resume —— extension 端 findExistingTerminal
|
// 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
|
||||||
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
|
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
|
||||||
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。
|
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。
|
||||||
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
|
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
|
||||||
@@ -211,7 +211,7 @@ export function IssueDetailPanel({
|
|||||||
label: '头脑风暴会话id',
|
label: '头脑风暴会话id',
|
||||||
type: 'action' as const,
|
type: 'action' as const,
|
||||||
description: hasSession
|
description: hasSession
|
||||||
? '点击在新终端运行 claude --resume <id> 恢复对话'
|
? '双击在新终端运行 claude --resume <id> 恢复对话'
|
||||||
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab',
|
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab',
|
||||||
actionIcon: <Terminal className="size-3.5" />,
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
onAction: (v: unknown) => {
|
onAction: (v: unknown) => {
|
||||||
@@ -226,7 +226,7 @@ export function IssueDetailPanel({
|
|||||||
onSecondaryAction,
|
onSecondaryAction,
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
// 实施会话即使 worktree 已被清掉也保留可点击的 resume 入口:
|
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
|
||||||
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
|
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
|
||||||
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
|
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
|
||||||
// 给出不同的提示文案。
|
// 给出不同的提示文案。
|
||||||
@@ -235,7 +235,7 @@ export function IssueDetailPanel({
|
|||||||
label: '实施会话id',
|
label: '实施会话id',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
description: issue?.worktreeExists
|
description: issue?.worktreeExists
|
||||||
? '点击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
|
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
|
||||||
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
|
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
|
||||||
actionIcon: <Terminal className="size-3.5" />,
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
onAction: (v) => {
|
onAction: (v) => {
|
||||||
@@ -259,7 +259,7 @@ export function IssueDetailPanel({
|
|||||||
key: 'reviewSessionId',
|
key: 'reviewSessionId',
|
||||||
label: '审查会话id',
|
label: '审查会话id',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
description: '点击在新终端运行 codex resume <id> 查看审查会话(cwd 优先用 worktree)',
|
description: '双击在新终端运行 codex resume <id> 查看审查会话(cwd 优先用 worktree)',
|
||||||
actionIcon: <Terminal className="size-3.5" />,
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
onAction: (v) => {
|
onAction: (v) => {
|
||||||
if (typeof v !== 'string' || v.length === 0 || !issue)
|
if (typeof v !== 'string' || v.length === 0 || !issue)
|
||||||
@@ -287,7 +287,7 @@ export function IssueDetailPanel({
|
|||||||
(() => {
|
(() => {
|
||||||
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
|
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
|
||||||
// 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
|
// 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
|
||||||
// 2. testSessionId 已存在 + testTabOpen=false → 显示 id,点击 resume
|
// 2. testSessionId 已存在 + testTabOpen=false → 显示 id,双击 resume
|
||||||
// 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
|
// 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
|
||||||
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
|
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
|
||||||
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
|
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
|
||||||
@@ -316,7 +316,7 @@ export function IssueDetailPanel({
|
|||||||
label: '测试会话id',
|
label: '测试会话id',
|
||||||
type: 'action' as const,
|
type: 'action' as const,
|
||||||
description: hasSession
|
description: hasSession
|
||||||
? '点击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
|
? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
|
||||||
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
|
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
|
||||||
actionIcon: <Terminal className="size-3.5" />,
|
actionIcon: <Terminal className="size-3.5" />,
|
||||||
onAction: (v: unknown) => {
|
onAction: (v: unknown) => {
|
||||||
@@ -391,14 +391,14 @@ export function IssueDetailPanel({
|
|||||||
key: 'specFile',
|
key: 'specFile',
|
||||||
label: '规格文件',
|
label: '规格文件',
|
||||||
type: 'file-link',
|
type: 'file-link',
|
||||||
description: '点击文件名在编辑器打开;cc 通过 issue body 的 <!-- spx:spec=路径 --> 注释实时同步',
|
description: '双击文件名在编辑器打开;cc 通过 issue body 的 <!-- spx:spec=路径 --> 注释实时同步',
|
||||||
onOpen: (p: string) => onOpenFile(p),
|
onOpen: (p: string) => onOpenFile(p),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'planFile',
|
key: 'planFile',
|
||||||
label: '计划文件',
|
label: '计划文件',
|
||||||
type: 'file-link',
|
type: 'file-link',
|
||||||
description: '点击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
|
description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
|
||||||
onOpen: (p: string) => onOpenFile(p),
|
onOpen: (p: string) => onOpenFile(p),
|
||||||
// Hide the "实施" button entirely when there's no plan file.
|
// Hide the "实施" button entirely when there's no plan file.
|
||||||
secondaryActionIcon: issue?.planFile
|
secondaryActionIcon: issue?.planFile
|
||||||
@@ -426,7 +426,7 @@ export function IssueDetailPanel({
|
|||||||
key: 'pr',
|
key: 'pr',
|
||||||
label: '合并请求',
|
label: '合并请求',
|
||||||
type: 'pr-link',
|
type: 'pr-link',
|
||||||
description: '点击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识',
|
description: '双击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识',
|
||||||
onAction: (v) => {
|
onAction: (v) => {
|
||||||
if (typeof v !== 'string' || v.length === 0)
|
if (typeof v !== 'string' || v.length === 0)
|
||||||
return
|
return
|
||||||
@@ -451,7 +451,7 @@ export function IssueDetailPanel({
|
|||||||
key: 'prDiffFile',
|
key: 'prDiffFile',
|
||||||
label: 'PR 变更摘要',
|
label: 'PR 变更摘要',
|
||||||
type: 'file-link',
|
type: 'file-link',
|
||||||
description: '点击文件名在编辑器打开生成的 PR 代码变更摘要',
|
description: '双击文件名在编辑器打开生成的 PR 代码变更摘要',
|
||||||
onOpen: (p: string) => onOpenFile(p),
|
onOpen: (p: string) => onOpenFile(p),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -472,7 +472,7 @@ export function IssueDetailPanel({
|
|||||||
key: 'worktreePath',
|
key: 'worktreePath',
|
||||||
label: '工作树',
|
label: '工作树',
|
||||||
type: 'file-link',
|
type: 'file-link',
|
||||||
description: '点击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
|
description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
|
||||||
onOpen: (p: string) => onOpenWorktree(p),
|
onOpen: (p: string) => onOpenWorktree(p),
|
||||||
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
|
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
|
||||||
secondaryActionIcon: <Trash2 className="size-3.5" />,
|
secondaryActionIcon: <Trash2 className="size-3.5" />,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Info,
|
Info,
|
||||||
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
@@ -54,11 +55,11 @@ export interface PropertyDef {
|
|||||||
options?: Array<{ label: string, value: string | number }>
|
options?: Array<{ label: string, value: string | number }>
|
||||||
/** 是否只读 */
|
/** 是否只读 */
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
/** type='action' / 'pr-link' 专用:点击值时的回调 */
|
/** type='action' / 'pr-link' 专用:双击值时的回调 */
|
||||||
onAction?: (value: unknown) => void
|
onAction?: (value: unknown) => void
|
||||||
/** type='action' 专用:值前的图标 */
|
/** type='action' 专用:值前的图标 */
|
||||||
actionIcon?: ReactNode
|
actionIcon?: ReactNode
|
||||||
/** type='file-link' 专用:点击文件名时调用,参数为 value(workspace 相对路径) */
|
/** type='file-link' 专用:双击文件名时调用,参数为 value(workspace 相对路径) */
|
||||||
onOpen?: (path: string) => void
|
onOpen?: (path: string) => void
|
||||||
/** type='file-link' 专用:点击右侧图标按钮时调用,用于重新扫描/加载 */
|
/** type='file-link' 专用:点击右侧图标按钮时调用,用于重新扫描/加载 */
|
||||||
onReload?: () => void
|
onReload?: () => void
|
||||||
@@ -145,8 +146,27 @@ function PropertyRow({
|
|||||||
registerCellRef: (fullKey: string, el: HTMLDivElement | null) => void
|
registerCellRef: (fullKey: string, el: HTMLDivElement | null) => void
|
||||||
}) {
|
}) {
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [actionPending, setActionPending] = useState(false)
|
||||||
|
const pendingTimerRef = useRef<number | null>(null)
|
||||||
const ro = propDef.readOnly
|
const ro = propDef.readOnly
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (pendingTimerRef.current != null)
|
||||||
|
window.clearTimeout(pendingTimerRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 触发主动作并给出即时反馈:行内图标临时切换为 spinner,约 1.5s 后恢复 */
|
||||||
|
function firePrimaryAction(fire: () => void): void {
|
||||||
|
fire()
|
||||||
|
setActionPending(true)
|
||||||
|
if (pendingTimerRef.current != null)
|
||||||
|
window.clearTimeout(pendingTimerRef.current)
|
||||||
|
pendingTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActionPending(false)
|
||||||
|
pendingTimerRef.current = null
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
function renderControl(): ReactNode {
|
function renderControl(): ReactNode {
|
||||||
switch (propDef.type) {
|
switch (propDef.type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
@@ -293,18 +313,23 @@ function PropertyRow({
|
|||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
{hasValue
|
{hasValue
|
||||||
? (
|
? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
data-property-action="primary"
|
type="button"
|
||||||
onClick={() => propDef.onAction?.(value)}
|
data-property-action="primary"
|
||||||
disabled={ro}
|
onDoubleClick={() => firePrimaryAction(() => propDef.onAction?.(value))}
|
||||||
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1.5 text-left text-xs text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)] disabled:cursor-not-allowed disabled:opacity-50"
|
disabled={ro}
|
||||||
>
|
className="flex min-w-0 select-none items-center gap-1.5 px-2 py-1.5 text-left text-xs text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
{propDef.actionIcon}
|
>
|
||||||
<span className="truncate font-mono underline-offset-2 hover:underline">
|
{actionPending
|
||||||
{String(value)}
|
? <Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||||
</span>
|
: propDef.actionIcon}
|
||||||
</button>
|
<span className="truncate font-mono underline-offset-2 hover:underline">
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span className="min-w-0 flex-1" aria-hidden="true" />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-xs opacity-50">—</span>
|
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-xs opacity-50">—</span>
|
||||||
@@ -342,15 +367,19 @@ function PropertyRow({
|
|||||||
<div className="flex w-full items-center gap-1.5 px-2 py-1.5">
|
<div className="flex w-full items-center gap-1.5 px-2 py-1.5">
|
||||||
{hasValue
|
{hasValue
|
||||||
? (
|
? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
data-property-action="primary"
|
type="button"
|
||||||
onClick={() => propDef.onOpen?.(value as string)}
|
data-property-action="primary"
|
||||||
title={value as string}
|
onDoubleClick={() => firePrimaryAction(() => propDef.onOpen?.(value as string))}
|
||||||
className="min-w-0 flex-1 truncate text-left text-xs font-mono text-[var(--vscode-textLink-foreground)] underline-offset-2 hover:text-[var(--vscode-textLink-activeForeground)] hover:underline"
|
title={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"
|
||||||
{value as string}
|
>
|
||||||
</button>
|
{actionPending && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
||||||
|
<span className="truncate">{value as string}</span>
|
||||||
|
</button>
|
||||||
|
<span className="min-w-0 flex-1" aria-hidden="true" />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<span className="min-w-0 flex-1 truncate text-xs opacity-50">未加载</span>
|
<span className="min-w-0 flex-1 truncate text-xs opacity-50">未加载</span>
|
||||||
@@ -402,14 +431,18 @@ function PropertyRow({
|
|||||||
<div className="flex w-full items-center gap-1.5 px-2 py-1.5">
|
<div className="flex w-full items-center gap-1.5 px-2 py-1.5">
|
||||||
{hasValue
|
{hasValue
|
||||||
? (
|
? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
data-property-action="primary"
|
type="button"
|
||||||
onClick={() => propDef.onAction?.(value)}
|
data-property-action="primary"
|
||||||
className="min-w-0 flex-1 truncate text-left text-xs font-mono text-[var(--vscode-textLink-foreground)] underline-offset-2 hover:text-[var(--vscode-textLink-activeForeground)] hover:underline"
|
onDoubleClick={() => firePrimaryAction(() => propDef.onAction?.(value))}
|
||||||
>
|
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"
|
||||||
{`#${value as string}`}
|
>
|
||||||
</button>
|
{actionPending && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
||||||
|
<span className="truncate">{`#${value as string}`}</span>
|
||||||
|
</button>
|
||||||
|
<span className="min-w-0 flex-1" aria-hidden="true" />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<span className="min-w-0 flex-1 truncate text-xs opacity-50">未关联 PR</span>
|
<span className="min-w-0 flex-1 truncate text-xs opacity-50">未关联 PR</span>
|
||||||
@@ -506,7 +539,7 @@ function PropertyRow({
|
|||||||
if (isSelected)
|
if (isSelected)
|
||||||
return
|
return
|
||||||
onSelect(fullKey)
|
onSelect(fullKey)
|
||||||
// 点在 action/file-link/pr-link 按钮上:只选中,不拦截,让 click 落到 primary
|
// 点在 action/file-link/pr-link 按钮上:只选中,不拦截,让后续双击落到 primary
|
||||||
const t = e.target
|
const t = e.target
|
||||||
if (t instanceof Element && t.closest('[data-property-action]'))
|
if (t instanceof Element && t.closest('[data-property-action]'))
|
||||||
return
|
return
|
||||||
@@ -664,7 +697,8 @@ export function PropertyGrid({
|
|||||||
return
|
return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
action.click()
|
// 主动作按双击触发,Enter 派发 dblclick 走同一条路径
|
||||||
|
action.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
||||||
}
|
}
|
||||||
// ArrowLeft / ArrowRight / 其他键:不处理,让事件继续冒泡给 App 全局监听
|
// ArrowLeft / ArrowRight / 其他键:不处理,让事件继续冒泡给 App 全局监听
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user