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 @@ -7,6 +7,14 @@

### Added

- Diff 标签支持按「变更范围」查看:文件树头部「<n> 个文件」补充范围信息,变为「<n> 个文件 · 全部变更」或「<n> 个文件 · <commit>」,整体可点击弹出下拉,选择查看「全部变更(PR base..head)」或某个 commit 的变更(该 commit 的 `parent..sha`)。提交 / 活动标签页点击 commit 不再跳浏览器,而是切到 Diff 标签本地渲染该 commit 的变更。commit 视图为只读 diff(行内评论 / 草稿锚定在 PR 全量 diff 行号上,不套用于单 commit)。`diff:listChangedFiles` / `getFileContent` / `getBlame` 增加可选 base/head 范围参数。

- Diff 支持给「删除行」新增行内评论 / 草稿:此前 hover「+」只挂在 head 侧(新增行),现并排视图下 base 侧(删除 / 上下文行)也可 hover「+」创建,锚定 `side: 'old'`(发布映射 Bitbucket `lineType: removed / fileType: FROM`)。统一(inline)视图下删除行以 view zone 呈现、无可 hover 行号,仍需切并排视图创建。

- PR 评审界面交互细节优化:合并按钮去掉「常绿填充」(易误判为已点击),改为与 approve 同款基础态 + 1s blink 突出可点击、点击后沿用 disabled 灰显;提交标签页表格行高加高、表头字号不小于正文;活动视图 inline 评论的「文件:行号」锚点可点击,直接跳到 Diff 标签对应位置;活动内容区宽度在 [480, 960] 内自适应、窄于 480 转横向滚动(修正窄宽下被 ChatPane 遮盖);PR 头部「冲突」标记改为带色 chip 展示。

- PR 详情标签页交互优化:整面板国际化(原「描述 / 时间线」写死中文、「Reviewers」写死英文,现按界面语言出文案);reviewer 列表参照活动时间线行式展示(前置状态图标 + 头像 + 名 + 决断 chip,「评审者 / 已批准 / 要求修改 / 待评审」);时间线精简为「远端创建 / 远端更新 / 最近更新时间」(移除「本地首次发现」);改为左右布局(左描述、右时间线 + 评审者),面板窄到阈值时按容器查询响应式将侧栏堆叠到描述下方;侧栏限宽 400px、时间小字号右对齐,避免元素过散。

- PR 详情「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):把评论、提交更新、reviewer 评审决断(approve / needs-work / unapprove / dismiss)按时间倒序归并为一条活动时间线,保留原有评论内容、排序与编辑 / 回复 / 删除 / 内联代码能力。新增统一的 `listPullRequestActivity` 平台契约——GitHub 取自 `/pulls/{n}/reviews`、Bitbucket 取自 `/activities`(带时间戳的决断事件)。提交另保留独立「提交」标签页。
- 视觉:各条目统一为「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,一条竖向虚线轨贯穿图标列连接相邻条目;评论标题统一为「xxx 评论」并前置评论图标,正文整体缩进成挂在轨上的卡片;作者头像 / 文本与评论主体人一致,不做差异化。评审决断动词统一为「批准 / 要求修改」并用带色 chip(绿 / 琥珀 / 中性)突出。时间标签 hover 显示精确到秒的实际时间点。
- 新建评论:标签栏右侧「评论」按钮可直接发一条不锚到文件的 summary 评论,编辑框作为时间线首个节点(头像在轨上、编辑框缩进)展开,发布后新评论即时出现在顶部(新增 `publishSummaryComment` 平台契约 + `comments:create` 通道)。
Expand Down
23 changes: 15 additions & 8 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,25 +172,31 @@ export const syncRepo: IpcController<'repo:sync'> = async (_event, req) => {
};

