diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de2fcdb..b0e65a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - Diff 支持给「删除行」新增行内评论 / 草稿:此前 hover「+」只挂在 head 侧(新增行),现并排视图下 base 侧(删除 / 上下文行)也可 hover「+」创建,锚定 `side: 'old'`(发布映射 Bitbucket `lineType: removed / fileType: FROM`)。统一(inline)视图下删除行以 view zone 呈现、无可 hover 行号,仍需切并排视图创建。 +- Diff 文件树标注合并冲突文件:有冲突的 PR(`pr.hasConflict`)打开 Diff 时,对会冲突的文件在右侧状态圆点左侧标一个琥珀色三角警示图标(hover 提示「合并到目标分支会产生冲突」),无需逐文件试合并即可一眼定位冲突所在。冲突文件由后端就 PR 目标分支 tip ⟂ 源 head 跑本地 `git merge-tree --write-tree` 试合并解析得到(新增 `diff:listConflictFiles` 通道,仅 `hasConflict` 为真时实际试合并、失败保守不标记)。 + - PR 评审界面交互细节优化:合并按钮去掉「常绿填充」(易误判为已点击),改为与 approve 同款基础态 + 1s blink 突出可点击、点击后沿用 disabled 灰显;提交标签页表格行高加高、表头字号不小于正文;活动视图 inline 评论的「文件:行号」锚点可点击,直接跳到 Diff 标签对应位置;活动内容区宽度在 [480, 960] 内自适应、窄于 480 转横向滚动(修正窄宽下被 ChatPane 遮盖);PR 头部「冲突」标记改为带色 chip 展示。 - PR 详情标签页交互优化:整面板国际化(原「描述 / 时间线」写死中文、「Reviewers」写死英文,现按界面语言出文案);reviewer 列表参照活动时间线行式展示(前置状态图标 + 头像 + 名 + 决断 chip,「评审者 / 已批准 / 要求修改 / 待评审」);时间线精简为「远端创建 / 远端更新 / 最近更新时间」(移除「本地首次发现」);改为左右布局(左描述、右时间线 + 评审者),面板窄到阈值时按容器查询响应式将侧栏堆叠到描述下方;侧栏限宽 400px、时间小字号右对齐,避免元素过散。 diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 56da0383..86c23f0d 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -187,6 +187,19 @@ export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (_ return ctx.repoMirror.listChangedFiles(id, base, head); }; +/** + * 列出合并会冲突的文件(文件树据此标三角警示)。仅当远端判定 PR 有冲突(pr.hasConflict)才实际跑 + * 本地 merge-tree 试合并——目标分支 tip ⟂ 源 head;无冲突的 PR 直接返回空,省一次试合并。 + */ +export const listConflictFiles: IpcController<'diff:listConflictFiles'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + if (!pr.hasConflict) return []; + const id = ctx.pr.repoIdentityFor(pr); + await ctx.pr.ensureMirrorReadyForPr(pr); + return ctx.repoMirror.listConflictFiles(id, pr.targetRef.sha, pr.sourceRef.sha); +}; + /** * 读 base / head 一侧文件内容。默认 PR merge-base / head;传 base/head 则按指定范围 * (commit 视图:base=parent、head=commit)。 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 40ac3a92..16af4616 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -69,6 +69,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('prs:merge', pr.mergePr); // 合并 PR ipcMain.handle('repo:sync', pr.syncRepo); // 同步 PR 所属 repo 本地镜像 ipcMain.handle('diff:listChangedFiles', pr.listChangedFiles); // 变更文件列表 + ipcMain.handle('diff:listConflictFiles', pr.listConflictFiles); // 合并冲突文件列表(文件树警示) ipcMain.handle('diff:getFileContent', pr.getFileContent); // 文件内容(base / head 一侧) ipcMain.handle('diff:commentCountCached', pr.getCommentCountCached); // 评论数角标(仅缓存) ipcMain.handle('diff:listComments', pr.listComments); // 拉评论(缓存 + in-flight 去重) diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx index d473b7b7..4d029f11 100644 --- a/apps/desktop/src/renderer/src/components/common/icons.tsx +++ b/apps/desktop/src/renderer/src/components/common/icons.tsx @@ -53,6 +53,28 @@ export function CloseIcon({ size = 16 }: IconProps) { ); } +/** 三角警示(叹号):合并冲突等需要用户注意的状态。文件树冲突文件行用。 */ +export function ConflictIcon({ size = 14, className }: IconProps) { + return ( + + ); +} + /** 文件夹:选择目录按钮用 */ export function FolderIcon({ size = 14 }: IconProps) { return ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx index cbbd31a1..f134b105 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx @@ -18,6 +18,7 @@ import { useBlame, useChangedFiles, useCommentZones, + useConflictFiles, useDiffComments, useDiffNav, useDiffOverviewMarks, @@ -96,6 +97,8 @@ export function DiffView({ ); const { files, filesError, retryFiles, selectedKey, setSelectedKey, selected, loadedKey } = useChangedFiles(pr, range, viewKey); + // 合并会冲突的文件路径集合(仅 pr.hasConflict 时实拉),文件树据此标三角警示。 + const conflictPaths = useConflictFiles(pr); const { content, contentLoading, contentError, setContentError } = useFileContent( pr, selected, @@ -287,6 +290,7 @@ export function DiffView({ selectedKey={selectedKey} commentCountByPath={commentCountByPath} draftCountByPath={draftCountByPath} + conflictPaths={conflictPaths} onSelect={(f) => setSelectedKey(fileKey(f))} /> )} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx index 33f37f50..6872cedd 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Icon } from '@iconify/react'; import type { DiffChangedFile } from '@meebox/ipc'; -import { ChevronIcon } from '../../../../common'; +import { ChevronIcon, ConflictIcon } from '../../../../common'; interface FileTreeProps { files: DiffChangedFile[]; @@ -12,6 +12,8 @@ interface FileTreeProps { commentCountByPath: Map; /** path → 本地待发布草稿数 (pending + edited)。跟 PR header "提交评审 (N)" 同口径 */ draftCountByPath: Map; + /** 合并会冲突的文件路径集合:命中的文件行在状态点左侧标三角警示图标。 */ + conflictPaths: Set; onSelect: (file: DiffChangedFile) => void; } @@ -44,6 +46,7 @@ export function FileTree({ selectedKey, commentCountByPath, draftCountByPath, + conflictPaths, onSelect, }: FileTreeProps) { const { t } = useTranslation(); @@ -70,6 +73,7 @@ export function FileTree({ selectedKey, commentCountByPath, draftCountByPath, + conflictPaths, onSelect, collapsed, toggle, @@ -84,6 +88,7 @@ interface RenderCtx { selectedKey: string | null; commentCountByPath: Map; draftCountByPath: Map; + conflictPaths: Set; onSelect: (file: DiffChangedFile) => void; collapsed: Set; toggle: (path: string) => void; @@ -132,6 +137,7 @@ function renderChildren(nodes: TreeNode[], depth: number, ctx: RenderCtx): React const selected = ctx.selectedKey === fileKey(f); const count = ctx.commentCountByPath.get(f.path) ?? 0; const draftCount = ctx.draftCountByPath.get(f.path) ?? 0; + const conflict = ctx.conflictPaths.has(f.path) || (!!f.oldPath && ctx.conflictPaths.has(f.oldPath)); out.push(
)} + {conflict && ( + + + + )} { + const [paths, setPaths] = useState>(() => new Set()); + + useEffect(() => { + if (!pr.hasConflict) { + setPaths(new Set()); + return; + } + let cancelled = false; + invoke('diff:listConflictFiles', { localId: pr.localId }) + .then((list) => { + if (!cancelled) setPaths(new Set(list)); + }) + .catch((e: unknown) => { + if (!cancelled) { + console.warn('diff:listConflictFiles failed', e); + setPaths(new Set()); + } + }); + return () => { + cancelled = true; + }; + }, [pr.localId, pr.hasConflict]); + + return paths; +} 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 05797066..fc29fd37 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -421,6 +421,7 @@ "fileTree": { "commentCountTitle_one": "{{count}} Remote-Kommentar", "commentCountTitle_other": "{{count}} Remote-Kommentare", + "conflictTitle": "Konflikt beim Merge in den Zielbranch", "draftCountTitle_one": "{{count}} Entwurf zur Veröffentlichung ausstehend", "draftCountTitle_other": "{{count}} Entwürfe zur Veröffentlichung ausstehend" }, 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 f9f47dd4..607a9594 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -421,6 +421,7 @@ "fileTree": { "commentCountTitle_one": "{{count}} remote comment", "commentCountTitle_other": "{{count}} remote comments", + "conflictTitle": "Conflicts when merged into the target branch", "draftCountTitle_one": "{{count}} draft pending publish", "draftCountTitle_other": "{{count}} drafts pending publish" }, 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 f3f76191..49a10ae3 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -411,6 +411,7 @@ }, "fileTree": { "commentCountTitle_other": "{{count}} 件のリモートコメント", + "conflictTitle": "ターゲットブランチへのマージで競合します", "draftCountTitle_other": "{{count}} 件の公開待ち下書き" }, "inlineCodeContext": { 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 1064cfad..30b4cac8 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -411,6 +411,7 @@ }, "fileTree": { "commentCountTitle_other": "{{count}} 条远端评论", + "conflictTitle": "合并到目标分支会产生冲突", "draftCountTitle_other": "{{count}} 条待发布草稿" }, "inlineCodeContext": { diff --git a/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss b/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss index d579a670..7eaa1485 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss @@ -305,6 +305,14 @@ flex-shrink: 0; } +// 合并冲突警示:紧贴状态点左侧的三角叹号,用 warning 色提示该文件合并会冲突。 +.tree-conflict { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: $color-warning; +} + // 评论 chip + 状态点的右侧容器;横向滚动时固定在视口右边缘,左缘渐变淡出, // 遮盖背后滚出来的长文件名。背景跟随 .tree-row 状态 (默认/hover/selected)。 .tree-row-right { diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 4bbaf30e..ee7c71a0 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -91,6 +91,15 @@ export interface PrChannels { request: { localId: string; base?: string; head?: string }; response: DiffChangedFile[]; }; + /** + * 列出合并到目标分支会产生冲突的文件路径(PR 目标 tip ⟂ 源 head 的 `git merge-tree` 试合并)。 + * 仅 `pr.hasConflict` 为真时才实际跑 merge-tree,否则直接返回空数组(省一次本地试合并)。 + * 试合并失败 / 无法判定时返回空数组(保守不标冲突),文件树据此在对应行标三角警示图标。 + */ + 'diff:listConflictFiles': { + request: { localId: string }; + response: string[]; + }; /** * 读取 base 或 head 一侧某文件的内容(二进制返回 {binary:true})。默认取 PR base / head 一侧; * 传 base / head sha 则按指定范围取(commit 视图:base=parent、head=commit)。 diff --git a/packages/repo-mirror/src/repo-mirror-manager.ts b/packages/repo-mirror/src/repo-mirror-manager.ts index 65762a1e..087a6a22 100644 --- a/packages/repo-mirror/src/repo-mirror-manager.ts +++ b/packages/repo-mirror/src/repo-mirror-manager.ts @@ -1,5 +1,7 @@ +import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { promisify } from 'node:util'; import simpleGit, { type SimpleGit } from 'simple-git'; import type { Logger } from 'pino'; import type { SyncProgressEvent } from '@meebox/shared'; @@ -43,6 +45,9 @@ const GIT_UNSAFE_ENV_KEYS = new Set([ 'prefix', ]); +/** Promise 版 execFile:用于 simple-git 会因非零退出吞掉 stdout 的命令(如 merge-tree 冲突时退出码 1)。 */ +const execFileAsync = promisify(execFile); + /** 从 env 里剔除 simple-git 会拦的危险 key(大小写不敏感)。 */ function stripGitUnsafeEnv(env: Record): Record { const out: Record = {}; @@ -452,6 +457,43 @@ export class RepoMirrorManager { return parseNameStatusZ(out); } + /** + * 列出把源 head 合并进目标 tip 会冲突的文件路径(`git merge-tree --write-tree` 的试合并,git ≥ 2.38)。 + * 无冲突(退出码 0)/ 无法判定(退出码 < 0 或 git 过旧)→ 返回空数组,由调用方保守不标记。 + * + * merge-tree 冲突时退出码为 1 且把结果写到 stdout,simple-git 会因非零退出吞掉 stdout,故直接走 + * execFile 自行捕获 stdout。`-z` 让输出 NUL 分隔(路径含空格/中文/引号都不破),`--name-only` 只出冲突 + * 文件名:首字段是结果 tree OID,随后是各冲突文件名,遇空字段(段分隔的双 NUL)即冲突文件段结束。 + */ + async listConflictFiles( + repo: RepoIdentity, + targetSha: string, + sourceSha: string, + ): Promise { + if (!targetSha || !sourceSha) return []; + const mirrorPath = this.mirrorPath(repo); + try { + // 退出码 0 = 干净可合并,无冲突。 + await execFileAsync( + 'git', + ['merge-tree', '--write-tree', '--name-only', '-z', targetSha, sourceSha], + { cwd: mirrorPath, maxBuffer: 64 * 1024 * 1024 }, + ); + return []; + } catch (err) { + const e = err as { code?: number | string; stdout?: string | Buffer }; + // 退出码 1 = 存在冲突,stdout 携带冲突文件段;其余(无法完成试合并 / git 过旧)保守返回空。 + if (e.code === 1 && e.stdout != null) { + return parseMergeTreeConflictsZ(e.stdout.toString()); + } + this.opts.logger?.warn( + { err, repo: this.repoKey(repo), targetSha, sourceSha }, + 'git merge-tree conflict probe failed; treating as no conflict', + ); + return []; + } + } + /** * 读取某文件在某 commit 的内容。完整 bare clone 下 blob 都在本地,直接 git show。 * 文件不在该 commit (新增/删除场景) 返回空 content。 @@ -772,6 +814,22 @@ function parseNameStatusZ(raw: string): ChangedFile[] { return out; } +/** + * 解析 `git merge-tree --write-tree --name-only -z` 在冲突时的 stdout。 + * 格式(NUL 分隔):`<结果 tree OID>\0<冲突文件名>\0...\0\0<提示信息段...>`——首字段是 tree OID,随后 + * 是各冲突文件名,遇空字段(段间双 NUL)即冲突文件段结束,后续提示信息段忽略。同名去重。 + */ +export function parseMergeTreeConflictsZ(raw: string): string[] { + const parts = raw.split('\0'); + const files: string[] = []; + // parts[0] = 结果 tree OID;从下一字段起收集冲突文件名,遇空字段(段分隔)停止。 + for (let i = 1; i < parts.length; i++) { + if (parts[i] === '') break; + files.push(parts[i]!); + } + return [...new Set(files)]; +} + /** * 解析 `git blame --porcelain` 输出。每个 hunk 头形如: * ` []` diff --git a/packages/repo-mirror/tests/repo-mirror-manager.test.ts b/packages/repo-mirror/tests/repo-mirror-manager.test.ts index 889dae0c..8126ade0 100644 --- a/packages/repo-mirror/tests/repo-mirror-manager.test.ts +++ b/packages/repo-mirror/tests/repo-mirror-manager.test.ts @@ -7,6 +7,7 @@ import { RepoMirrorManager, parseBlamePorcelain, parseHunkAddedLines, + parseMergeTreeConflictsZ, } from '../src/repo-mirror-manager.js'; import type { RepoIdentity } from '../src/types.js'; @@ -359,6 +360,57 @@ describe('RepoMirrorManager diff/content', () => { const r = await mgr.getFileContent(repo, sha, 'icon.png'); expect(r.binary).toBe(true); }); + + it('parseMergeTreeConflictsZ 取首 OID 后到段分隔双 NUL 间的冲突文件名(去重)', () => { + // `git merge-tree --write-tree --name-only -z` 冲突时的 stdout:OID\0 file\0 \0(段分隔) 提示... + const raw = + '4530c9c9c26e09ddc2340fd825c09a190039d7d2\0f.txt\0src/x y.ts\0\0' + + '1\0f.txt\0CONFLICT (content): Merge conflict in f.txt\0'; + expect(parseMergeTreeConflictsZ(raw)).toEqual(['f.txt', 'src/x y.ts']); + }); + + it('parseMergeTreeConflictsZ 无冲突文件(首字段后即段分隔)返回空数组', () => { + expect(parseMergeTreeConflictsZ('4530c9c9\0\0info')).toEqual([]); + }); + + it('listConflictFiles 列出试合并到目标分支会冲突的文件', async () => { + const upstream = simpleGit(upstreamPath); + await upstream.addConfig('commit.gpgsign', 'false', false, 'local'); + // 目标分支名(init 默认 master / main,环境而定),后续 checkout 回它。 + const main = (await upstream.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); + // base:两个文件 + await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb\nc\n'); + await fs.writeFile(path.join(upstreamPath, 'solo.txt'), 'x\n'); + await upstream.add('.'); + await upstream.commit('conflict base'); + const baseSha = (await upstream.revparse(['HEAD'])).trim(); + + // feature 分支:改 shared.txt 第二行 + 改 solo.txt + await upstream.checkoutLocalBranch('feature'); + await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb-feature\nc\n'); + await fs.writeFile(path.join(upstreamPath, 'solo.txt'), 'x-feature\n'); + await upstream.add('.'); + await upstream.commit('feature edit'); + const featureSha = (await upstream.revparse(['HEAD'])).trim(); + + // 回目标分支并对 shared.txt 同一行做冲突改动;solo.txt 不动 → 仅 shared.txt 冲突 + await upstream.checkout([main]); + await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb-main\nc\n'); + await upstream.add('.'); + await upstream.commit('main edit'); + const targetSha = (await upstream.revparse([main])).trim(); + expect(targetSha).not.toBe(baseSha); + + const mgr = makeManager(); + await mgr.syncMirror(repo); + + const conflicts = await mgr.listConflictFiles(repo, targetSha, featureSha); + expect(conflicts).toContain('shared.txt'); + expect(conflicts).not.toContain('solo.txt'); + + // 无冲突方向(同一分支与自身)→ 空 + expect(await mgr.listConflictFiles(repo, targetSha, targetSha)).toEqual([]); + }); }); describe('RepoMirrorManager.materializeWorktree', () => {