From 6445384d5e2999d909fe6caaecd8bc6bffe36122 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 14:33:58 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(diff):=20Diff=20=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=20commit=20=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件树头部「 个文件」补充范围信息,变为「· 全部变更」或「· 」,整体可 点击弹出范围下拉(参考 Bitbucket 两行布局:图标 + 标题 + 元信息),选「全部变更」或 某 commit 的 parent..sha。下拉用 portal + fixed 定位,避免被文件列表 overflow 裁切 / 被 Monaco 盖住;菜单整体拉宽,commit 标题超宽省略。 - 提交 / 活动标签页点击 commit 不再跳浏览器,改为切到 Diff 标签本地渲染该 commit 变更。 - commit 视图为只读 diff(行内评论 / 草稿锚定在 PR 全量 diff 行号上,不套用于单 commit)。 - `diff:listChangedFiles` / `getFileContent` / `getBlame` 增加可选 base/head 范围参数。 - 提交面板时间列接入 hover 精确时间提示、表格限宽 960 居中,与详情 / 活动页一致。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + apps/desktop/src/main/controllers/pr.ts | 23 +- .../src/components/features/pr/PrPanel.tsx | 24 +- .../features/pr/tabs/CommitsPanel.tsx | 34 +- .../pr/tabs/activity/ActivityPanel.tsx | 26 +- .../features/pr/tabs/diff/DiffView.tsx | 313 ++++++++++++++++-- .../src/renderer/src/i18n/locales/de-DE.json | 4 + .../src/renderer/src/i18n/locales/en-US.json | 4 + .../src/renderer/src/i18n/locales/ja-JP.json | 3 + .../src/renderer/src/i18n/locales/zh-CN.json | 3 + .../src/styles/features/file-tree.scss | 135 ++++++++ .../renderer/src/styles/layout/main-pane.scss | 3 + packages/ipc/src/pr.ts | 16 +- 13 files changed, 530 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fd810..faed6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- Diff 标签支持按「变更范围」查看:文件树头部「 个文件」补充范围信息,变为「 个文件 · 全部变更」或「 个文件 · 」,整体可点击弹出下拉,选择查看「全部变更(PR base..head)」或某个 commit 的变更(该 commit 的 `parent..sha`)。提交 / 活动标签页点击 commit 不再跳浏览器,而是切到 Diff 标签本地渲染该 commit 的变更。commit 视图为只读 diff(行内评论 / 草稿锚定在 PR 全量 diff 行号上,不套用于单 commit)。`diff:listChangedFiles` / `getFileContent` / `getBlame` 增加可选 base/head 范围参数。 + - PR 详情「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):把评论、提交更新、reviewer 评审决断(approve / needs-work / unapprove / dismiss)按时间倒序归并为一条活动时间线,保留原有评论内容、排序与编辑 / 回复 / 删除 / 内联代码能力。新增统一的 `listPullRequestActivity` 平台契约——GitHub 取自 `/pulls/{n}/reviews`、Bitbucket 取自 `/activities`(带时间戳的决断事件)。提交另保留独立「提交」标签页。 - 视觉:各条目统一为「图标节点 + 头像 + 加粗作者名 + 动词 + 时间」,一条竖向虚线轨贯穿图标列连接相邻条目;评论标题统一为「xxx 评论」并前置评论图标,正文整体缩进成挂在轨上的卡片;作者头像 / 文本与评论主体人一致,不做差异化。评审决断动词统一为「批准 / 要求修改」并用带色 chip(绿 / 琥珀 / 中性)突出。时间标签 hover 显示精确到秒的实际时间点。 - 新建评论:标签栏右侧「评论」按钮可直接发一条不锚到文件的 summary 评论,编辑框作为时间线首个节点(头像在轨上、编辑框缩进)展开,发布后新评论即时出现在顶部(新增 `publishSummaryComment` 平台契约 + `comments:create` 通道)。 diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index dd47fb8..0075efe 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -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); }; @@ -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)), 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 b60ff46..f181cfd 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -1,6 +1,11 @@ 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, + PrCommit, + StoredPullRequest, +} from '@meebox/shared'; import { invoke } from '../../../api'; import { useDraftsForPr } from '../../../stores/drafts-store'; import { PaneLoading } from '../../common/Loading'; @@ -9,6 +14,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'; @@ -55,6 +61,17 @@ export function PrPanel({ const [tab, setTab] = useState('diff'); // 活动标签页「新建评论」编辑框开关(由标签栏「评论」按钮触发,编辑框出现在时间线顶部) const [composingComment, setComposingComment] = useState(false); + // 「查看特定 commit」请求:提交 / 活动标签页点击某 commit → 切到 Diff tab 本地渲染该 commit 变更 + const [pendingCommitView, setPendingCommitView] = useState(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'); @@ -170,6 +187,8 @@ export function PrPanel({ capabilities={capabilities} pendingNav={pendingDiffNav ?? null} onNavConsumed={onDiffNavConsumed} + pendingCommitView={pendingCommitView} + onCommitViewConsumed={() => setPendingCommitView(null)} /> @@ -181,6 +200,7 @@ export function PrPanel({ composing={composingComment} onComposeClose={() => setComposingComment(false)} currentUserName={currentUserName} + onViewCommit={viewCommit} /> @@ -201,7 +221,7 @@ export function PrPanel({ /> - + diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx index 3367a58..eecbcf2 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx @@ -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(null); const [error, setError] = useState(null); @@ -78,7 +81,7 @@ export function CommitsPanel({ pr }: CommitsPanelProps) { {commits.map((c) => ( - + ))} @@ -86,17 +89,22 @@ export function CommitsPanel({ pr }: CommitsPanelProps) { ); } -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 ( onView?.(commit)} title={commit.message /* 完整 commit body hover 可见 */} > @@ -119,7 +127,13 @@ function CommitRow({ commit, pr }: { commit: PrCommit; pr: StoredPullRequest }) {commit.author.displayName} - + ); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx index 0e4b9a1..c0fe177 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx @@ -33,6 +33,8 @@ interface ActivityPanelProps { onComposeClose?: () => void; /** 当前 PAT 用户名(新建评论编辑框头像用) */ currentUserName?: string | null; + /** 点击时间线上的 commit 事件 → 在 Diff 标签页本地渲染该 commit 的变更(不再跳浏览器) */ + onViewCommit?: (commit: PrCommit) => void; } /** 三路数据 + 其配对 PR 一起冻结,跨 poll 稳定引用,给评论树(含内联 Monaco)稳定身份避免重渲染。 */ @@ -72,6 +74,7 @@ export function ActivityPanel({ composing = false, onComposeClose, currentUserName, + onViewCommit, }: ActivityPanelProps) { // 评论换行:GitHub/Bitbucket hard-break;GitLab CommonMark 软换行。缺省回退 true。 const hardBreaks = capabilities?.commentHardBreaks ?? true; @@ -176,7 +179,9 @@ export function ActivityPanel({ rows.push({ key: `commit:${cm.sha}`, at: Date.parse(cm.committedAt || cm.authoredAt) || 0, - node: , + node: ( + + ), }); } for (const ev of view.activity) { @@ -189,7 +194,7 @@ export function ActivityPanel({ // newest first;稳定排序下同刻条目按 评论→提交→决断 入队序排列 rows.sort((a, b) => b.at - a.at); return rows.map((r) => r.node); - }, [view, viewPr, autoExpandSet, hardBreaks, showTimeline]); + }, [view, viewPr, autoExpandSet, hardBreaks, showTimeline, onViewCommit]); // 首载失败 / 切 PR 失败(无可信展示内容,或现有 view 属于旧 PR):整块错误,不拿旧 PR 内容冒充新的。 if (error && (!view || view.pr.localId !== pr.localId)) { @@ -248,17 +253,22 @@ export function ActivityPanel({ } /** 时间线上的提交事件:commit 图标 + 短 SHA + 主题 + 作者 + 时间;可点击跳远端 commit 页。 */ -function CommitEvent({ commit, pr }: { commit: PrCommit; pr: StoredPullRequest }) { +function CommitEvent({ + 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 (
  • onView?.(commit)} title={commit.message} >