From 3aeaf9c9f61dabe9c75ee57f66a90ec72d7c5947 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 12:50:11 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(pr):=20=E8=AF=84=E8=AE=BA=20tab=20?= =?UTF-8?q?=E6=BC=94=E8=BF=9B=E4=B8=BA=E6=B4=BB=E5=8A=A8=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 PR 详情「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):评论、 提交更新、reviewer 评审决断(approve / needs-work / unapprove / dismiss)按时间 倒序归并为一条时间线,保留原有评论内容、排序与编辑 / 回复 / 删除 / 内联代码能力。 - 平台契约:新增 PrActivityEvent 类型与 listPullRequestActivity 方法;GitHub 取自 /pulls/{n}/reviews、Bitbucket 取自 /activities(带时间戳的决断事件)。 - IPC:新增 diff:listActivity 通道 + controller + 注册。 - 差异化:新增 capabilities.activityTimeline;GitLab 无统一活动事件源(CE 无审批、 审批系统 note 解析脆弱)→ 标签页保持纯「评论」视图,不混入提交 / 决断。 - 视觉:各条目统一「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,竖向虚线轨连接 相邻条目;评论标题统一「xxx 评论」+ 评论图标,正文整体缩进成挂在轨上的卡片。 - 提交另保留独立「提交」标签页。 - i18n:四语言补 tabActivity / activityPanel 文案。 - 约定:mapBB* 统一改名为 mapBitbucket*。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + apps/desktop/src/main/controllers/pr.ts | 14 + apps/desktop/src/main/ipc.ts | 1 + .../renderer/src/components/common/icons.tsx | 21 + .../src/components/features/pr/PrPanel.tsx | 7 +- .../components/features/pr/tabs/PrTabs.tsx | 16 +- .../pr/tabs/activity/ActivityPanel.tsx | 283 ++++++++++++ .../features/pr/tabs/comments/CommentItem.tsx | 331 ++++++++++++++ .../pr/tabs/comments/CommentsPanel.tsx | 415 ------------------ .../src/renderer/src/i18n/locales/de-DE.json | 14 + .../src/renderer/src/i18n/locales/en-US.json | 14 + .../src/renderer/src/i18n/locales/ja-JP.json | 14 + .../src/renderer/src/i18n/locales/zh-CN.json | 14 + .../renderer/src/styles/layout/main-pane.scss | 115 +++++ packages/ipc/src/pr.ts | 10 + .../platform-bitbucket-server/src/adapter.ts | 53 ++- packages/platform-github/src/adapter.ts | 32 ++ packages/platform-gitlab/src/adapter.ts | 10 + packages/poller/tests/poller.test.ts | 8 + packages/shared/src/platform.ts | 43 ++ 20 files changed, 985 insertions(+), 436 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx delete mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a9c01..ac2e77f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ## [Unreleased] +### Added + +- PR 详情「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):把评论、提交更新、reviewer 评审决断(approve / needs-work / unapprove / dismiss)按时间倒序归并为一条活动时间线,保留原有评论内容、排序与编辑 / 回复 / 删除 / 内联代码能力。新增统一的 `listPullRequestActivity` 平台契约——GitHub 取自 `/pulls/{n}/reviews`、Bitbucket 取自 `/activities`(带时间戳的决断事件)。提交另保留独立「提交」标签页。 + - 视觉:各条目统一为「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,一条竖向虚线轨贯穿图标列连接相邻条目;评论标题统一为「xxx 评论」并前置评论图标,正文整体缩进成挂在轨上的卡片;作者头像 / 文本与评论主体人一致,不做差异化。 + - GitLab 走差异化设计:无统一活动事件源(CE 无审批、审批系统 note 解析脆弱),标签页保持纯「评论」视图(`capabilities.activityTimeline=false`),不混入提交 / 决断。 + ### Changed - **前端代码结构重构(可维护性)**:纯结构调整,对外接口与界面 / 交互行为均不变。重点: diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 66d946d..a386c1c 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -242,6 +242,20 @@ export const listCommits: IpcController<'diff:listCommits'> = async (_event, req ); }; +/** + * 拉评审决断活动事件(approve / needs-work / unapprove / dismiss)。不缓存,量小; + * 进活动时间线时与评论 / 提交归并。平台取不到历史决断时 adapter 返回 []。 + */ +export const listActivity: IpcController<'diff:listActivity'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + return adapter.listPullRequestActivity( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); +}; + /** * 本地 git 算 PR 引入提交数(base=targetRef.sha 排除合入的目标提交);镜像未齐返回 null。 */ diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 7651c76..6bdaedf 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -72,6 +72,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('diff:commentCountCached', pr.getCommentCountCached); // 评论数角标(仅缓存) ipcMain.handle('diff:listComments', pr.listComments); // 拉评论(缓存 + in-flight 去重) ipcMain.handle('diff:listCommits', pr.listCommits); // 提交列表 + ipcMain.handle('diff:listActivity', pr.listActivity); // 评审决断活动事件(时间线) ipcMain.handle('diff:commitCount', pr.getCommitCount); // 提交数角标(本地 git) ipcMain.handle('diff:getBlame', pr.getBlame); // blame + PR 引入行 ipcMain.handle('repo:getTotalSize', pr.getTotalSize); // 本地镜像总占用(设置页) diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx index d3c36e7..44e2acd 100644 --- a/apps/desktop/src/renderer/src/components/common/icons.tsx +++ b/apps/desktop/src/renderer/src/components/common/icons.tsx @@ -408,6 +408,27 @@ export function NeedsWorkIcon({ size = 14 }: IconProps) { ); } +/** git commit 字形:横线上一个实心节点(活动时间线提交事件用)。 */ +export function CommitIcon({ size = 14 }: IconProps) { + return ( + + ); +} + /** 机器人头像:AutoPilot 启用态。天线 + 头框 + 双眼 + 两侧耳。 */ export function RobotIcon({ size = 14 }: IconProps) { return ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx index f0807e6..59c8c4f 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -4,7 +4,7 @@ import type { LocalPrStatus, PlatformCapabilities, StoredPullRequest } from '@me import { invoke } from '../../../api'; import { useDraftsForPr } from '../../../stores/drafts-store'; import { PaneLoading } from '../../common/Loading'; -import { CommentsPanel } from './tabs/comments/CommentsPanel'; +import { ActivityPanel } from './tabs/activity/ActivityPanel'; import { CommitsPanel } from './tabs/CommitsPanel'; // Monaco 编辑器(~10MB)懒加载:只有真正切到 Diff tab 才拉取 DiffView chunk, // 不阻塞窗口首帧 / PR 列表 / 首启向导。 @@ -146,6 +146,7 @@ export function PrPanel({ commitCount={commitCount} totalDraftCount={totalDraftCount} publishableCount={publishableCount} + activityTimeline={capabilities?.activityTimeline ?? false} showWhitespace={showWhitespace} onToggleWhitespace={() => setShowWhitespace((b) => !b)} showBlame={showBlame} @@ -169,8 +170,8 @@ export function PrPanel({ /> - - + setCommentCount(n)} capabilities={capabilities} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx index 1287777..41d866d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { PersonIcon, WhitespaceIcon } from '../../../common/icons'; -export type PrTab = 'diff' | 'comments' | 'drafts' | 'commits' | 'info'; +export type PrTab = 'diff' | 'activity' | 'drafts' | 'commits' | 'info'; /** * PR 详情 tab 栏:diff / 评论 / 草稿 / 提交 / 信息(带计数徽标),diff tab 时右侧附带 @@ -14,6 +14,7 @@ export function PrTabs({ commitCount, totalDraftCount, publishableCount, + activityTimeline, showWhitespace, onToggleWhitespace, showBlame, @@ -27,6 +28,8 @@ export function PrTabs({ commitCount: number | null; totalDraftCount: number; publishableCount: number; + /** 该平台是否提供活动时间线(见 capabilities.activityTimeline);否则该 tab 标题退化为「评论」 */ + activityTimeline: boolean; showWhitespace: boolean; onToggleWhitespace: () => void; showBlame: boolean; @@ -46,15 +49,16 @@ export function PrTabs({ > {t('mainPane.tabDiff')} - {/* comments 在 commits 前:评审决断时评论的权重大于 commit 时间线 */} + {/* 活动时间线(评论 + 提交 + 评审决断)在 commits 前:评审决断时讨论权重大于纯 commit 列表。 + 角标仍取评论数——讨论量最具行动指引,提交另有独立 tab 计数。 */} + )} + {canEdit && !replyOpen && ( + + )} + {/* 删除按钮在最后,跟其它按钮风格对齐 — disable 期间文案变"删除中…" */} + {canDelete && !replyOpen && ( + + )} + + ) : null; + + const deleteErrorEl = deleteError ? ( +
+ {t('commentsPanel.deleteFailed', { msg: deleteError })} + +
+ ) : null; + + const replyEditor = replyOpen ? ( + setReplyOpen(false)} + onPosted={() => setReplyOpen(false)} + /> + ) : null; + + const repliesEl = + comment.replies.length > 0 ? ( + // 满 MAX_REPLY_DEPTH 层后用 pr-comments-flat 取代 pr-comments-replies(不再缩进 / 左边框)→ + // 更深回复拉平在该层缩进上;pr-comments-flat 据此给同层级相邻评论加横向分割线区分。 +
    + {comment.replies.map((r) => ( + + ))} +
+ ) : null; + + const confirmModalEl = confirmDelete ? ( + void handleDelete()} + onCancel={() => setConfirmDelete(false)} + /> + ) : null; + + // 时间线模式的顶层评论:与其它事件统一的标题行(图标 + 头像 + 作者 + 『评论』+ 时间),正文挂成缩进卡片。 + if (timeline && depth === 0) { + return ( +
  • +
    + + +
    + {comment.author.displayName} + {t('activityPanel.verb.commented')} + {anchorChip} +
    + +
    +
    + {inlineCode} + {bodyOrEdit} + {foot} + {deleteErrorEl} + {replyEditor} + {repliesEl} +
    + {confirmModalEl} +
  • + ); + } + + return ( +
  • +
    + + {comment.author.displayName} + {anchorChip} + +
    + {inlineCode} + {bodyOrEdit} + {foot} + {deleteErrorEl} + {replyEditor} + {repliesEl} + {confirmModalEl} +
  • + ); +} + +/** + * 相对时间文案(刚刚 / N 分钟前 / …),一周以上回退本地日期。供评论与活动事件共用。 + */ +export function formatRelativeTime(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const diffSec = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (diffSec < 60) return i18n.t('commentsPanel.justNow'); + if (diffSec < 3600) + return i18n.t('commentsPanel.minutesAgo', { count: Math.round(diffSec / 60) }); + if (diffSec < 86400) + return i18n.t('commentsPanel.hoursAgo', { count: Math.round(diffSec / 3600) }); + if (diffSec < 86400 * 7) + return i18n.t('commentsPanel.daysAgo', { count: Math.round(diffSec / 86400) }); + return new Date(t).toLocaleDateString(); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx deleted file mode 100644 index 4c968a4..0000000 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentsPanel.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import ReactMarkdown from 'react-markdown'; -import remarkBreaks from 'remark-breaks'; -import remarkGfm from 'remark-gfm'; -import type { PlatformCapabilities, PrComment, StoredPullRequest } from '@meebox/shared'; -import { invoke, subscribe } from '../../../../../api'; -import i18n from '../../../../../i18n'; -import { formatBackendError, type FormattedError } from '../../../../../errors'; -import { REMOTE_REHYPE_PLUGINS } from '../../../../../lib/markdown'; -import { Avatar } from '../../../../common/Avatar'; -import { PaneLoading } from '../../../../common/Loading'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../../common/BitbucketImage'; -import { CommentEditEditor } from './CommentEditEditor'; -import { CommentReplyEditor } from './CommentReplyEditor'; -import { ConfirmModal } from '../../../../common/ConfirmModal'; -import { mermaidComponents } from '../../../../common/markdownMermaid'; -// 行内代码上下文用 Monaco,懒加载随 DiffView 同一套 Monaco chunk 按需拉取,不进入口包。 -const InlineCodeContext = lazy(() => - import('./InlineCodeContext').then((m) => ({ default: m.InlineCodeContext })), -); - -/** - * 评论树结构相等比较(按 remoteId + 正文 + version + 编辑/删除权限 + 递归 replies)。poll 多数返回 - * 内容不变的评论:相等就跳过 setComments、保留旧引用,让 React bail-out,避免整棵评论树(含内联 - * Monaco)无谓重渲染(刷新抖动)。 - */ -function sameCommentList(a: readonly PrComment[], b: readonly PrComment[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const x = a[i]!; - const y = b[i]!; - if ( - x.remoteId !== y.remoteId || - x.body !== y.body || - x.version !== y.version || - x.canEdit !== y.canEdit || - x.canDelete !== y.canDelete || - !sameCommentList(x.replies, y.replies) - ) { - return false; - } - } - return true; -} - -interface CommentsPanelProps { - pr: StoredPullRequest; - /** 拉取成功后回调,把顶层评论数 (不含 replies) 报给父组件用于 tab 角标 */ - onCommentsLoaded?: (count: number) => void; - /** 活动连接能力位;此处用 commentHardBreaks 决定评论是否启用 remark-breaks。 */ - capabilities?: PlatformCapabilities; -} - -/** - * PR 全量评论视图(独立标签页,跟 Diff inline 评论互补)。 - * - * 数据来源 跟 DiffView 的 inline 评论同一份 (`diff:listComments`),main 层有 - * `pr_updated_at` 缓存;进入本面板时优先回缓存,远端变更后失效自动重拉。 - * - * 排版:summary 评论 (anchor=null) 跟 inline 评论 (anchor!=null) 都展示,inline - * 顶部标 `path:line` chip 让用户知道这条评论锚在哪。replies 嵌套缩进 1 层渲染。 - */ -export function CommentsPanel({ pr, onCommentsLoaded, capabilities }: CommentsPanelProps) { - // 评论换行:GitHub/Bitbucket hard-break;GitLab CommonMark 软换行。缺省回退 true。 - const hardBreaks = capabilities?.commentHardBreaks ?? true; - const { t } = useTranslation(); - // 已展示的视图:评论与其配对的 pr 一起冻结。切 PR 时**不立刻清空**——旧视图继续渲染、上盖 loading - // 遮罩,新数据 ready 后整体替换(stale-while-loading),消除「先闪『加载中』再渲新」的空窗。 - // pr 与评论必须配对:旧评论要用旧 PR 的上下文(图片代理 / 回复目标)渲染,且该 ref 跨 poll 稳定, - // 给评论树(含内联 Monaco)稳定引用避免重渲染。 - const [view, setView] = useState<{ pr: StoredPullRequest; comments: PrComment[] } | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - setLoading(true); - setError(null); - // 不清 view:切 PR 期间旧评论继续显示、由遮罩盖住,避免空窗闪烁。 - const fetchList = async (force: boolean): Promise => { - try { - const list = await invoke('diff:listComments', { localId: pr.localId, force }); - if (cancelled) return; - // 同 PR 且内容相等:保留旧 view 引用让 React bail(comments:changed 无实质变化时不重渲评论树)。 - setView((prev) => - prev && prev.pr.localId === pr.localId && sameCommentList(prev.comments, list) - ? prev - : { pr, comments: list }, - ); - setLoading(false); - onCommentsLoaded?.(list.length); - } catch (e) { - if (!cancelled) { - setError(formatBackendError(e)); - setLoading(false); - } - } - }; - void fetchList(true); - // 监听 main 端 comments:changed 事件 — 用户回复评论 / 其他 PR 操作触发评论 - // 树变化时重拉远端最新 (force=true 跳过 cache 比对) - const unsub = subscribe('comments:changed', (e) => { - if (e.localId === pr.localId) void fetchList(true); - }); - return () => { - cancelled = true; - unsub(); - }; - // onCommentsLoaded 故意不放依赖:父组件每次 render 都会重传新 ref,会触发误重拉 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pr.localId]); - - const viewPr = view?.pr; - // 按 createdAt **倒序** (newest first):评论页主要给用户快速看最新动态用, - // 不是逐条对话回溯,最新在顶部更合理 - const ordered = useMemo( - () => (view?.comments ?? []).slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)), - [view], - ); - // inline 评论自动挂 Monaco 上限:前 N 条 (按倒序后的顺序,最新 N 个 inline) 直接 - // 挂;超额走 click-to-expand 懒加载。CAP 取 10 跟用户感知节奏一致 —— 一屏内大 - // 概看完,再多就需要主动展开 - const AUTO_EXPAND_CAP = 10; - const autoExpandSet = useMemo(() => { - const out = new Set(); - let i = 0; - for (const c of ordered) { - if (c.anchor) { - if (i < AUTO_EXPAND_CAP) out.add(c.remoteId); - i++; - } - } - return out; - }, [ordered]); - - // 缓存顶层评论元素列表:deps 全是稳定引用(ordered 经 sameCommentList 跳过后不变、viewPr 与评论配对 - // 冻结、autoExpandSet 随 ordered)。poll 无评论变化时元素引用不变 → React 跳过整棵评论子树(含内联 - // Monaco),消除刷新抖动。 - const commentEls = useMemo( - () => - viewPr - ? ordered.map((c) => ( - - )) - : [], - [ordered, viewPr, autoExpandSet, hardBreaks], - ); - - // 首载失败 / 切 PR 失败(无可信展示内容,或现有 view 属于旧 PR):整块错误,不拿旧 PR 的评论冒充新的。 - if (error && (!view || view.pr.localId !== pr.localId)) { - return ( -
    -
    -
    - {t('commentsPanel.loadError', { title: error.title })} -
    {error.detail}
    -
    -
    -
    - ); - } - - return ( -
    -
    - {view && ordered.length > 0 &&
      {commentEls}
    } - {view && ordered.length === 0 && !loading && ( -

    {t('commentsPanel.empty')}

    - )} -
    - {/* 加载遮罩盖住旧内容(或首载空面板),ready 后整体替换。PaneLoading 默认 delayMs=150: - 命中本地缓存的快切换遮罩根本不出现、旧内容直接换新(零闪);只有慢加载才显 spinner。 */} - {loading && } -
    - ); -} - -/** - * 嵌套回复的最大缩进层级:满此层级后继续递归但**不再加缩进**(拉平展示),避免深嵌套把内容挤到 - * 右侧极窄。depth 0 为顶层评论;depth 1..MAX 逐级缩进,超过 MAX 的更深回复一律平铺在 MAX 层缩进上, - * 仍按作者归属可读。Bitbucket 实际只一层 reply,GitHub / GitLab 可深嵌套,故设上限。 - */ -const MAX_REPLY_DEPTH = 5; - -/** - * 单条评论 + 嵌套 replies。inline 评论顶部显示 `path:line side` chip 区分锚点位置; - * summary 评论不挂 chip。replies 走递归,depth 控制左侧缩进;满 MAX_REPLY_DEPTH 层后拉平 - * (不再加缩进,见该常量)。 - */ -function CommentItem({ - comment, - pr, - depth, - autoExpandCode = false, - hardBreaks, -}: { - comment: PrComment; - pr: StoredPullRequest; - depth: number; - /** 顶层 (depth=0) 由父组件按 CAP 决定 true/false;replies 总是 false (不渲染 code) */ - autoExpandCode?: boolean; - hardBreaks: boolean; -}) { - const { t } = useTranslation(); - // 评论 body 内嵌图片走 IPC 代理 (Bitbucket 私有资源需 PAT 鉴权) - const mdComponents = useMemo( - () => ({ ...mermaidComponents, img: makeBitbucketImageFor(pr.localId, pr.url) }), - [pr.localId, pr.url], - ); - const [replyOpen, setReplyOpen] = useState(false); - const [editOpen, setEditOpen] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); - - // 删除/编辑条件 main 端预判好了 (annotateOwnership)。renderer 直读 flag, - // 不再自己比对 author / version / replies - const canDelete = comment.canDelete === true; - const canEdit = comment.canEdit === true; - - const handleDelete = async (): Promise => { - if (!canDelete || comment.version === undefined) return; - setConfirmDelete(false); - setDeleting(true); - setDeleteError(null); - try { - await invoke('comments:delete', { - localId: pr.localId, - commentId: comment.remoteId, - version: comment.version, - }); - // 成功 → main 端清 cache + 广播 comments:changed → 本面板 useEffect 重拉, - // 这条评论自然从列表里消失,不用手动维护本地 state - } catch (e) { - setDeleteError(e instanceof Error ? e.message : String(e)); - setDeleting(false); - } - }; - - return ( -
  • -
    - - {comment.author.displayName} - {comment.anchor && ( - // inline 评论锚点 chip:path:line + 侧别 (old=base / new=head)。 - // 让用户在评论面板里也能定位到代码位置 (后续可点击跳 Diff 视图) - - {comment.anchor.path}:{comment.anchor.line} - - )} - -
    - {/* inline 评论:在正文上方嵌一段代码上下文 (Monaco read-only),锚定行高亮。 - replies (depth > 0) 不重复展示,避免冗余 —— 父评论已经给了上下文。 - autoExpandCode 由父组件按"最新 N 条"决定,超额条目用户点开才挂 editor */} - {comment.anchor && depth === 0 && ( - {t('commentsPanel.loadingCodeContext')} - } - > - - - )} - {/* 编辑态:textarea 占位替换 markdown 正文;非编辑态:渲染 markdown */} - {editOpen && typeof comment.version === 'number' ? ( - setEditOpen(false)} - onSaved={() => setEditOpen(false)} - /> - ) : ( -
    - {/* hardBreaks(Bitbucket/GitHub)挂 remarkBreaks 单 \n→
    ;GitLab CommonMark 不挂 */} - - {comment.body} - -
    - )} - {/* 操作行:编辑态隐藏所有按钮 (避免跟编辑器底部按钮组重复);非编辑态展示 - 回复 / 编辑 / 删除三个按钮 */} - {!editOpen && ( -
    - {!replyOpen && ( - - )} - {canEdit && !replyOpen && ( - - )} - {/* 删除按钮在最后,跟其它按钮风格对齐 — disable 期间文案变"删除中…" */} - {canDelete && !replyOpen && ( - - )} -
    - )} - {deleteError && ( -
    - {t('commentsPanel.deleteFailed', { msg: deleteError })} - -
    - )} - {replyOpen && ( - setReplyOpen(false)} - onPosted={() => setReplyOpen(false)} - /> - )} - {comment.replies.length > 0 && ( - // 满 MAX_REPLY_DEPTH 层后用 pr-comments-flat 取代 pr-comments-replies(不再缩进 / 左边框)→ - // 更深回复拉平在该层缩进上;pr-comments-flat 据此给同层级相邻评论加横向分割线区分。 -
      - {comment.replies.map((r) => ( - - ))} -
    - )} - {confirmDelete && ( - void handleDelete()} - onCancel={() => setConfirmDelete(false)} - /> - )} -
  • - ); -} - -function formatRelativeTime(iso: string): string { - const t = Date.parse(iso); - if (Number.isNaN(t)) return iso; - const diffSec = Math.max(0, Math.round((Date.now() - t) / 1000)); - if (diffSec < 60) return i18n.t('commentsPanel.justNow'); - if (diffSec < 3600) - return i18n.t('commentsPanel.minutesAgo', { count: Math.round(diffSec / 60) }); - if (diffSec < 86400) - return i18n.t('commentsPanel.hoursAgo', { count: Math.round(diffSec / 3600) }); - if (diffSec < 86400 * 7) - return i18n.t('commentsPanel.daysAgo', { count: Math.round(diffSec / 86400) }); - return new Date(t).toLocaleDateString(); -} diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index c9f574f..62379de 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -1,4 +1,17 @@ { + "activityPanel": { + "empty": "Noch keine Aktivität in diesem PR", + "loadError": "Aktivität konnte nicht geladen werden · {{title}}", + "loading": "Aktivität wird geladen…", + "mergeCommit": "Merge-Commit · {{parents}} Eltern", + "verb": { + "approved": "hat diese Änderungen genehmigt", + "commented": "hat kommentiert", + "dismissed": "hat die Review verworfen", + "needsWork": "hat Änderungen angefordert", + "unapproved": "hat die Genehmigung zurückgezogen" + } + }, "app": { "approveActionFailed": "Genehmigungsaktion fehlgeschlagen: {{msg}}", "loading": "Wird geladen…", @@ -461,6 +474,7 @@ "showBlame": "Blame aktivieren (nur head-Seite)", "showWhitespace": "Leerzeichen anzeigen (Leerzeichen / Tab)", "sideBySide": "Nebeneinander", + "tabActivity": "Aktivität", "tabComments": "Kommentare", "tabCommits": "Commits", "tabDiff": "Änderungen", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 218452e..5938048 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -1,4 +1,17 @@ { + "activityPanel": { + "empty": "No activity yet", + "loadError": "Failed to load activity · {{title}}", + "loading": "Loading activity…", + "mergeCommit": "Merge commit · {{parents}} parents", + "verb": { + "approved": "approved these changes", + "commented": "commented", + "dismissed": "dismissed their review", + "needsWork": "requested changes", + "unapproved": "withdrew their approval" + } + }, "app": { "approveActionFailed": "Approval action failed: {{msg}}", "loading": "Loading…", @@ -461,6 +474,7 @@ "showBlame": "Enable blame (head side only)", "showWhitespace": "Show whitespace (spaces / tabs)", "sideBySide": "Side by side", + "tabActivity": "Activity", "tabComments": "Comments", "tabCommits": "Commits", "tabDiff": "Changes", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index fb1e9b9..8452ae0 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -1,4 +1,17 @@ { + "activityPanel": { + "empty": "この PR にはまだアクティビティがありません", + "loadError": "アクティビティの読み込みに失敗しました · {{title}}", + "loading": "アクティビティを読み込み中…", + "mergeCommit": "マージコミット · 親 {{parents}} 件", + "verb": { + "approved": "変更を承認しました", + "commented": "コメントしました", + "dismissed": "レビューを取り消しました", + "needsWork": "変更をリクエストしました", + "unapproved": "承認を取り消しました" + } + }, "app": { "approveActionFailed": "承認操作に失敗しました:{{msg}}", "loading": "読み込み中…", @@ -448,6 +461,7 @@ "showBlame": "Blame を有効化(head 側のみ)", "showWhitespace": "空白文字を表示(スペース / Tab)", "sideBySide": "並べて表示", + "tabActivity": "アクティビティ", "tabComments": "コメント", "tabCommits": "コミット", "tabDiff": "変更", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index a7e3329..15bf93f 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -1,4 +1,17 @@ { + "activityPanel": { + "empty": "该 PR 暂无活动", + "loadError": "加载活动失败 · {{title}}", + "loading": "加载活动中…", + "mergeCommit": "合并提交 · {{parents}} 个父提交", + "verb": { + "approved": "批准了这些更改", + "commented": "评论", + "dismissed": "撤销了评审", + "needsWork": "请求修改", + "unapproved": "撤回了批准" + } + }, "app": { "approveActionFailed": "审批操作失败:{{msg}}", "loading": "加载中…", @@ -448,6 +461,7 @@ "showBlame": "开启追溯显示(仅 head 侧)", "showWhitespace": "显示空白字符(空格 / Tab)", "sideBySide": "并排", + "tabActivity": "活动", "tabComments": "评论", "tabCommits": "提交", "tabDiff": "变更", diff --git a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss index 5634228..c91ebb7 100644 --- a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss +++ b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss @@ -493,6 +493,121 @@ padding-top: $space-4; border-top: 1px solid $border-muted; } + +// 时间线模式的顶层评论:li 自身退掉卡片样式(卡片下移到 .pr-comment-card),标题行走 .pr-activity-item +// 版式与其它事件统一;正文卡片整体缩进、挂在时间线轨右侧。 +.pr-comment-timeline.pr-comment-depth-0 { + padding: 0; + background: none; + border: none; + border-radius: 0; +} +.pr-comment-card { + // 缩进对齐到标题行的头像左缘(行左 padding + 图标 22 + gap) + margin-left: calc(22px + #{$space-3} * 2); + margin-top: $space-2; + padding: $space-4 $space-5; + background: $bg-elev; + border: 1px solid $border-default; + border-radius: $radius-md; +} + +// ── 活动时间线:提交 / 评审决断事件行 + 评论行 ── +// 所有条目(评论 / 提交 / 决断)首列都是同一位置的图标节点,正文在右;一条竖向虚线轨贯穿图标列、 +// 连接相邻条目,图标用面板底色遮住轨线形成「节点」效果。 +.pr-activity-list { + position: relative; + + // 竖向虚线轨:居中于图标列(行左 padding $space-3 + 图标半宽 11px)。首尾各内缩一点,不超出首/末节点。 + &::before { + content: ''; + position: absolute; + top: $space-5; + bottom: $space-5; + left: calc(#{$space-3} + 11px); + border-left: 1px dashed $border-default; + z-index: 0; + } +} +.pr-activity-item { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-2 $space-3; + // 与评论主体人文本同字号(body 默认 14px),作者展示不做差异化 + font-size: $fs-lg; + color: $text-body; +} +.pr-activity-clickable { + cursor: pointer; + border-radius: $radius-sm; + + &:hover { + background: $bg-hover; + } +} +.pr-activity-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + color: $text-muted; + // 浮于虚线轨之上并用面板底色遮挡轨线 → 图标成为时间线节点 + position: relative; + z-index: 1; + background: $bg-app; +} +// 决断语义色:approve 绿、needs-work 琥珀;unapproved / dismissed 维持中性 muted +.pr-activity-icon-approved { + color: $color-approved; +} +.pr-activity-icon-needsWork { + color: $color-warning-bright; +} +.pr-activity-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: $space-3; + overflow: hidden; +} +.pr-activity-commit-subject { + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pr-activity-sha { + flex-shrink: 0; + font-family: $font-mono; + font-size: $fs-xs; + color: $text-muted; +} +.pr-activity-merge-tag { + flex-shrink: 0; + font-size: $fs-xs; + padding: 0 $space-2; + border-radius: $radius-sm; + background: $bg-hover; + color: $text-muted; +} +.pr-activity-actor { + flex-shrink: 0; + font-weight: 600; + color: $text-primary; +} +.pr-activity-verb { + color: $text-muted; +} +.pr-activity-time { + flex-shrink: 0; + margin-left: auto; + font-size: $fs-xs; +} .pr-comment-head { display: flex; align-items: center; diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 24bf2ac..1362f00 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -1,6 +1,7 @@ import type { LocalPrStatus, PollResult, + PrActivityEvent, PrComment, PrCommit, ReviewDraft, @@ -110,6 +111,15 @@ export interface PrChannels { request: { localId: string }; response: PrCommit[]; }; + /** + * 拉取 PR 上的评审决断活动事件(approve / needs-work / unapprove / dismiss),带时间戳。 + * 活动时间线把它与评论 / 提交按时间归并。不缓存(量小,量级同 commits);平台取不到历史 + * 决断(如 GitLab CE 无审批)时返回 [],时间线只展示评论与提交。 + */ + 'diff:listActivity': { + request: { localId: string }; + response: PrActivityEvent[]; + }; /** * 本地 git rev-list 算 PR 引入的 commit 数 (base..head)。完全走本地 bare 镜像, * 不打远端;任一 sha 不在镜像 (尚未 sync 到本 PR 范围) → null。 diff --git a/packages/platform-bitbucket-server/src/adapter.ts b/packages/platform-bitbucket-server/src/adapter.ts index 1149edc..a10f8ac 100644 --- a/packages/platform-bitbucket-server/src/adapter.ts +++ b/packages/platform-bitbucket-server/src/adapter.ts @@ -5,6 +5,8 @@ import type { PlatformAdapter, PlatformCapabilities, PlatformUser, + PrActivityEvent, + PrActivityKind, PrComment, PrCommentAnchor, PrCommit, @@ -87,7 +89,7 @@ interface BitbucketComment { interface BitbucketCommentAnchor { diffType?: 'EFFECTIVE' | 'COMMIT' | 'RANGE'; // line / lineType 对文件级评论(挂在文件而非具体行)或孤儿 anchor(锚定行已不存在) - // 可能缺省 —— 标可选,mapBBAnchor 据此降级,避免读 undefined.toLowerCase 崩 + // 可能缺省 —— 标可选,mapBitbucketAnchor 据此降级,避免读 undefined.toLowerCase 崩 line?: number; lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT'; fileType?: 'FROM' | 'TO'; @@ -156,6 +158,7 @@ export class BitbucketServerAdapter implements PlatformAdapter { resolvableThreads: false, suggestions: false, reviewGrouping: false, + activityTimeline: true, }; } @@ -287,7 +290,7 @@ export class BitbucketServerAdapter implements PlatformAdapter { `/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests/${prId}/comments`, { text: body, parent: { id: Number(parentCommentId) } }, ); - return mapBBComment(created); + return mapBitbucketComment(created); } async deleteComment( @@ -324,7 +327,7 @@ export class BitbucketServerAdapter implements PlatformAdapter { // PUT 接口正常返回 JSON;走到这里只可能是上游 Bitbucket 配错回了 204 throw new Error('editComment: Bitbucket 返回空响应,无法确认更新结果'); } - return mapBBComment(updated); + return mapBitbucketComment(updated); } async publishInlineComment( @@ -344,7 +347,7 @@ export class BitbucketServerAdapter implements PlatformAdapter { `/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests/${prId}/comments`, { text: body, anchor: toBBAnchor(anchor) }, ); - return mapBBComment(created); + return mapBitbucketComment(created); } async getUserAvatar( @@ -416,7 +419,7 @@ export class BitbucketServerAdapter implements PlatformAdapter { for await (const c of this.client.paginate( `/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests/${prId}/commits`, )) { - out.push(mapBBCommit(c, this.baseUrl, repo)); + out.push(mapBitbucketCommit(c, this.baseUrl, repo)); } return out; } @@ -439,13 +442,39 @@ export class BitbucketServerAdapter implements PlatformAdapter { const id = String(c.id); if (seen.has(id)) continue; seen.add(id); - out.push(mapBBComment(c, activity.commentAnchor)); + out.push(mapBitbucketComment(c, activity.commentAnchor)); + } + return out; + } + + async listPullRequestActivity(repo: RepoRef, prId: string): Promise { + // 同 /activities 流里挑出决断类事件:APPROVED / UNAPPROVED / REVIEWED(= 标记 Needs Work)。 + // 评论(COMMENTED)走 listPullRequestComments,这里只取评审决断。 + const out: PrActivityEvent[] = []; + for await (const activity of this.client.paginate( + `/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests/${prId}/activities`, + )) { + const kind: PrActivityKind | null = + activity.action === 'APPROVED' + ? 'approved' + : activity.action === 'UNAPPROVED' + ? 'unapproved' + : activity.action === 'REVIEWED' + ? 'needsWork' + : null; + if (!kind) continue; + out.push({ + remoteId: String(activity.id), + kind, + actor: mapUser(activity.user), + createdAt: new Date(activity.createdDate).toISOString(), + }); } return out; } } -function mapBBCommit(c: BitbucketCommit, baseUrl: string, repo: RepoRef): PrCommit { +function mapBitbucketCommit(c: BitbucketCommit, baseUrl: string, repo: RepoRef): PrCommit { // Bitbucket commit URL:/projects/

    /repos//commits/ const url = `${baseUrl}/projects/${repo.projectKey}/repos/${repo.repoSlug}/commits/${c.id}`; return { @@ -470,21 +499,21 @@ function bitbucketCommitterToUser(c: { name: string; emailAddress?: string }): P return { name: c.name, displayName: c.name }; } -function mapBBComment(c: BitbucketComment, anchor?: BitbucketCommentAnchor): PrComment { +function mapBitbucketComment(c: BitbucketComment, anchor?: BitbucketCommentAnchor): PrComment { return { remoteId: String(c.id), author: mapUser(c.author), body: c.text, createdAt: new Date(c.createdDate).toISOString(), updatedAt: new Date(c.updatedDate).toISOString(), - anchor: anchor ? mapBBAnchor(anchor) : null, - replies: (c.comments ?? []).map((r) => mapBBComment(r)), + anchor: anchor ? mapBitbucketAnchor(anchor) : null, + replies: (c.comments ?? []).map((r) => mapBitbucketComment(r)), // 透传 Bitbucket 乐观锁版本号 — DELETE / PUT 时调用方必须带回来,否则 409 version: c.version, }; } -function mapBBAnchor(a: BitbucketCommentAnchor): PrCommentAnchor | null { +function mapBitbucketAnchor(a: BitbucketCommentAnchor): PrCommentAnchor | null { // 无行号 = 文件级 / 孤儿 anchor,无法锚到具体行 → 返回 null,调用方退化成 summary 评论 if (a.line == null) return null; return { @@ -497,7 +526,7 @@ function mapBBAnchor(a: BitbucketCommentAnchor): PrCommentAnchor | null { } /** - * 跨平台中性 anchor → Bitbucket REST 字段。mapBBAnchor 的反方向,发布 inline 评论时 + * 跨平台中性 anchor → Bitbucket REST 字段。mapBitbucketAnchor 的反方向,发布 inline 评论时 * 用。diffType 显式给 'EFFECTIVE' 让评论锚到"当前生效 diff"而不是某次具体 commit * —— PR 后续 push 新 commit 时评论仍跟着行走。 */ diff --git a/packages/platform-github/src/adapter.ts b/packages/platform-github/src/adapter.ts index 90d5fd3..769dde1 100644 --- a/packages/platform-github/src/adapter.ts +++ b/packages/platform-github/src/adapter.ts @@ -6,6 +6,8 @@ import type { PlatformAdapter, PlatformCapabilities, PlatformUser, + PrActivityEvent, + PrActivityKind, PrComment, PrCommentAnchor, PrCommit, @@ -179,6 +181,7 @@ export class GitHubAdapter implements PlatformAdapter { resolvableThreads: false, suggestions: false, reviewGrouping: false, + activityTimeline: true, }; } @@ -253,6 +256,35 @@ export class GitHubAdapter implements PlatformAdapter { return out.reverse(); } + async listPullRequestActivity(repo: RepoRef, prId: string): Promise { + const reviews = await collect( + this.client.paginate( + `/repos/${repo.projectKey}/${repo.repoSlug}/pulls/${prId}/reviews`, + ), + ); + const out: PrActivityEvent[] = []; + for (const r of reviews) { + // COMMENTED / PENDING 不是决断;submitted_at 缺失(草稿态)跳过 + if (!r.user || !r.submitted_at) continue; + const kind: PrActivityKind | null = + r.state === 'APPROVED' + ? 'approved' + : r.state === 'CHANGES_REQUESTED' + ? 'needsWork' + : r.state === 'DISMISSED' + ? 'dismissed' + : null; + if (!kind) continue; + out.push({ + remoteId: String(r.id), + kind, + actor: mapUser(r.user), + createdAt: r.submitted_at, + }); + } + return out; + } + async listPullRequestComments(repo: RepoRef, prId: string): Promise { const prefix = `/repos/${repo.projectKey}/${repo.repoSlug}`; const [issueComments, reviewComments] = await Promise.all([ diff --git a/packages/platform-gitlab/src/adapter.ts b/packages/platform-gitlab/src/adapter.ts index 7ce54fd..060fa3e 100644 --- a/packages/platform-gitlab/src/adapter.ts +++ b/packages/platform-gitlab/src/adapter.ts @@ -6,6 +6,7 @@ import type { PlatformAdapter, PlatformCapabilities, PlatformUser, + PrActivityEvent, PrComment, PrCommentAnchor, PrCommit, @@ -177,6 +178,8 @@ export class GitLabAdapter implements PlatformAdapter { resolvableThreads: false, suggestions: false, reviewGrouping: false, + // GitLab 无统一活动事件源(CE 无审批、审批系统 note 解析脆弱)→ PR 标签页退化为纯评论视图。 + activityTimeline: false, }; } @@ -291,6 +294,13 @@ export class GitLabAdapter implements PlatformAdapter { return out; } + async listPullRequestActivity(_repo: RepoRef, _prId: string): Promise { + // 差异化设计:GitLab 不参与活动时间线(capabilities.activityTimeline=false,PR 标签页退化为纯 + // 评论视图),故无需提供决断事件。GitLab 也没有统一活动事件源——CE 无审批、审批仅以脆弱的英文 + // 系统 note 体现,与 Bitbucket /activities、GitHub /reviews 的可靠时间戳事件不对等——返回空。 + return []; + } + async getUserAvatar( _slug: string, avatarUrl?: string, diff --git a/packages/poller/tests/poller.test.ts b/packages/poller/tests/poller.test.ts index 04eab0b..485e853 100644 --- a/packages/poller/tests/poller.test.ts +++ b/packages/poller/tests/poller.test.ts @@ -47,6 +47,7 @@ class FakeAdapter implements PlatformAdapter { resolvableThreads: false, suggestions: false, reviewGrouping: false, + activityTimeline: true, }; } async ping() { @@ -69,6 +70,9 @@ class FakeAdapter implements PlatformAdapter { async listPullRequestCommits(): Promise { return []; } + async listPullRequestActivity(): Promise { + return []; + } async getUserAvatar(): Promise { return null; } @@ -253,6 +257,7 @@ describe('Poller.tick', () => { resolvableThreads: false, suggestions: false, reviewGrouping: false, + activityTimeline: true, }), async ping() { return { ok: true }; @@ -267,6 +272,9 @@ describe('Poller.tick', () => { async listPullRequestCommits() { return []; }, + async listPullRequestActivity() { + return []; + }, async getUserAvatar() { return null; }, diff --git a/packages/shared/src/platform.ts b/packages/shared/src/platform.ts index fd6b80e..0e8a4be 100644 --- a/packages/shared/src/platform.ts +++ b/packages/shared/src/platform.ts @@ -184,6 +184,33 @@ export interface PrComment { nativeId?: string; } +/** + * PR 评审决断事件的判定类型。`dismissed` = 决断被撤销/作废(GitHub DISMISSED), + * 与主动 `unapproved`(撤回赞成)语义相近但来源不同,保留区分供 UI 文案。 + */ +export type PrActivityKind = 'approved' | 'needsWork' | 'unapproved' | 'dismissed'; + +/** + * PR 活动时间线上的「评审决断」事件(带时间戳)。跨平台中性形状,由各 adapter 从原生活动流 + * 映射:GitHub `/pulls/{n}/reviews`(state + submitted_at);Bitbucket `/activities` + * (action=APPROVED/REVIEWED/UNAPPROVED + createdDate);GitLab 系统 note(approved/ + * unapproved,CE 无审批则取不到)。 + * + * 仅承载评论 / 提交之外的「决断类」事件——评论走 {@link PrComment}、提交走 {@link PrCommit}, + * 渲染层把三路按时间归并成一条时间线。平台拿不到历史事件时该方法返回空数组。 + */ +export interface PrActivityEvent { + /** 平台侧事件 id(去重 / React key 用) */ + remoteId: string; + kind: PrActivityKind; + /** 触发该决断的用户 */ + actor: PlatformUser; + /** ISO */ + createdAt: string; + /** 决断附带正文(GitHub review body 可能带说明);无则省略 */ + body?: string; +} + /** * PR 的 diff 基准 sha(行内评论发布锚点用)。GitHub 用 `headSha` 作 commit_id; * GitLab 用三者拼 position;Bitbucket 不需要(忽略)。adapter 可按 prId 内部拉取, @@ -233,6 +260,13 @@ export interface PlatformCapabilities { suggestions: boolean; /** 决断 + 行内评论是否可成组提交(pending review);映射到本地草稿池→批量发布 */ reviewGrouping: boolean; + /** + * 是否提供「带时间戳的评审决断活动事件流」({@link PrActivityEvent})以支撑活动时间线。 + * GitHub(/reviews)/ Bitbucket(/activities)为 `true`:该 PR 标签页渲染评论 + 提交 + 决断 + * 归并的「活动」时间线。GitLab 为 `false`:无统一活动事件源(CE 无审批、系统 note 解析脆弱), + * 标签页退化为纯「评论」视图(沿用原行为与文案),不混入提交 / 决断。 + */ + activityTimeline: boolean; } /** @@ -308,6 +342,15 @@ export interface PlatformAdapter { */ listPullRequestCommits(repo: RepoRef, prId: string): Promise; + /** + * 列出 PR 上的「评审决断」活动事件(approve / needs-work / unapprove / dismiss),带时间戳。 + * 供活动时间线与评论 / 提交归并展示。各平台映射来源见 {@link PrActivityEvent}。 + * + * 顺序不约定(调用方按 createdAt 自行排序)。平台不支持取历史决断事件(如 GitLab CE 无审批) + * 时返回 `[]`,而非抛错——时间线据此优雅降级、只展示评论与提交。 + */ + listPullRequestActivity(repo: RepoRef, prId: string): Promise; + /** * 拉用户头像图片。返回原始字节 + content-type,main 进程负责缓存与转 data URL; * renderer 不直接 fetch(无 token、无法跨 origin 取私有 Bitbucket 资源)。 From 43232b280e5fea8e76cecb39838ec607d8df0d55 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 13:25:46 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(pr):=20=E6=B4=BB=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E6=94=AF=E6=8C=81=E6=96=B0=E5=BB=BA=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E4=B8=8E=E4=BA=A4=E4=BA=92=E7=BB=86=E8=8A=82=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建评论:活动标签栏右侧「评论」按钮发一条不锚到文件的 summary 评论,编辑框 作为时间线首个节点(头像在轨上、编辑框缩进)展开,发布后即时出现在顶部。 新增 publishSummaryComment 平台契约(GitHub issue 评论 / Bitbucket 无锚评论 / GitLab 新 discussion)+ comments:create 通道 + controller + 注册。 - 评审决断:动词统一为「批准 / 要求修改」,并用带色 chip(绿 / 琥珀 / 中性)突出。 - 时间标签:改用自定义 tooltip(data-tip + ::after,120ms 短延迟),各时间标签 行为一致、修复 review 行原生 title 不弹问题,精确到秒。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +- apps/desktop/src/main/controllers/pr.ts | 16 ++++ apps/desktop/src/main/ipc.ts | 1 + .../src/components/features/pr/PrPanel.tsx | 6 ++ .../components/features/pr/tabs/PrTabs.tsx | 12 ++- .../pr/tabs/activity/ActivityPanel.tsx | 72 +++++++++++++-- .../pr/tabs/comments/CommentComposer.tsx | 91 +++++++++++++++++++ .../features/pr/tabs/comments/CommentItem.tsx | 23 ++++- .../src/renderer/src/i18n/locales/de-DE.json | 12 ++- .../src/renderer/src/i18n/locales/en-US.json | 16 +++- .../src/renderer/src/i18n/locales/ja-JP.json | 12 ++- .../src/renderer/src/i18n/locales/zh-CN.json | 18 +++- .../renderer/src/styles/layout/main-pane.scss | 80 ++++++++++++++++ packages/ipc/src/pr.ts | 8 ++ .../platform-bitbucket-server/src/adapter.ts | 9 ++ packages/platform-github/src/adapter.ts | 9 ++ packages/platform-gitlab/src/adapter.ts | 9 ++ packages/poller/tests/poller.test.ts | 4 + packages/shared/src/platform.ts | 8 ++ 19 files changed, 387 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2e77f..a832e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### Added - PR 详情「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):把评论、提交更新、reviewer 评审决断(approve / needs-work / unapprove / dismiss)按时间倒序归并为一条活动时间线,保留原有评论内容、排序与编辑 / 回复 / 删除 / 内联代码能力。新增统一的 `listPullRequestActivity` 平台契约——GitHub 取自 `/pulls/{n}/reviews`、Bitbucket 取自 `/activities`(带时间戳的决断事件)。提交另保留独立「提交」标签页。 - - 视觉:各条目统一为「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,一条竖向虚线轨贯穿图标列连接相邻条目;评论标题统一为「xxx 评论」并前置评论图标,正文整体缩进成挂在轨上的卡片;作者头像 / 文本与评论主体人一致,不做差异化。 + - 视觉:各条目统一为「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,一条竖向虚线轨贯穿图标列连接相邻条目;评论标题统一为「xxx 评论」并前置评论图标,正文整体缩进成挂在轨上的卡片;作者头像 / 文本与评论主体人一致,不做差异化。评审决断动词统一为「批准 / 要求修改」并用带色 chip(绿 / 琥珀 / 中性)突出。时间标签 hover 显示精确到秒的实际时间点。 + - 新建评论:标签栏右侧「评论」按钮可直接发一条不锚到文件的 summary 评论,编辑框作为时间线首个节点(头像在轨上、编辑框缩进)展开,发布后新评论即时出现在顶部(新增 `publishSummaryComment` 平台契约 + `comments:create` 通道)。 - GitLab 走差异化设计:无统一活动事件源(CE 无审批、审批系统 note 解析脆弱),标签页保持纯「评论」视图(`capabilities.activityTimeline=false`),不混入提交 / 决断。 ### Changed diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index a386c1c..dd47fb8 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -37,6 +37,22 @@ export const replyComment: IpcController<'comments:reply'> = async (_event, req) return reply; }; +/** + * 在 PR 上新建一条 summary(顶层、不锚文件)评论,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 + */ +export const createComment: IpcController<'comments:create'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const created = await adapter.publishSummaryComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return created; +}; + /** * 删除自己作者的远端评论(带 version 乐观锁)。失败原文抛给 renderer;成功后清缓存 + 广播。 */ diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 6bdaedf..b7e4b51 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -58,6 +58,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { * 评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 */ ipcMain.handle('comments:reply', pr.replyComment); // 回复评论 + ipcMain.handle('comments:create', pr.createComment); // 新建 summary 评论 ipcMain.handle('comments:delete', pr.deleteComment); // 删除自己的评论 ipcMain.handle('comments:edit', pr.editComment); // 编辑自己的评论 ipcMain.handle('comments:fetchAttachment', pr.fetchAttachment); // 拉评论内嵌图片(代理带 PAT) diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx index 59c8c4f..b60ff46 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -53,6 +53,8 @@ export function PrPanel({ }: PrPanelProps) { const { t } = useTranslation(); const [tab, setTab] = useState('diff'); + // 活动标签页「新建评论」编辑框开关(由标签栏「评论」按钮触发,编辑框出现在时间线顶部) + const [composingComment, setComposingComment] = useState(false); // 收到跳转请求 → 强制切到 Diff tab,DiffView 自己负责消费 anchor useEffect(() => { if (pendingDiffNav) setTab('diff'); @@ -147,6 +149,7 @@ export function PrPanel({ totalDraftCount={totalDraftCount} publishableCount={publishableCount} activityTimeline={capabilities?.activityTimeline ?? false} + onNewComment={() => setComposingComment(true)} showWhitespace={showWhitespace} onToggleWhitespace={() => setShowWhitespace((b) => !b)} showBlame={showBlame} @@ -175,6 +178,9 @@ export function PrPanel({ pr={pr} onCommentsLoaded={(n) => setCommentCount(n)} capabilities={capabilities} + composing={composingComment} + onComposeClose={() => setComposingComment(false)} + currentUserName={currentUserName} /> diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx index 41d866d..63d31b9 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { PersonIcon, WhitespaceIcon } from '../../../common/icons'; +import { ChatIcon, PersonIcon, WhitespaceIcon } from '../../../common/icons'; export type PrTab = 'diff' | 'activity' | 'drafts' | 'commits' | 'info'; @@ -15,6 +15,7 @@ export function PrTabs({ totalDraftCount, publishableCount, activityTimeline, + onNewComment, showWhitespace, onToggleWhitespace, showBlame, @@ -30,6 +31,8 @@ export function PrTabs({ publishableCount: number; /** 该平台是否提供活动时间线(见 capabilities.activityTimeline);否则该 tab 标题退化为「评论」 */ activityTimeline: boolean; + /** 活动标签页右侧「评论」按钮:新建一条不锚到文件的评论 */ + onNewComment: () => void; showWhitespace: boolean; onToggleWhitespace: () => void; showBlame: boolean; @@ -108,6 +111,13 @@ export function PrTabs({ > {t('mainPane.tabInfo')} + {tab === 'activity' && ( +

    + +
    + )} {tab === 'diff' && (