Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@

## [Unreleased]

### Added

- PR 详情「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):把评论、提交更新、reviewer 评审决断(approve / needs-work / unapprove / dismiss)按时间倒序归并为一条活动时间线,保留原有评论内容、排序与编辑 / 回复 / 删除 / 内联代码能力。新增统一的 `listPullRequestActivity` 平台契约——GitHub 取自 `/pulls/{n}/reviews`、Bitbucket 取自 `/activities`(带时间戳的决断事件)。提交另保留独立「提交」标签页。
- 视觉:各条目统一为「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,一条竖向虚线轨贯穿图标列连接相邻条目;评论标题统一为「xxx 评论」并前置评论图标,正文整体缩进成挂在轨上的卡片;作者头像 / 文本与评论主体人一致,不做差异化。评审决断动词统一为「批准 / 要求修改」并用带色 chip(绿 / 琥珀 / 中性)突出。时间标签 hover 显示精确到秒的实际时间点。
- 新建评论:标签栏右侧「评论」按钮可直接发一条不锚到文件的 summary 评论,编辑框作为时间线首个节点(头像在轨上、编辑框缩进)展开,发布后新评论即时出现在顶部(新增 `publishSummaryComment` 平台契约 + `comments:create` 通道)。
- GitLab 走差异化设计:无统一活动事件源(CE 无审批、审批系统 note 解析脆弱),标签页保持纯「评论」视图(`capabilities.activityTimeline=false`),不混入提交 / 决断。

### Changed

- **前端代码结构重构(可维护性)**:纯结构调整,对外接口与界面 / 交互行为均不变。重点:
Expand All @@ -16,6 +23,7 @@

### Fixed

- PR 头部与详情页的评审状态 chip(pending / approved / needs_work、reviewer 的 approved / needs work / pending)此前为写死英文,现按界面语言出国际化文案(新增 `prStatus` 文案集,四语言)。
- 切换不同 PR 时 diff 文件树「左栏空白 → 文件树整体弹出」的抖动:DiffView 改为 stale-while-loading——引入 `loadedPrId` 标记当前已渲染内容所属 PR,切 PR 期间保留旧树 / 旧内容渲染、上盖加载遮罩(延迟 150ms,命中缓存的快切换直接换新),并门控 content / comments / blame 拉取(避免「新 localId + 旧选中文件」错拉),新文件列表 ready 后整体替换。
- diff 文件树首次加载时文件名被图标渲染推移的抖动:图标改用固定 16px 占位槽包裹,iconify 的 svg 晚一帧进 DOM 也不塌缩,文件名位置稳定。
- 切换不同 PR 时评论页先闪「加载评论中」再渲新内容的空窗:改为 stale-while-loading——切 PR 期间保留旧评论渲染、上盖加载遮罩,新数据 ready 后整体替换;遮罩延迟 150ms 显示,命中本地缓存的快切换直接换新、零闪。
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;成功后清缓存 + 广播。
*/
Expand Down Expand Up @@ -242,6 +258,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。
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -72,6 +73,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); // 本地镜像总占用(设置页)
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/src/components/common/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,27 @@ export function NeedsWorkIcon({ size = 14 }: IconProps) {
);
}

/** git commit 字形:横线上一个实心节点(活动时间线提交事件用)。 */
export function CommitIcon({ size = 14 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="1.5" y1="8" x2="5" y2="8" />
<line x1="11" y1="8" x2="14.5" y2="8" />
<circle cx="8" cy="8" r="2.8" />
</svg>
);
}