/**
* 列出 base..head 变更文件(先确保镜像 + 锚到固定 merge-base)。
* 列出变更文件(先确保镜像)。默认 PR merge-base..head 全部变更;传 base/head 则列该范围
* (如某 commit 的 parent..sha),用于「查看特定 commit」。
*/
export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (_event, req) => {
const ctx = getContext();
const pr = await ctx.pr.findPrOrThrow(req.localId);
const id = ctx.pr.repoIdentityFor(pr);
await ctx.pr.ensureMirrorReadyForPr(pr);
const base = await ctx.pr.resolveDiffBaseSha(pr);
return ctx.repoMirror.listChangedFiles(id, base, pr.sourceRef.sha);
const base = req.base ?? (await ctx.pr.resolveDiffBaseSha(pr));
const head = req.head ?? pr.sourceRef.sha;
return ctx.repoMirror.listChangedFiles(id, base, head);
};

/**
* 读 base(固定 merge-base)/ head 一侧文件内容。
* 读 base / head 一侧文件内容。默认 PR merge-base / head;传 base/head 则按指定范围
* (commit 视图:base=parent、head=commit)。
*/
export const getFileContent: IpcController<'diff:getFileContent'> = async (_event, req) => {
const ctx = getContext();
const pr = await ctx.pr.findPrOrThrow(req.localId);
const id = ctx.pr.repoIdentityFor(pr);
const sha = req.side === 'base' ? await ctx.pr.resolveDiffBaseSha(pr) : pr.sourceRef.sha;
const sha =
req.side === 'base'
? (req.base ?? (await ctx.pr.resolveDiffBaseSha(pr)))
: (req.head ?? pr.sourceRef.sha);
return ctx.repoMirror.getFileContent(id, sha, req.path);
};

