11
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { CircleSlash, ExternalLink, GitMerge, Play, Terminal, Trash2, X } from 'lucide-react'
|
||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||
import type { Issue, IssueColumn } from '../types'
|
||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||
import { isIssueLocked } from '../lib/dependencies'
|
||||
import type { PropertyGroup } from './property-grid'
|
||||
import { PropertyGrid } from './property-grid'
|
||||
|
||||
interface IssueDetailPanelProps {
|
||||
issue: Issue | null
|
||||
/** 当前所有 issues,用于解析前置依赖锁定态。 */
|
||||
allIssues: Issue[]
|
||||
/** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */
|
||||
globalAutoReview: boolean
|
||||
onOpenInBrowser: (url: string) => void
|
||||
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||||
/** Open a new terminal that runs `codex resume <id>` for the auto-review
|
||||
* conversation associated with this issue. */
|
||||
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||
/** Open a new terminal that runs `claude --resume <id>` for the test
|
||||
* conversation associated with this issue. */
|
||||
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||
/** Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the
|
||||
* Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). */
|
||||
onStartTestSession: (issueNumber: number) => void
|
||||
/** Open a workspace-relative file in the editor. */
|
||||
onOpenFile: (path: string) => void
|
||||
/** Kick off the end-to-end implementation flow for the given plan file. */
|
||||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||
/** Open the gitea PR page in the browser. */
|
||||
onOpenPr: (pr: string) => void
|
||||
/** Start a background Claude run that writes the PR diff summary markdown. */
|
||||
onGeneratePrDiffSummary: (issueNumber: number) => void
|
||||
/** Whether a PR diff summary generation request is in flight for an issue. */
|
||||
isPrDiffSummaryRunning: (issueNumber: number) => boolean
|
||||
/** Open the workspace-relative worktree path in a new VS Code window. */
|
||||
onOpenWorktree: (path: string) => void
|
||||
/** Delete the worktree (`git worktree remove`) and clear it from state. */
|
||||
onDeleteWorktree: (issueNumber: number, path: string) => void
|
||||
/** Pre-merge the issue's feature branch into the main worktree's current branch
|
||||
* (`git merge --no-commit --no-ff <branch>`) so the user can inspect locally. */
|
||||
onMergeBranch: (issueNumber: number, branch: string) => void
|
||||
/** 硬删整个工单 + 关联资源(worktree / PR / feature branch / cc tabs)。
|
||||
* 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。 */
|
||||
onDeleteIssue?: (issueNumber: number) => void
|
||||
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
||||
onCloseIssue: (issueNumber: number) => void
|
||||
/** Close the matching session terminal tab. The extension watches
|
||||
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
|
||||
* makes the X button vanish on its own. */
|
||||
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||||
/** Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty
|
||||
* (created out-of-band via spx without webhook linking). Bound to the Play
|
||||
* button next to the "头脑风暴会话 id" row. */
|
||||
onStartBrainstormSession: (issueNumber: number) => void
|
||||
/** Persist a per-issue `autoReview` override into the state JSON. */
|
||||
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
||||
/** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */
|
||||
profiles: ClaudeProfile[]
|
||||
/** Persist a per-issue `profilePath` override into the state JSON. */
|
||||
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
|
||||
/** Persist a per-issue `testProfilePath` override into the state JSON. */
|
||||
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||||
/** Open the in-webview log modal. */
|
||||
onOpenLogs: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
|
||||
*
|
||||
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
|
||||
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
|
||||
* 一个 action 类型字段,点击或选中卡按 Enter 都会触发
|
||||
* `claude --resume <id>` 在新终端中恢复对话。
|
||||
*/
|
||||
export function IssueDetailPanel({
|
||||
issue,
|
||||
allIssues,
|
||||
globalAutoReview,
|
||||
onOpenInBrowser,
|
||||
onResumeSession,
|
||||
onResumeReviewSession,
|
||||
onResumeTestSession,
|
||||
onStartTestSession,
|
||||
onOpenFile,
|
||||
onImplement,
|
||||
onOpenPr,
|
||||
onGeneratePrDiffSummary,
|
||||
isPrDiffSummaryRunning,
|
||||
onOpenWorktree,
|
||||
onDeleteWorktree,
|
||||
onMergeBranch,
|
||||
onDeleteIssue,
|
||||
onCloseIssue,
|
||||
onCloseSessionTab,
|
||||
onStartBrainstormSession,
|
||||
onUpdateAutoReview,
|
||||
profiles,
|
||||
onUpdateProfilePath,
|
||||
onUpdateTestProfilePath,
|
||||
onOpenLogs,
|
||||
}: IssueDetailPanelProps) {
|
||||
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
|
||||
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
|
||||
const { locked, prerequisiteNumber } = issue
|
||||
? isIssueLocked(issue, allIssues)
|
||||
: { locked: false, prerequisiteNumber: undefined as number | undefined }
|
||||
const lockTitle = locked && prerequisiteNumber != null
|
||||
? `等待 #${prerequisiteNumber} 完成`
|
||||
: undefined
|
||||
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
|
||||
|
||||
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表,
|
||||
// 防止用户连续快速点击同一行时触发多次 resume —— extension 端 findExistingTerminal
|
||||
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
|
||||
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。
|
||||
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
|
||||
const RESUME_THROTTLE_MS = 800
|
||||
const throttleBySessionId = (sid: string): boolean => {
|
||||
const now = Date.now()
|
||||
const last = lastFiredAtRef.current.get(sid) ?? 0
|
||||
if (now - last < RESUME_THROTTLE_MS)
|
||||
return false
|
||||
lastFiredAtRef.current.set(sid, now)
|
||||
return true
|
||||
}
|
||||
|
||||
const schema = useMemo<PropertyGroup[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'state',
|
||||
label: 'state JSON',
|
||||
actions: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLogs}
|
||||
title="查看扩展日志"
|
||||
aria-label="查看扩展日志"
|
||||
className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
properties: [
|
||||
{
|
||||
key: 'column',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
readOnly: true,
|
||||
description: '工单所在的看板列;写入 issue 最后一条 JSON 评论的 column 字段',
|
||||
options: COLUMN_ORDER.map((c: IssueColumn) => ({
|
||||
label: `${c}(${COLUMN_LABELS[c]})`,
|
||||
value: c,
|
||||
})),
|
||||
},
|
||||
(() => {
|
||||
// 头脑风暴会话 id 行有三种状态:
|
||||
// 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab
|
||||
// 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮)
|
||||
// 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab)
|
||||
// 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮
|
||||
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
|
||||
const canStart = !hasSession && issue?.column !== 'done'
|
||||
let secondaryActionIcon: ReactNode | undefined
|
||||
let secondaryActionTitle: string | undefined
|
||||
let onSecondaryAction: (() => void) | undefined
|
||||
if (hasSession && issue?.brainstormTabOpen) {
|
||||
secondaryActionIcon = <X className="size-3.5" />
|
||||
secondaryActionTitle = '关闭头脑风暴 tab'
|
||||
onSecondaryAction = () => {
|
||||
if (issue)
|
||||
onCloseSessionTab(issue.number, 'brainstorm')
|
||||
}
|
||||
}
|
||||
else if (canStart) {
|
||||
secondaryActionIcon = <Play className="size-3.5" />
|
||||
secondaryActionTitle = '启动头脑风暴会话'
|
||||
onSecondaryAction = () => {
|
||||
if (issue)
|
||||
onStartBrainstormSession(issue.number)
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: 'sessionId',
|
||||
label: '头脑风暴会话id',
|
||||
type: 'action' as const,
|
||||
description: hasSession
|
||||
? '点击在新终端运行 claude --resume <id> 恢复对话'
|
||||
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab',
|
||||
actionIcon: <Terminal className="size-3.5" />,
|
||||
onAction: (v: unknown) => {
|
||||
if (typeof v !== 'string' || v.length === 0)
|
||||
return
|
||||
if (!throttleBySessionId(v))
|
||||
return
|
||||
onResumeSession(v, issue?.profilePath, undefined, issue?.number)
|
||||
},
|
||||
secondaryActionIcon,
|
||||
secondaryActionTitle,
|
||||
onSecondaryAction,
|
||||
}
|
||||
})(),
|
||||
// 实施会话即使 worktree 已被清掉也保留可点击的 resume 入口:
|
||||
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
|
||||
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
|
||||
// 给出不同的提示文案。
|
||||
{
|
||||
key: 'implementSessionId',
|
||||
label: '实施会话id',
|
||||
type: 'action',
|
||||
description: issue?.worktreeExists
|
||||
? '点击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
|
||||
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
|
||||
actionIcon: <Terminal className="size-3.5" />,
|
||||
onAction: (v) => {
|
||||
if (typeof v !== 'string' || v.length === 0)
|
||||
return
|
||||
if (!throttleBySessionId(v))
|
||||
return
|
||||
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
|
||||
},
|
||||
secondaryActionIcon: issue?.implementTabOpen
|
||||
? <X className="size-3.5" />
|
||||
: undefined,
|
||||
secondaryActionTitle: '关闭实施 tab',
|
||||
onSecondaryAction: () => {
|
||||
if (issue)
|
||||
onCloseSessionTab(issue.number, 'implement')
|
||||
},
|
||||
},
|
||||
issue?.worktreeExists
|
||||
? {
|
||||
key: 'reviewSessionId',
|
||||
label: '审查会话id',
|
||||
type: 'action',
|
||||
description: '点击在新终端运行 codex resume <id> 查看审查会话(cwd 优先用 worktree)',
|
||||
actionIcon: <Terminal className="size-3.5" />,
|
||||
onAction: (v) => {
|
||||
if (typeof v !== 'string' || v.length === 0 || !issue)
|
||||
return
|
||||
if (!throttleBySessionId(v))
|
||||
return
|
||||
onResumeReviewSession(v, issue.number, issue?.worktreePath)
|
||||
},
|
||||
secondaryActionIcon: issue?.reviewTabOpen
|
||||
? <X className="size-3.5" />
|
||||
: undefined,
|
||||
secondaryActionTitle: '关闭审查 tab',
|
||||
onSecondaryAction: () => {
|
||||
if (issue)
|
||||
onCloseSessionTab(issue.number, 'review')
|
||||
},
|
||||
}
|
||||
: {
|
||||
key: 'reviewSessionId',
|
||||
label: '审查会话id',
|
||||
type: 'string',
|
||||
readOnly: true,
|
||||
description: 'worktree 已清理,无法 resume;仅保留 id 文本',
|
||||
},
|
||||
(() => {
|
||||
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
|
||||
// 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
|
||||
// 2. testSessionId 已存在 + testTabOpen=false → 显示 id,点击 resume
|
||||
// 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
|
||||
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
|
||||
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
|
||||
const canStart = !hasSession && !!issue?.pr
|
||||
let secondaryActionIcon: ReactNode | undefined
|
||||
let secondaryActionTitle: string | undefined
|
||||
let onSecondaryAction: (() => void) | undefined
|
||||
if (hasSession && issue?.testTabOpen) {
|
||||
secondaryActionIcon = <X className="size-3.5" />
|
||||
secondaryActionTitle = '关闭测试 tab'
|
||||
onSecondaryAction = () => {
|
||||
if (issue)
|
||||
onCloseSessionTab(issue.number, 'test')
|
||||
}
|
||||
}
|
||||
else if (canStart) {
|
||||
secondaryActionIcon = <Play className="size-3.5" />
|
||||
secondaryActionTitle = '启动测试会话'
|
||||
onSecondaryAction = () => {
|
||||
if (issue)
|
||||
onStartTestSession(issue.number)
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: 'testSessionId',
|
||||
label: '测试会话id',
|
||||
type: 'action' as const,
|
||||
description: hasSession
|
||||
? '点击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
|
||||
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
|
||||
actionIcon: <Terminal className="size-3.5" />,
|
||||
onAction: (v: unknown) => {
|
||||
if (typeof v !== 'string' || v.length === 0 || !issue)
|
||||
return
|
||||
if (!throttleBySessionId(v))
|
||||
return
|
||||
onResumeTestSession(v, issue.number)
|
||||
},
|
||||
secondaryActionIcon,
|
||||
secondaryActionTitle,
|
||||
onSecondaryAction,
|
||||
}
|
||||
})(),
|
||||
{
|
||||
key: 'autoReview',
|
||||
label: '自动审查',
|
||||
type: 'boolean',
|
||||
description: `打开新 PR 时是否自动启动 codex 审查;未设置时跟随全局(当前全局:${globalAutoReview ? '开' : '关'})`,
|
||||
},
|
||||
{
|
||||
key: 'color',
|
||||
label: '颜色',
|
||||
type: 'theme-color',
|
||||
readOnly: true,
|
||||
description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色',
|
||||
},
|
||||
{
|
||||
key: 'profilePath',
|
||||
label: '实施配置文件',
|
||||
type: 'select',
|
||||
options: (() => {
|
||||
// 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何
|
||||
// option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发
|
||||
// onChange、永远存不进去;占位项让未设置态有真实匹配项。
|
||||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||||
const current = issue?.profilePath
|
||||
// 自定义路径兜底:state JSON 里存的 profile 不在已知列表时,
|
||||
// 额外追加一项以免下拉显示空白、丢失当前选择。
|
||||
if (current && !opts.some(o => o.value === current))
|
||||
opts.push({ label: `自定义:${current}`, value: current })
|
||||
return opts
|
||||
})(),
|
||||
description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认(offical),选具体 profile 则覆盖(持久化 state JSON)',
|
||||
},
|
||||
{
|
||||
key: 'testProfilePath',
|
||||
label: '测试配置文件',
|
||||
type: 'select',
|
||||
options: (() => {
|
||||
// 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何
|
||||
// option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发
|
||||
// onChange、永远存不进去;占位项让未设置态有真实匹配项。
|
||||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||||
const current = issue?.testProfilePath
|
||||
// 自定义路径兜底:state JSON 里存的 profile 不在已知列表时,
|
||||
// 额外追加一项以免下拉显示空白、丢失当前选择。
|
||||
if (current && !opts.some(o => o.value === current))
|
||||
opts.push({ label: `自定义:${current}`, value: current })
|
||||
return opts
|
||||
})(),
|
||||
description: '测试会话使用的 Claude 配置文件;留空时回退实施配置文件',
|
||||
},
|
||||
{
|
||||
key: 'specFile',
|
||||
label: '规格文件',
|
||||
type: 'file-link',
|
||||
description: '点击文件名在编辑器打开;cc 通过 issue body 的 <!-- spx:spec=路径 --> 注释实时同步',
|
||||
onOpen: (p: string) => onOpenFile(p),
|
||||
},
|
||||
{
|
||||
key: 'planFile',
|
||||
label: '计划文件',
|
||||
type: 'file-link',
|
||||
description: '点击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
|
||||
onOpen: (p: string) => onOpenFile(p),
|
||||
// Hide the "实施" button entirely when there's no plan file.
|
||||
secondaryActionIcon: issue?.planFile
|
||||
? <Play className="size-3.5" />
|
||||
: undefined,
|
||||
secondaryActionTitle: locked ? lockTitle : '实施此计划',
|
||||
// Disable while running or already done. The user can still click
|
||||
// when status === 'failed' or never been run. Re-clicking while
|
||||
// 'running' is blocked at the UI to avoid registering duplicate
|
||||
// webhooks. To retry after a perceived failure with status still
|
||||
// 'running', edit the state-JSON comment manually for now.
|
||||
// 锁定态(前置工单未完成)同样禁止启动实施。
|
||||
secondaryDisabled:
|
||||
!issue?.planFile
|
||||
|| issue?.implementStatus === 'running'
|
||||
|| issue?.implementStatus === 'done'
|
||||
|| locked,
|
||||
onSecondaryAction: () => {
|
||||
if (!issue?.planFile)
|
||||
return
|
||||
onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pr',
|
||||
label: '合并请求',
|
||||
type: 'pr-link',
|
||||
description: '点击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识',
|
||||
onAction: (v) => {
|
||||
if (typeof v !== 'string' || v.length === 0)
|
||||
return
|
||||
// value 形如 "65" 或 "65(已合并)";剥掉合并后缀只把纯编号传出去。
|
||||
const pr = v.replace(/\(已合并\)\s*$/, '')
|
||||
if (pr.length > 0)
|
||||
onOpenPr(pr)
|
||||
},
|
||||
secondaryActionIcon: issue?.pr && !issue?.prDiffFile
|
||||
? <Play className="size-3.5" />
|
||||
: undefined,
|
||||
secondaryActionTitle: prDiffSummaryRunning
|
||||
? 'PR 变更摘要生成中'
|
||||
: '生成 PR 变更摘要',
|
||||
secondaryDisabled: prDiffSummaryRunning,
|
||||
onSecondaryAction: () => {
|
||||
if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
|
||||
onGeneratePrDiffSummary(issue.number)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prDiffFile',
|
||||
label: 'PR 变更摘要',
|
||||
type: 'file-link',
|
||||
description: '点击文件名在编辑器打开生成的 PR 代码变更摘要',
|
||||
onOpen: (p: string) => onOpenFile(p),
|
||||
},
|
||||
{
|
||||
key: 'branch',
|
||||
label: '分支',
|
||||
type: 'string',
|
||||
readOnly: true,
|
||||
description: '实施流程创建的分支名',
|
||||
secondaryActionIcon: issue?.branch ? <GitMerge className="size-3.5" /> : undefined,
|
||||
secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)',
|
||||
onSecondaryAction: () => {
|
||||
if (issue?.branch)
|
||||
onMergeBranch(issue.number, issue.branch)
|
||||
},
|
||||
},
|
||||
issue?.worktreeExists
|
||||
? {
|
||||
key: 'worktreePath',
|
||||
label: '工作树',
|
||||
type: 'file-link',
|
||||
description: '点击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
|
||||
onOpen: (p: string) => onOpenWorktree(p),
|
||||
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
|
||||
secondaryActionIcon: <Trash2 className="size-3.5" />,
|
||||
secondaryActionTitle: '删除 worktree',
|
||||
}
|
||||
: {
|
||||
key: 'worktreePath',
|
||||
label: '工作树',
|
||||
type: 'string',
|
||||
readOnly: true,
|
||||
description: '实施流程创建的 git worktree 路径(workspace 相对)',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
|
||||
)
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
|
||||
按方向键或点击卡片查看详情
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
column: issue.column,
|
||||
sessionId: issue.sessionId ?? null,
|
||||
implementSessionId: issue.implementSessionId ?? null,
|
||||
reviewSessionId: issue.reviewSessionId ?? null,
|
||||
testSessionId: issue.testSessionId ?? null,
|
||||
autoReview: issue.autoReview ?? globalAutoReview,
|
||||
color: issue.color ?? null,
|
||||
profilePath: issue.profilePath ?? null,
|
||||
testProfilePath: issue.testProfilePath ?? null,
|
||||
specFile: issue.specFile ?? null,
|
||||
planFile: issue.planFile ?? null,
|
||||
prDiffFile: issue.prDiffFile ?? null,
|
||||
pr: issue.pr
|
||||
? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
|
||||
: null,
|
||||
branch: issue.branch ?? null,
|
||||
worktreePath: issue.worktreePath ?? null,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 border-t border-[var(--vscode-panel-border)] p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="flex-1 truncate text-sm font-medium">
|
||||
<span className="font-mono opacity-60">
|
||||
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
|
||||
</span>
|
||||
{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"
|
||||
>
|
||||
YouTrack
|
||||
</span>
|
||||
)}
|
||||
{' '}
|
||||
{issue.title}
|
||||
{locked && prerequisiteNumber != null
|
||||
? (
|
||||
<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}
|
||||
>
|
||||
等待 #
|
||||
{prerequisiteNumber}
|
||||
{' '}
|
||||
完成
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
||||
</button>
|
||||
{/* 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' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCloseIssue(issue.number)}
|
||||
title="关闭工单"
|
||||
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"
|
||||
>
|
||||
<CircleSlash className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => issue && onDeleteIssue?.(issue.number)}
|
||||
title="删除工单(不可撤销)"
|
||||
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"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<span className="opacity-60">附件({issue.attachments.length})</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{issue.attachments.map(att => (
|
||||
<button
|
||||
key={att.url}
|
||||
type="button"
|
||||
onClick={() => onOpenInBrowser(att.url)}
|
||||
title={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" />
|
||||
<span className="truncate">{att.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<PropertyGrid
|
||||
schema={schema}
|
||||
data={data}
|
||||
hideToolbar
|
||||
fillHeight
|
||||
onChange={(key, value) => {
|
||||
if (key === 'autoReview' && issue && typeof value === 'boolean')
|
||||
onUpdateAutoReview(issue.number, value)
|
||||
if (key === 'profilePath' && issue && typeof value === 'string')
|
||||
onUpdateProfilePath(issue.number, value)
|
||||
if (key === 'testProfilePath' && issue && typeof value === 'string')
|
||||
onUpdateTestProfilePath(issue.number, value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user