diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fd810..d6ff448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ ### Added +- Diff 标签支持按「变更范围」查看:文件树头部「 个文件」补充范围信息,变为「 个文件 · 全部变更」或「 个文件 · 」,整体可点击弹出下拉,选择查看「全部变更(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` 通道)。 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..0e2e53f 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,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'; @@ -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'; @@ -55,6 +62,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 +188,8 @@ export function PrPanel({ capabilities={capabilities} pendingNav={pendingDiffNav ?? null} onNavConsumed={onDiffNavConsumed} + pendingCommitView={pendingCommitView} + onCommitViewConsumed={() => setPendingCommitView(null)} /> @@ -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 }, + }) + } /> @@ -201,7 +227,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/PrInfoView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx index f169bf4..7285d97 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx @@ -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 ✓ {t('prStatus.approved')}; - if (status === 'needsWork') - return ✗ {t('prStatus.needsWork')}; - return {t('prStatus.pending')}; +/** reviewer 状态 → 决断 chip 类型(复用活动时间线 chip 配色)+ 文案 key(复用 prStatus)。 */ +const REVIEWER_STATUS_META: Record = { + 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 ; + if (status === 'needsWork') return ; + return ; } export function PrInfoView({ pr }: PrInfoViewProps) { + const { t } = useTranslation(); // 描述 body 内嵌图片走 IPC 代理 (Bitbucket 私有资源需 PAT 鉴权),与评论/diff 一致 const mdComponents = useMemo( () => ({ ...mermaidComponents, img: makeBitbucketImageFor(pr.localId, pr.url) }), @@ -39,53 +48,80 @@ export function PrInfoView({ pr }: PrInfoViewProps) { return (
-
- {pr.description && ( +
+ {/* 左:描述(主内容);右:时间线 + 评审者(元信息侧栏,参考 PR overview 布局) */} +
+ {pr.description ? ( +
+

{t('prInfo.description')}

+
+ {/* Bitbucket 远端用 \r\n 行尾,remark 解析时 CR 跟 LF 各算一次换行 → 单换行 + 被当成段落分隔,每个 list item 之间多一段空白。归一化成 \n */} + + {pr.description.replace(/\r\n?/g, '\n')} + +
+
+ ) : ( +
+

{t('prInfo.description')}

+

{t('prInfo.descriptionEmpty')}

+
+ )} +
+ +
); 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..bcee0bd 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 @@ -5,6 +5,7 @@ import type { PrActivityEvent, PrActivityKind, PrComment, + PrCommentAnchor, PrCommit, StoredPullRequest, } from '@meebox/shared'; @@ -33,6 +34,10 @@ interface ActivityPanelProps { onComposeClose?: () => void; /** 当前 PAT 用户名(新建评论编辑框头像用) */ currentUserName?: string | null; + /** 点击时间线上的 commit 事件 → 在 Diff 标签页本地渲染该 commit 的变更(不再跳浏览器) */ + onViewCommit?: (commit: PrCommit) => void; + /** 点击 inline 评论锚点 chip → 跳到 Diff 对应文件/行 */ + onJumpToAnchor?: (anchor: PrCommentAnchor) => void; } /** 三路数据 + 其配对 PR 一起冻结,跨 poll 稳定引用,给评论树(含内联 Monaco)稳定身份避免重渲染。 */ @@ -72,6 +77,8 @@ export function ActivityPanel({ composing = false, onComposeClose, currentUserName, + onViewCommit, + onJumpToAnchor, }: ActivityPanelProps) { // 评论换行:GitHub/Bitbucket hard-break;GitLab CommonMark 软换行。缺省回退 true。 const hardBreaks = capabilities?.commentHardBreaks ?? true; @@ -168,6 +175,7 @@ export function ActivityPanel({ autoExpandCode={autoExpandSet.has(c.remoteId)} hardBreaks={hardBreaks} timeline={showTimeline} + onJumpToAnchor={onJumpToAnchor} /> ), }); @@ -176,7 +184,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 +199,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, onJumpToAnchor]); // 首载失败 / 切 PR 失败(无可信展示内容,或现有 view 属于旧 PR):整块错误,不拿旧 PR 内容冒充新的。 if (error && (!view || view.pr.localId !== pr.localId)) { @@ -248,17 +258,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} >
  • + ); +} 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 d14ebe3..2859ac2 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -178,6 +178,7 @@ "textareaAria": "Editor für Kommentarantworten" }, "commentsPanel": { + "anchorJumpTitle": "Zur Stelle im Diff springen", "anchorTitle": "Verankert an {{side}}-Seite · {{lineType}}", "daysAgo_one": "vor {{count}} Tag", "daysAgo_other": "vor {{count}} Tagen", @@ -296,7 +297,11 @@ "reply": "Antworten", "resizeFileListTitle": "Ziehen, um die Breite des Dateibaums anzupassen", "retry": "Erneut versuchen", + "scopeAll": "Alle Änderungen", + "scopeCommitCount_one": "{{count}} Commit", + "scopeCommitCount_other": "{{count}} Commits", "scopeLabel": "{{scope}}: ", + "scopeLoading": "Commits werden geladen…", "searchAria": "Suchen", "searchChanges": "Änderungen durchsuchen", "searchChangesTitle": "Änderungen durchsuchen (head + base)", @@ -544,6 +549,16 @@ "githubSub": "github.com / Enterprise", "gitlabSub": "gitlab.com / Self-Managed" }, + "prInfo": { + "createdAt": "Erstellt (remote)", + "description": "Beschreibung", + "descriptionEmpty": "Keine Beschreibung", + "recentUpdate": "Zuletzt aktualisiert", + "reviewers": "Reviewer ({{n}})", + "reviewersEmpty": "Keine", + "timeline": "Zeitleiste", + "updatedAt": "Aktualisiert (remote)" + }, "prItem": { "approvedCount_one": "{{count}} Reviewer hat genehmigt", "approvedCount_other": "{{count}} Reviewer haben genehmigt", 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 de04d53..5fecfe9 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -178,6 +178,7 @@ "textareaAria": "Comment reply editor" }, "commentsPanel": { + "anchorJumpTitle": "Jump to this line in Diff", "anchorTitle": "Anchored to {{side}} side · {{lineType}}", "daysAgo_one": "{{count}} day ago", "daysAgo_other": "{{count}} days ago", @@ -296,7 +297,11 @@ "reply": "Reply", "resizeFileListTitle": "Drag to resize file tree", "retry": "Retry", + "scopeAll": "All changes", + "scopeCommitCount_one": "{{count}} commit", + "scopeCommitCount_other": "{{count}} commits", "scopeLabel": "{{scope}}: ", + "scopeLoading": "Loading commits…", "searchAria": "Search", "searchChanges": "Search changes", "searchChangesTitle": "Search changes (head + base)", @@ -544,6 +549,16 @@ "githubSub": "github.com / Enterprise", "gitlabSub": "gitlab.com / Self-Managed" }, + "prInfo": { + "createdAt": "Created (remote)", + "description": "Description", + "descriptionEmpty": "No description", + "recentUpdate": "Last updated", + "reviewers": "Reviewers ({{n}})", + "reviewersEmpty": "None", + "timeline": "Timeline", + "updatedAt": "Updated (remote)" + }, "prItem": { "approvedCount_one": "{{count}} reviewer approved", "approvedCount_other": "{{count}} reviewers approved", 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 c9b565c..2712dbb 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -178,6 +178,7 @@ "textareaAria": "コメント返信エディタ" }, "commentsPanel": { + "anchorJumpTitle": "Diff の該当位置へ移動", "anchorTitle": "{{side}} 側にアンカー · {{lineType}}", "daysAgo_other": "{{count}} 日前", "deleteConfirmMessage": "この操作はリモートのこのコメントを削除し、元に戻せません。続行しますか?", @@ -288,7 +289,10 @@ "reply": "返信", "resizeFileListTitle": "ドラッグしてファイルツリーの幅を調整", "retry": "再試行", + "scopeAll": "すべての変更", + "scopeCommitCount_other": "{{count}} 件のコミット", "scopeLabel": "{{scope}}: ", + "scopeLoading": "コミットを読み込み中…", "searchAria": "検索", "searchChanges": "変更内容を検索", "searchChangesTitle": "変更内容を検索 (head + base)", @@ -531,6 +535,16 @@ "githubSub": "github.com / Enterprise", "gitlabSub": "gitlab.com / Self-Managed" }, + "prInfo": { + "createdAt": "リモート作成", + "description": "説明", + "descriptionEmpty": "説明なし", + "recentUpdate": "最終更新", + "reviewers": "レビュアー ({{n}})", + "reviewersEmpty": "なし", + "timeline": "タイムライン", + "updatedAt": "リモート更新" + }, "prItem": { "approvedCount_other": "{{count}} 人のレビュアーが承認しました", "executing": "Agent タスク実行中", 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 b8ee41f..786ab3c 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -178,6 +178,7 @@ "textareaAria": "评论回复编辑器" }, "commentsPanel": { + "anchorJumpTitle": "跳转到 Diff 对应位置", "anchorTitle": "锚定 {{side}} 侧 · {{lineType}}", "daysAgo_other": "{{count}} 天前", "deleteConfirmMessage": "此操作会删除远端的这条评论,且无法恢复。确定继续吗?", @@ -288,7 +289,10 @@ "reply": "回复", "resizeFileListTitle": "拖动调整文件树宽度", "retry": "重试", + "scopeAll": "全部变更", + "scopeCommitCount_other": "{{count}} 个提交", "scopeLabel": "{{scope}}:", + "scopeLoading": "加载提交中…", "searchAria": "搜索", "searchChanges": "搜索变更内容", "searchChangesTitle": "搜索变更内容 (head + base)", @@ -531,6 +535,16 @@ "githubSub": "github.com / Enterprise", "gitlabSub": "gitlab.com / Self-Managed" }, + "prInfo": { + "createdAt": "远端创建", + "description": "描述", + "descriptionEmpty": "无描述", + "recentUpdate": "最近更新时间", + "reviewers": "评审者 ({{n}})", + "reviewersEmpty": "无", + "timeline": "时间线", + "updatedAt": "远端更新" + }, "prItem": { "approvedCount_other": "{{count}} 位 reviewer 已 approve", "executing": "Agent 任务执行中", diff --git a/apps/desktop/src/renderer/src/styles/features/file-tree.scss b/apps/desktop/src/renderer/src/styles/features/file-tree.scss index fed224f..c1e049b 100644 --- a/apps/desktop/src/renderer/src/styles/features/file-tree.scss +++ b/apps/desktop/src/renderer/src/styles/features/file-tree.scss @@ -83,6 +83,141 @@ } } +// 变更范围选择器(文件树头部「 个文件 · 全部变更 / 」可点击下拉) +.diff-scope-select { + position: relative; + flex: 1 1 auto; + min-width: 0; +} +.diff-scope-trigger { + display: inline-flex; + align-items: center; + gap: $space-2; + max-width: 100%; + padding: 1px $space-2; + background: transparent; + border: 1px solid transparent; + border-radius: $radius-sm; + color: $text-muted; + cursor: pointer; + // header 整体 uppercase + letterspacing;trigger 内含 commit 主题,需还原为普通可读文本 + text-transform: none; + letter-spacing: normal; + + &:hover { + background: $bg-hover; + color: $text-primary; + border-color: $border-default; + } +} +.diff-scope-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.diff-scope-chevron { + flex-shrink: 0; + transition: transform 0.12s ease; + + &.open { + transform: rotate(180deg); + } +} +// 下拉用 fixed 定位(top/left/width 由 JS 按触发器位置计算)+ portal 挂 body, +// 避免被 .diff-file-list 的 overflow 裁切 / 被右侧 Monaco 盖住。 +.diff-scope-menu { + position: fixed; + z-index: 100; + max-height: 360px; + overflow-y: auto; + overflow-x: hidden; + margin: 0; + padding: $space-2; + list-style: none; + background: $bg-elev; + border: 1px solid $border-default; + border-radius: $radius-md; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} +// 每个选项:左侧横跨两行高度的图标 + 右侧两行正文(标题行 + 元信息副行,参考 Bitbucket) +.diff-scope-option { + display: flex; + flex-direction: row; + align-items: center; + gap: $space-3; + width: 100%; + overflow: hidden; + padding: $space-3; + background: transparent; + border: none; + border-radius: $radius-sm; + color: $text-body; + cursor: pointer; + text-align: left; + text-transform: none; + letter-spacing: normal; + + &:hover { + background: $bg-hover; + } + &.active { + background: $bg-selected; + color: $text-on-accent; + } +} +.diff-scope-option-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + color: $text-muted; +} +.diff-scope-option.active .diff-scope-option-icon { + color: $text-on-accent; +} +.diff-scope-option-body { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 auto; + min-width: 0; +} +.diff-scope-option-title { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: $fs-md; +} +.diff-scope-option-sub { + display: flex; + align-items: center; + gap: $space-2; + min-width: 0; + font-size: $fs-sm; + color: $text-muted; +} +.diff-scope-option.active .diff-scope-option-sub { + color: $text-on-accent; +} +.diff-scope-author { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.diff-scope-sha { + flex-shrink: 0; + font-family: $font-mono; + font-size: $fs-xs; +} +.diff-scope-time { + flex-shrink: 0; + margin-left: auto; +} + // 文件树容器 .diff-file-tree { flex: 1; diff --git a/apps/desktop/src/renderer/src/styles/features/pr-info.scss b/apps/desktop/src/renderer/src/styles/features/pr-info.scss index 038ac02..34e1027 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr-info.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr-info.scss @@ -5,6 +5,8 @@ flex: 1; overflow-y: auto; padding: 20px 24px; + // 作为容器查询基准:左右布局按本面板宽度响应(而非窗口宽度),窄到阈值时侧栏落到描述下方 + container-type: inline-size; } .pr-info-content { @@ -13,6 +15,39 @@ margin: 0 auto; } +// 左右布局:左侧描述(主内容)+ 右侧元信息侧栏(时间线 / 评审者) +.pr-info-layout { + display: flex; + align-items: flex-start; + gap: 32px; +} +.pr-info-main { + flex: 1 1 auto; + min-width: 0; +} +.pr-info-side { + flex: 0 0 260px; + // 宽度上限:堆叠(占满整宽)时也保持紧凑,避免时间 / 评审 chip 被拉到两端过散 + max-width: 400px; + display: flex; + flex-direction: column; +} +// 两列首个 section 顶部不留大间距,与对侧标题齐平 +.pr-info-main > .pr-detail-section:first-child, +.pr-info-side > .pr-detail-section:first-child { + margin-top: 0; +} +// 响应式:面板窄到阈值时改为纵向堆叠,侧栏(时间线 / 评审者)落到描述下方、占满整宽 +@container (max-width: 680px) { + .pr-info-layout { + flex-direction: column; + } + .pr-info-side { + flex: none; + width: 100%; + } +} + .status-tag { display: inline-block; padding: 1px $space-3; @@ -36,9 +71,17 @@ color: $text-on-accent; } +// 冲突标记:chip 效果(琥珀色 tint 底 + 警示色文字),比纯文字更突出 .conflict-tag { + display: inline-flex; + align-items: center; + gap: $space-1; + padding: 1px $space-3; + border-radius: $radius-sm; + background: rgba(245, 158, 11, 0.18); color: $color-warning-bright; font-weight: 600; + font-size: $fs-sm; } .pr-detail-section { @@ -60,14 +103,48 @@ } .reviewer-list { - // 显式无序列表样式:disc bullet + 适度左 padding 给 marker 留位置。 - // 12 个 reviewer 的视觉区分靠 bullet 更直观,纯堆叠看起来像段落 - list-style: disc; - padding-left: $space-8; - - li { - padding: $space-2 0; - } + // 行式展示(与活动时间线一致):前置状态图标 + 头像 + 名 + 决断 chip,不再用 disc bullet + list-style: none; + padding-left: 0; + margin: 0; +} +.reviewer-item { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-2 0; +} +.reviewer-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + color: $text-muted; +} +.reviewer-icon-approved { + color: $color-approved; +} +.reviewer-icon-needsWork { + color: $color-warning-bright; +} +// 待评审(unapproved):中性空心圆点 +.reviewer-pending-dot { + width: 9px; + height: 9px; + border: 1.5px solid currentColor; + border-radius: $radius-full; + opacity: 0.6; +} +.reviewer-name { + // 侧栏较窄:名字过长时省略,给后置 chip 让位 + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: $text-primary; } .tag-approved { @@ -77,7 +154,15 @@ .pr-detail-kv { display: grid; - grid-template-columns: 160px 1fr; - gap: $space-3 $space-6; - font-size: $fs-lg; + // key 列按内容自适应(不再固定 160px),把更多宽度留给时间值;窄侧栏下避免日期时间换行 + grid-template-columns: auto 1fr; + gap: $space-2 $space-4; + // 侧栏较窄:时间用小字号,单行展示不换行 + font-size: $fs-sm; + + .modal-kv-val { + white-space: nowrap; + // 时间值右对齐到侧栏右缘,与左侧标签分列两端 + text-align: right; + } } 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 b2e716f..94df27d 100644 --- a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss +++ b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss @@ -82,20 +82,45 @@ // "合并" 按钮:分支合并图标 + 文案。绿底实心强调"放行 / 合并"终态动作,跟 review // approve 同绿色系但用填充 (区别于 approve 的描边态);仅在 canMerge 时渲染 +// 合并按钮:基础态与 approve 的 inactive 一致(默认描边 + 正文色,仅图标用绿色语义色), +// 不再「常绿填充」——常绿易被误判为「已点击 / 已合并」态。改以 1s 周期 blink 突出可点击; +// 点击后(merging → disabled)走 .btn:disabled 灰显并停闪。 .pr-header-merge { display: inline-flex; align-items: center; gap: $space-2; - background: $color-approved; - border-color: $color-approved; - color: $text-on-accent; + animation: pr-merge-blink 1s ease-in-out infinite; svg { flex-shrink: 0; + color: $color-approved; } - &:hover { - filter: brightness(0.93); + &:hover:not(:disabled) { + // hover 给确定反馈:停闪 + 实心绿描边 + 浅绿填充 + animation: none; + background: rgba(22, 163, 74, 0.14); + border-color: $color-approved; + } + &:disabled { + animation: none; + } +} +@keyframes pr-merge-blink { + 0%, + 100% { + border-color: $border-default; + } + 50% { + border-color: $color-approved; + background: rgba(22, 163, 74, 0.14); + } +} +// 降低动态偏好:不闪烁,保留绿描边以示可合并 +@media (prefers-reduced-motion: reduce) { + .pr-header-merge { + animation: none; + border-color: $color-approved; } } @@ -430,6 +455,8 @@ .pr-tab-pane { flex: 1; min-height: 0; + // 允许在窄宽度下收缩到小于内容固有宽度,让内部滚动容器接管横向滚动(否则会撑破、被 ChatPane 盖住) + min-width: 0; display: flex; } @@ -441,6 +468,8 @@ .pr-comments-panel { flex: 1; min-height: 0; + // 同 .pr-tab-pane:可收缩到小于内容固有宽度,横向滚动交给内层 .pr-comments-scroll + min-width: 0; display: flex; flex-direction: column; position: relative; @@ -449,7 +478,8 @@ } .pr-comments-scroll { flex: 1; - overflow-y: auto; + // 纵向常驻滚动;横向在内容低于最小宽度时出现滚动条(见下方 min-width) + overflow: auto; padding: $space-6 20px; } .pr-comments-list { @@ -460,10 +490,12 @@ flex-direction: column; gap: $space-5; } -// 顶层评论列表限宽 + 居中,与详情页一致;滚动条留在外层 .pr-comments-scroll -// 右缘。仅约束直接子列表,嵌套 replies 的 .pr-comments-list 不受影响。 +// 顶层时间线 / 评论列表:宽度在 [480, 960] 内自适应缩放并居中;窄于 480 不再压缩, +// 转为外层 .pr-comments-scroll 的横向滚动(避免内嵌代码 / 评论被挤到不可读)。 +// 仅约束直接子列表,嵌套 replies 的 .pr-comments-list 不受影响。 .pr-comments-scroll > .pr-comments-list { max-width: 960px; + min-width: 480px; margin: 0 auto; } .pr-comments-replies { @@ -725,6 +757,17 @@ padding: 0; } } +// 可点击锚点 chip(活动视图):button 形态需重置原生样式,并给可点反馈 +.pr-comment-anchor-link { + border: none; + font-family: inherit; + cursor: pointer; + + &:hover { + filter: brightness(1.15); + text-decoration: underline; + } +} .pr-comment-time { margin-left: auto; font-size: $fs-xs; @@ -952,7 +995,10 @@ background: $bg-app; } .pr-commits-table { + // 表格限宽 960 居中,与详情 / 活动 / 评论页一致;滚动条留在外层 .pr-commits-panel 右缘 width: 100%; + max-width: 960px; + margin: 0 auto; border-collapse: collapse; font-size: $fs-md; @@ -964,15 +1010,18 @@ } th { text-align: left; - padding: $space-3 $space-4; + // 表头与行同步加高,保持视觉一致 + padding: $space-5 $space-4; font-weight: 600; color: $text-muted; - font-size: $fs-sm; + // 表头字号不小于正文(与表格 $fs-md 一致),靠加粗 + muted 色区分而非缩小 + font-size: $fs-md; border-bottom: 1px solid $border-default; white-space: nowrap; } td { - padding: $space-3 $space-4; + // 行高适当加高(纵向 padding 6px → 10px),提交列表更松弛易读 + padding: $space-5 $space-4; border-bottom: 1px solid $border-muted; vertical-align: middle; } diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 3540346..3445ce6 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -81,14 +81,20 @@ export interface PrChannels { request: { localId: string }; response: { mirrorPath: string; freshClone: boolean }; }; - /** 列出 PR baseSha → headSha 之间变更的文件(自动先 sync mirror) */ + /** + * 列出变更文件(自动先 sync mirror)。默认 PR baseSha → headSha 的全部变更; + * 传 base / head(如某 commit 的 `parent..sha`)则列该范围的变更,用于「查看特定 commit」。 + */ 'diff:listChangedFiles': { - request: { localId: string }; + request: { localId: string; base?: string; head?: string }; response: DiffChangedFile[]; }; - /** 读取 PR base 或 head 一侧某文件的内容(二进制返回 {binary:true}) */ + /** + * 读取 base 或 head 一侧某文件的内容(二进制返回 {binary:true})。默认取 PR base / head 一侧; + * 传 base / head sha 则按指定范围取(commit 视图:base=parent、head=commit)。 + */ 'diff:getFileContent': { - request: { localId: string; side: DiffSide; path: string }; + request: { localId: string; side: DiffSide; path: string; base?: string; head?: string }; response: DiffFileContent; }; /** @@ -142,7 +148,7 @@ export interface PrChannels { * renderer 能区分"未变更行(出 blame)"vs"PR 改动行(出色带占位)"。 */ 'diff:getBlame': { - request: { localId: string; path: string }; + request: { localId: string; path: string; base?: string; head?: string }; response: { /** 仅未变更行的 blame(已过滤掉 PR 改动行) */ lines: DiffBlameLine[];