Expand Down Expand Up @@ -290,10 +296,11 @@ export const getBlame: IpcController<'diff:getBlame'> = async (_event, req) => {
const ctx = getContext();
const pr = await ctx.pr.findPrOrThrow(req.localId);
const id = ctx.pr.repoIdentityFor(pr);
const base = await ctx.pr.resolveDiffBaseSha(pr);
const base = req.base ?? (await ctx.pr.resolveDiffBaseSha(pr));
const head = req.head ?? pr.sourceRef.sha;
const [allBlame, changedSet] = await Promise.all([
ctx.repoMirror.getBlame(id, pr.sourceRef.sha, req.path),
ctx.repoMirror.listChangedHeadLines(id, base, pr.sourceRef.sha, req.path),
ctx.repoMirror.getBlame(id, head, req.path),
ctx.repoMirror.listChangedHeadLines(id, base, head, req.path),
]);
return {
lines: allBlame.filter((b) => !changedSet.has(b.line)),
Expand Down
30 changes: 28 additions & 2 deletions apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { LocalPrStatus, PlatformCapabilities, StoredPullRequest } from '@meebox/shared';
import type {
LocalPrStatus,
PlatformCapabilities,
PrCommentAnchor,
PrCommit,
StoredPullRequest,
} from '@meebox/shared';
import { invoke } from '../../../api';
import { useDraftsForPr } from '../../../stores/drafts-store';
import { PaneLoading } from '../../common/Loading';
Expand All @@ -9,6 +15,7 @@ import { CommitsPanel } from './tabs/CommitsPanel';
// Monaco 编辑器(~10MB)懒加载:只有真正切到 Diff tab 才拉取 DiffView chunk,
// 不阻塞窗口首帧 / PR 列表 / 首启向导。
const DiffView = lazy(() => import('./tabs/diff/DiffView').then((m) => ({ default: m.DiffView })));
import type { PendingCommitView } from './tabs/diff/DiffView';
import { DraftsPanel } from './tabs/drafts/DraftsPanel';
import { PrInfoView } from './tabs/PrInfoView';
import { PublishReviewModal } from './tabs/drafts/PublishReviewModal';
Expand Down Expand Up @@ -55,6 +62,17 @@ export function PrPanel({
const [tab, setTab] = useState<PrTab>('diff');
// 活动标签页「新建评论」编辑框开关(由标签栏「评论」按钮触发,编辑框出现在时间线顶部)
const [composingComment, setComposingComment] = useState(false);
// 「查看特定 commit」请求:提交 / 活动标签页点击某 commit → 切到 Diff tab 本地渲染该 commit 变更
const [pendingCommitView, setPendingCommitView] = useState<PendingCommitView | null>(null);
const viewCommit = (commit: PrCommit): void => {
setPendingCommitView({
sha: commit.sha,
parent: commit.parents[0] ?? null,
abbreviatedSha: commit.abbreviatedSha,
subject: commit.message.split('\n', 1)[0] ?? commit.abbreviatedSha,
});
setTab('diff');
};
// 收到跳转请求 → 强制切到 Diff tab,DiffView 自己负责消费 anchor
useEffect(() => {
if (pendingDiffNav) setTab('diff');
Expand Down Expand Up @@ -170,6 +188,8 @@ export function PrPanel({
capabilities={capabilities}
pendingNav={pendingDiffNav ?? null}
onNavConsumed={onDiffNavConsumed}
pendingCommitView={pendingCommitView}
onCommitViewConsumed={() => setPendingCommitView(null)}
/>
</Suspense>
</KeepAliveTab>
Expand All @@ -181,6 +201,12 @@ export function PrPanel({
composing={composingComment}
onComposeClose={() => setComposingComment(false)}
currentUserName={currentUserName}
onViewCommit={viewCommit}
onJumpToAnchor={(a: PrCommentAnchor) =>
onRequestDiffNav?.({
anchor: { path: a.path, startLine: a.line, endLine: a.line },
})
}
/>
</KeepAliveTab>
<KeepAliveTab active={tab === 'drafts'}>
Expand All @@ -201,7 +227,7 @@ export function PrPanel({
/>
</KeepAliveTab>
<KeepAliveTab active={tab === 'commits'}>
<CommitsPanel pr={pr} />
<CommitsPanel pr={pr} onViewCommit={viewCommit} />
</KeepAliveTab>
<KeepAliveTab active={tab === 'info'}>
<PrInfoView pr={pr} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ import type { PrCommit, StoredPullRequest } from '@meebox/shared';
import { invoke } from '../../../../api';
import { formatBackendError, type FormattedError } from '../../../../errors';
import { Avatar } from '../../../common/Avatar';
import { formatExactTime } from './comments/CommentItem';

interface CommitsPanelProps {
pr: StoredPullRequest;
/** 点击某 commit → 在 Diff 标签页本地渲染该 commit 的变更(不再跳浏览器) */
onViewCommit?: (commit: PrCommit) => void;
}

/**
* PR commits 列表,表格布局。来源 `diff:listCommits` (无缓存,进入面板时拉一次)。
*
* 列:短 SHA / 提交主题 (commit message 首行) / 作者 / 时间。merge commit 用
* 标记 chip 区分。点击行打开远端 commit 详情页 (Bitbucket commit URL)
* 标记 chip 区分。点击行 → 在 Diff 标签页本地渲染该 commit 的变更
*
* 列表默认按平台返回顺序 (newest first),跟 git log 习惯一致。
*/
export function CommitsPanel({ pr }: CommitsPanelProps) {
export function CommitsPanel({ pr, onViewCommit }: CommitsPanelProps) {
const { t } = useTranslation();
const [commits, setCommits] = useState<PrCommit[] | null>(null);
const [error, setError] = useState<FormattedError | null>(null);
Expand Down Expand Up @@ -78,25 +81,30 @@ export function CommitsPanel({ pr }: CommitsPanelProps) {
</thead>
<tbody>
{commits.map((c) => (
<CommitRow key={c.sha} commit={c} pr={pr} />
<CommitRow key={c.sha} commit={c} pr={pr} onView={onViewCommit} />
))}
</tbody>
</table>
</div>
);
}

function CommitRow({ commit, pr }: { commit: PrCommit; pr: StoredPullRequest }) {
function CommitRow({
commit,
pr,
onView,
}: {
commit: PrCommit;
pr: StoredPullRequest;
onView?: (commit: PrCommit) => void;
}) {
const { t } = useTranslation();
const isMerge = commit.parents.length > 1;
const subject = commit.message.split('\n', 1)[0]!;
const open = (): void => {
if (commit.url) window.open(commit.url, '_blank', 'noreferrer');
};
return (
<tr
className={`pr-commits-row ${commit.url ? 'pr-commits-row-clickable' : ''}`}
onClick={open}
className={`pr-commits-row ${onView ? 'pr-commits-row-clickable' : ''}`}
onClick={() => onView?.(commit)}
title={commit.message /* 完整 commit body hover 可见 */}
>
<td className="pr-commits-col-sha">
Expand All @@ -119,7 +127,13 @@ function CommitRow({ commit, pr }: { commit: PrCommit; pr: StoredPullRequest })
<span>{commit.author.displayName}</span>
</td>
<td className="pr-commits-col-time">
<time dateTime={commit.authoredAt}>{formatCommitTime(commit.authoredAt, t)}</time>
<time
className="time-tip"
dateTime={commit.authoredAt}
data-tip={formatExactTime(commit.authoredAt)}
>
{formatCommitTime(commit.authoredAt, t)}
</time>
</td>
</tr>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { ReviewerStatus, StoredPullRequest } from '@meebox/shared';
import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown';
import { Avatar } from '../../../common/Avatar';
import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../common/BitbucketImage';
import { ApproveIcon, NeedsWorkIcon } from '../../../common/icons';
import { mermaidComponents } from '../../../common/markdownMermaid';

interface PrInfoViewProps {
pr: StoredPullRequest;
}

function ReviewerStatusTag({ status }: { status: ReviewerStatus }) {
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>;
/** reviewer 状态 → 决断 chip 类型(复用活动时间线 chip 配色)+ 文案 key(复用 prStatus)。 */
const REVIEWER_STATUS_META: Record<ReviewerStatus, { chipKind: string; labelKey: string }> = {
approved: { chipKind: 'approved', labelKey: 'prStatus.approved' },
needsWork: { chipKind: 'needsWork', labelKey: 'prStatus.needsWork' },
unapproved: { chipKind: 'unapproved', labelKey: 'prStatus.pending' },
};

/** reviewer 前置状态图标:approve 绿勾 / needs-work 琥珀叹号 / 待评审 中性空心点。 */
function ReviewerStatusIcon({ status }: { status: ReviewerStatus }) {
if (status === 'approved') return <ApproveIcon size={16} />;
if (status === 'needsWork') return <NeedsWorkIcon size={16} />;
return <span className="reviewer-pending-dot" />;
}

export function PrInfoView({ pr }: PrInfoViewProps) {
const { t } = useTranslation();
// 描述 body 内嵌图片走 IPC 代理 (Bitbucket 私有资源需 PAT 鉴权),与评论/diff 一致
const mdComponents = useMemo(
() => ({ ...mermaidComponents, img: makeBitbucketImageFor(pr.localId, pr.url) }),
Expand All @@ -39,53 +48,80 @@ export function PrInfoView({ pr }: PrInfoViewProps) {

return (
<div className="pr-info-view">
<div className="pr-info-content">
{pr.description && (
<div className="pr-info-content pr-info-layout">
{/* 左:描述(主内容);右:时间线 + 评审者(元信息侧栏,参考 PR overview 布局) */}
<div className="pr-info-main">
{pr.description ? (
<section className="pr-detail-section">
<h3>{t('prInfo.description')}</h3>
<div className="pr-detail-description markdown">
{/* Bitbucket 远端用 \r\n 行尾,remark 解析时 CR 跟 LF 各算一次换行 → 单换行
被当成段落分隔,每个 list item 之间多一段空白。归一化成 \n */}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={mdComponents}
urlTransform={transformBitbucketUrl}
>
{pr.description.replace(/\r\n?/g, '\n')}
</ReactMarkdown>
</div>
</section>
) : (
<section className="pr-detail-section">
<h3>{t('prInfo.description')}</h3>
<p className="muted">{t('prInfo.descriptionEmpty')}</p>
</section>
)}
</div>

<aside className="pr-info-side">
<section className="pr-detail-section">
<h3>描述</h3>
<div className="pr-detail-description markdown">
{/* Bitbucket 远端用 \r\n 行尾,remark 解析时 CR 跟 LF 各算一次换行 → 单换行
被当成段落分隔,每个 list item 之间多一段空白。归一化成 \n */}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={mdComponents}
urlTransform={transformBitbucketUrl}
>
{pr.description.replace(/\r\n?/g, '\n')}
</ReactMarkdown>
<h3>{t('prInfo.timeline')}</h3>
<div className="pr-detail-kv">
<div className="modal-kv-key">{t('prInfo.createdAt')}</div>
<div className="modal-kv-val">{new Date(pr.createdAt).toLocaleString()}</div>
<div className="modal-kv-key">{t('prInfo.updatedAt')}</div>
<div className="modal-kv-val">{new Date(pr.updatedAt).toLocaleString()}</div>
<div className="modal-kv-key">{t('prInfo.recentUpdate')}</div>
<div className="modal-kv-val">{new Date(pr.lastSeenAt).toLocaleString()}</div>
</div>
</section>
)}

<section className="pr-detail-section">
<h3>Reviewers ({reviewers.length})</h3>
{reviewers.length === 0 ? (
<p className="muted">无</p>
) : (
<ul className="reviewer-list">
{reviewers.map((r) => (
<li key={r.name}>
{r.displayName} <ReviewerStatusTag status={r.status} />
</li>
))}
</ul>
)}
</section>

<section className="pr-detail-section">
<h3>时间线</h3>
<div className="pr-detail-kv">
<div className="modal-kv-key">远端创建</div>
<div className="modal-kv-val">{new Date(pr.createdAt).toLocaleString()}</div>
<div className="modal-kv-key">远端更新</div>
<div className="modal-kv-val">{new Date(pr.updatedAt).toLocaleString()}</div>
<div className="modal-kv-key">本地首次发现</div>
<div className="modal-kv-val">{new Date(pr.discoveredAt).toLocaleString()}</div>
<div className="modal-kv-key">最近一次 poll 看到</div>
<div className="modal-kv-val">{new Date(pr.lastSeenAt).toLocaleString()}</div>
</div>
</section>
<section className="pr-detail-section">
<h3>{t('prInfo.reviewers', { n: reviewers.length })}</h3>
{reviewers.length === 0 ? (
<p className="muted">{t('prInfo.reviewersEmpty')}</p>
) : (
<ul className="reviewer-list">
{reviewers.map((r) => {
const meta = REVIEWER_STATUS_META[r.status];
return (
<li key={r.name} className="reviewer-item">
<span
className={`reviewer-icon reviewer-icon-${r.status}`}
aria-hidden="true"
>
<ReviewerStatusIcon status={r.status} />
</span>
<Avatar
connectionId={pr.connectionId}
slug={r.slug ?? r.name}
displayName={r.displayName}
avatarUrl={r.avatarUrl}
size={22}
/>
<span className="reviewer-name">{r.displayName}</span>
<span className={`pr-activity-chip pr-activity-chip-${meta.chipKind}`}>
{t(meta.labelKey)}
</span>
</li>
);
})}
</ul>
)}
</section>
</aside>
</div>
</div>
);
Expand Down
Loading
Loading