/** 机器人头像:AutoPilot 启用态。天线 + 头框 + 双眼 + 两侧耳。 */
export function RobotIcon({ size = 14 }: IconProps) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export function PrHeader({
· {pr.sourceRef.displayId} → {pr.targetRef.displayId}
</span>
<span> · </span>
<span className={`status-tag status-${pr.localStatus}`}>{pr.localStatus}</span>
<span className={`status-tag status-${pr.localStatus}`}>
{t(`prStatus.${pr.localStatus === 'needs_work' ? 'needsWork' : pr.localStatus}`)}
</span>
</div>
<div className="pr-header-actions">
<a
Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 列表 / 首启向导。
Expand Down Expand Up @@ -53,6 +53,8 @@ export function PrPanel({
}: PrPanelProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<PrTab>('diff');
// 活动标签页「新建评论」编辑框开关(由标签栏「评论」按钮触发,编辑框出现在时间线顶部)
const [composingComment, setComposingComment] = useState(false);
// 收到跳转请求 → 强制切到 Diff tab,DiffView 自己负责消费 anchor
useEffect(() => {
if (pendingDiffNav) setTab('diff');
Expand Down Expand Up @@ -146,6 +148,8 @@ export function PrPanel({
commitCount={commitCount}
totalDraftCount={totalDraftCount}
publishableCount={publishableCount}
activityTimeline={capabilities?.activityTimeline ?? false}
onNewComment={() => setComposingComment(true)}
showWhitespace={showWhitespace}
onToggleWhitespace={() => setShowWhitespace((b) => !b)}
showBlame={showBlame}
Expand All @@ -169,11 +173,14 @@ export function PrPanel({
/>
</Suspense>
</KeepAliveTab>
<KeepAliveTab active={tab === 'comments'}>
<CommentsPanel
<KeepAliveTab active={tab === 'activity'}>
<ActivityPanel
pr={pr}
onCommentsLoaded={(n) => setCommentCount(n)}
capabilities={capabilities}
composing={composingComment}
onComposeClose={() => setComposingComment(false)}
currentUserName={currentUserName}
/>
</KeepAliveTab>
<KeepAliveTab active={tab === 'drafts'}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { ReviewerStatus, StoredPullRequest } from '@meebox/shared';
Expand All @@ -11,9 +12,11 @@ interface PrInfoViewProps {
}

function ReviewerStatusTag({ status }: { status: ReviewerStatus }) {
if (status === 'approved') return <span className="tag-approved">✓ approved</span>;
if (status === 'needsWork') return <span className="tag-needs-work">✗ needs work</span>;
return <span className="muted">pending</span>;
const { t } = useTranslation();
if (status === 'approved') return <span className="tag-approved">✓ {t('prStatus.approved')}</span>;
if (status === 'needsWork')
return <span className="tag-needs-work">✗ {t('prStatus.needsWork')}</span>;
return <span className="muted">{t('prStatus.pending')}</span>;
}

export function PrInfoView({ pr }: PrInfoViewProps) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { PersonIcon, WhitespaceIcon } from '../../../common/icons';
import { ChatIcon, 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 时右侧附带
Expand All @@ -14,6 +14,8 @@ export function PrTabs({
commitCount,
totalDraftCount,
publishableCount,
activityTimeline,
onNewComment,
showWhitespace,
onToggleWhitespace,
showBlame,
Expand All @@ -27,6 +29,10 @@ export function PrTabs({
commitCount: number | null;
totalDraftCount: number;
publishableCount: number;
/** 该平台是否提供活动时间线(见 capabilities.activityTimeline);否则该 tab 标题退化为「评论」 */
activityTimeline: boolean;
/** 活动标签页右侧「评论」按钮:新建一条不锚到文件的评论 */
onNewComment: () => void;
showWhitespace: boolean;
onToggleWhitespace: () => void;
showBlame: boolean;
Expand All @@ -46,15 +52,16 @@ export function PrTabs({
>
{t('mainPane.tabDiff')}
</button>
{/* comments 在 commits 前:评审决断时评论的权重大于 commit 时间线 */}
{/* 活动时间线(评论 + 提交 + 评审决断)在 commits 前:评审决断时讨论权重大于纯 commit 列表。
角标仍取评论数——讨论量最具行动指引,提交另有独立 tab 计数。 */}
<button
type="button"
className={`pr-tab ${tab === 'comments' ? 'active' : ''}`}
onClick={() => onTab('comments')}
className={`pr-tab ${tab === 'activity' ? 'active' : ''}`}
onClick={() => onTab('activity')}
role="tab"
aria-selected={tab === 'comments'}
aria-selected={tab === 'activity'}
>
{t('mainPane.tabComments')}
{t(activityTimeline ? 'mainPane.tabActivity' : 'mainPane.tabComments')}
<TabCountBadge
count={commentCount}
ariaLabel={(n) => t('mainPane.commentCountAria', { count: n })}
Expand Down Expand Up @@ -104,6 +111,13 @@ export function PrTabs({
>
{t('mainPane.tabInfo')}
</button>
{tab === 'activity' && (
<div className="pr-tabs-right">
<button type="button" className="pr-tab-action-btn" onClick={onNewComment}>
<ChatIcon /> {t('mainPane.newComment')}
</button>
</div>
)}
{tab === 'diff' && (
<div className="pr-tabs-right">
<button
Expand Down
Loading
Loading