✨ feat(vscode): 审查列可以申请同事移交工单
This commit is contained in:
1 parent
65583a001c
commit
6342f39de0
22 files changed
+450
-31
No files matched your search
@@ -94,6 +94,10 @@ export function App() {
|
||||
requestHandoffUsers,
|
||||
startHandoff,
|
||||
acceptHandoff,
|
||||
askHandoff,
|
||||
cancelHandoffRequest,
|
||||
acceptHandoffRequest,
|
||||
rejectHandoffRequest,
|
||||
isHandoffRunning,
|
||||
} = useIssues()
|
||||
const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles()
|
||||
@@ -389,6 +393,10 @@ export function App() {
|
||||
onDependencyClear={clearDependency}
|
||||
me={me}
|
||||
avatars={avatars}
|
||||
onAskHandoff={askHandoff}
|
||||
onCancelHandoffRequest={cancelHandoffRequest}
|
||||
onAcceptHandoffRequest={acceptHandoffRequest}
|
||||
onRejectHandoffRequest={rejectHandoffRequest}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[40vh] shrink-0">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Ref } from 'react'
|
||||
import type { ReactNode, Ref } from 'react'
|
||||
import type { Issue } from '../types'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from '../../../src/panel/handlers/handoffRequest'
|
||||
import { Card } from './ui/card'
|
||||
|
||||
interface IssueCardProps {
|
||||
@@ -26,6 +27,11 @@ interface IssueCardProps {
|
||||
ownerLogin?: string
|
||||
/** 归属人头像 data URI;缺失时回退首字母圆点。 */
|
||||
ownerAvatar?: string
|
||||
me?: string
|
||||
onAskHandoff?: (issueNumber: number) => void
|
||||
onCancelHandoffRequest?: (issueNumber: number) => void
|
||||
onAcceptHandoffRequest?: (issueNumber: number) => void
|
||||
onRejectHandoffRequest?: (issueNumber: number) => void
|
||||
}
|
||||
|
||||
/** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */
|
||||
@@ -51,8 +57,69 @@ function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HandoffRequestActions({
|
||||
issue,
|
||||
me,
|
||||
onAsk,
|
||||
onCancel,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: {
|
||||
issue: Issue
|
||||
me?: string
|
||||
onAsk?: (issueNumber: number) => void
|
||||
onCancel?: (issueNumber: number) => void
|
||||
onAccept?: (issueNumber: number) => void
|
||||
onReject?: (issueNumber: number) => void
|
||||
}) {
|
||||
const button = 'rounded border border-[var(--vscode-contrastBorder)] px-1.5 py-0.5 text-[10px] hover:bg-[var(--vscode-toolbar-hoverBackground)]'
|
||||
let body: ReactNode = null
|
||||
if (canAnswerHandoffRequest(issue, me)) {
|
||||
body = (
|
||||
<>
|
||||
<span className="text-[10px] opacity-80">
|
||||
{issue.handoffRequestFrom}
|
||||
{' '}
|
||||
想要
|
||||
</span>
|
||||
{issue.handoffRequestError && issue.handoffRequestError !== 'rejected'
|
||||
? <span className="text-[10px] text-[var(--vscode-errorForeground)]">{issue.handoffRequestError}</span>
|
||||
: null}
|
||||
<button type="button" className={button} onClick={() => onAccept?.(issue.number)}>同意</button>
|
||||
<button type="button" className={button} onClick={() => onReject?.(issue.number)}>拒绝</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
else if (canCancelHandoffRequest(issue, me)) {
|
||||
body = (
|
||||
<>
|
||||
<span className="text-[10px] opacity-80">
|
||||
{issue.handoffRequestError && issue.handoffRequestError !== 'rejected' ? issue.handoffRequestError : '已申请'}
|
||||
</span>
|
||||
<button type="button" className={button} onClick={() => onCancel?.(issue.number)}>取消</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
else if (canAskHandoff(issue, me)) {
|
||||
body = <button type="button" className={button} onClick={() => onAsk?.(issue.number)}>申请移交</button>
|
||||
}
|
||||
else if (issue.column === 'review' && issue.handoffRequestFrom && issue.source !== 'youtrack' && !issue.handoffAttachmentId) {
|
||||
body = <span className="text-[10px] opacity-60">申请中</span>
|
||||
}
|
||||
if (!body)
|
||||
return null
|
||||
return (
|
||||
<div
|
||||
className="mb-1 flex flex-wrap items-center gap-1"
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
|
||||
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar }, ref) => {
|
||||
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar, me, onAskHandoff, onCancelHandoffRequest, onAcceptHandoffRequest, onRejectHandoffRequest }, ref) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging }
|
||||
= useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly })
|
||||
|
||||
@@ -111,6 +178,14 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<HandoffRequestActions
|
||||
issue={issue}
|
||||
me={me}
|
||||
onAsk={onAskHandoff}
|
||||
onCancel={onCancelHandoffRequest}
|
||||
onAccept={onAcceptHandoffRequest}
|
||||
onReject={onRejectHandoffRequest}
|
||||
/>
|
||||
<div className={cn('truncate', (locked || (readOnly && ownerLogin)) && 'pr-5', locked && readOnly && ownerLogin && 'pr-10')}>
|
||||
<span className="font-mono opacity-60">
|
||||
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
|
||||
|
||||
@@ -45,6 +45,10 @@ interface KanbanBoardProps {
|
||||
me?: string
|
||||
/** Gitea login → 头像 data URI。 */
|
||||
avatars?: Record<string, string>
|
||||
onAskHandoff?: (issueNumber: number) => void
|
||||
onCancelHandoffRequest?: (issueNumber: number) => void
|
||||
onAcceptHandoffRequest?: (issueNumber: number) => void
|
||||
onRejectHandoffRequest?: (issueNumber: number) => void
|
||||
}
|
||||
|
||||
const kanbanCollisionDetection: CollisionDetection = (args) => {
|
||||
@@ -119,6 +123,10 @@ export function KanbanBoard({
|
||||
onDependencyClear,
|
||||
me,
|
||||
avatars,
|
||||
onAskHandoff,
|
||||
onCancelHandoffRequest,
|
||||
onAcceptHandoffRequest,
|
||||
onRejectHandoffRequest,
|
||||
}: KanbanBoardProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
@@ -281,6 +289,11 @@ export function KanbanBoard({
|
||||
readOnly={readOnly}
|
||||
ownerLogin={ownerLogin}
|
||||
ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined}
|
||||
me={me}
|
||||
onAskHandoff={onAskHandoff}
|
||||
onCancelHandoffRequest={onCancelHandoffRequest}
|
||||
onAcceptHandoffRequest={onAcceptHandoffRequest}
|
||||
onRejectHandoffRequest={onRejectHandoffRequest}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ToastItem } from '../components/ToastStack'
|
||||
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
|
||||
import type { Issue, IssueColumn } from '../types'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { handoffRequestNotices } from '../../../src/panel/handlers/handoffRequest'
|
||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||
import { onMessage, postMessage } from '../lib/vscode'
|
||||
|
||||
@@ -320,6 +321,11 @@ export interface UseIssuesResult {
|
||||
startHandoff: (issueNumber: number, to: string) => void
|
||||
/** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */
|
||||
acceptHandoff: (issueNumber: number) => void
|
||||
/** 审查列里向同事申请把工单移交给自己。 */
|
||||
askHandoff: (issueNumber: number) => void
|
||||
cancelHandoffRequest: (issueNumber: number) => void
|
||||
acceptHandoffRequest: (issueNumber: number) => void
|
||||
rejectHandoffRequest: (issueNumber: number) => void
|
||||
/** 是否有移交/接管流程正在该工单上跑,直到扩展端回 `handoff/done` 才清除。 */
|
||||
isHandoffRunning: (issueNumber: number) => boolean
|
||||
}
|
||||
@@ -632,6 +638,18 @@ export function useIssues(): UseIssuesResult {
|
||||
postMessage({ type: 'handoff/start', issueNumber, to })
|
||||
}, [markHandoffRunning])
|
||||
|
||||
const askHandoff = useCallback((issueNumber: number): void => {
|
||||
postMessage({ type: 'handoff/request', issueNumber })
|
||||
}, [])
|
||||
const cancelHandoffRequest = useCallback((issueNumber: number): void => {
|
||||
postMessage({ type: 'handoff/request-cancel', issueNumber })
|
||||
}, [])
|
||||
const acceptHandoffRequest = useCallback((issueNumber: number): void => {
|
||||
postMessage({ type: 'handoff/request-accept', issueNumber })
|
||||
}, [])
|
||||
const rejectHandoffRequest = useCallback((issueNumber: number): void => {
|
||||
postMessage({ type: 'handoff/request-reject', issueNumber })
|
||||
}, [])
|
||||
const acceptHandoff = useCallback((issueNumber: number): void => {
|
||||
if (handoffRunningRef.current.has(issueNumber))
|
||||
return
|
||||
@@ -1042,5 +1060,32 @@ export function useIssues(): UseIssuesResult {
|
||||
return cleanup
|
||||
}, [clearPrDiffSummaryRunning, markHandoffRunning])
|
||||
|
||||
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
||||
const seenIssues = useRef<Issue[] | null>(null)
|
||||
useEffect(() => {
|
||||
if (state.status !== 'ready')
|
||||
return
|
||||
const prev = seenIssues.current
|
||||
seenIssues.current = state.issues
|
||||
if (!prev || !me)
|
||||
return
|
||||
const messages: string[] = []
|
||||
for (const next of state.issues) {
|
||||
const before = prev.find(issue => issue.number === next.number)
|
||||
if (before)
|
||||
messages.push(...handoffRequestNotices(before, next, me))
|
||||
}
|
||||
if (messages.length === 0)
|
||||
return
|
||||
setToasts(current => [
|
||||
...current,
|
||||
...messages.map(message => ({
|
||||
id: `handoff-request-${message}`,
|
||||
level: 'info' as const,
|
||||
message,
|
||||
dismissOnTimer: 8000,
|
||||
})),
|
||||
])
|
||||
}, [state, me])
|
||||
|
||||
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest }
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export type ExtensionToWebview
|
||||
| { type: 'issues/loaded' }
|
||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } }
|
||||
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||
| { type: 'session/tab-settled', sessionId: string }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
@@ -245,6 +245,10 @@ export type WebviewToExtension
|
||||
| { type: 'handoff/users', issueNumber: number }
|
||||
| { type: 'handoff/start', issueNumber: number, to: string }
|
||||
| { type: 'handoff/accept', issueNumber: number }
|
||||
| { type: 'handoff/request', issueNumber: number }
|
||||
| { type: 'handoff/request-cancel', issueNumber: number }
|
||||
| { type: 'handoff/request-accept', issueNumber: number }
|
||||
| { type: 'handoff/request-reject', issueNumber: number }
|
||||
| { type: 'session-handoff/users' }
|
||||
| { type: 'session-handoff/start', sessionId: string, to: string }
|
||||
| { type: 'session-handoff/accept', attachmentId: number }
|
||||
|
||||
@@ -103,6 +103,10 @@ export interface Issue {
|
||||
handoffAttachmentId?: string
|
||||
/** 发起移交的 Gitea login。 */
|
||||
handoffFrom?: string
|
||||
/** 谁在申请把这个审查工单移交给自己。 */
|
||||
handoffRequestFrom?: string
|
||||
/** 同意移交失败的原因。对方拒绝时为 rejected。 */
|
||||
handoffRequestError?: string
|
||||
/**
|
||||
* 各类会话 tab 是否当前在编辑器分组里打开(由 extension 通过 issue/patch 同步)。
|
||||
* 仅用于详情面板控制是否在会话 id 行右侧显示 "关闭 tab" 按钮,不持久化到 state JSON。
|
||||
|
||||
Reference in new issue
Block a user