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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、时间小字号右对齐,避免元素过散。
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 去重)
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/renderer/src/components/common/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ export function CloseIcon({ size = 16 }: IconProps) {
);
}

/** 三角警示(叹号):合并冲突等需要用户注意的状态。文件树冲突文件行用。 */
export function ConflictIcon({ size = 14, className }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M8 2 L14.5 13.5 H1.5 Z" />
<line x1="8" y1="6.5" x2="8" y2="9.5" />
<line x1="8" y1="11.5" x2="8" y2="11.6" />
</svg>
);
}

/** 文件夹:选择目录按钮用 */
export function FolderIcon({ size = 14 }: IconProps) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
useBlame,
useChangedFiles,
useCommentZones,
useConflictFiles,
useDiffComments,
useDiffNav,
useDiffOverviewMarks,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -287,6 +290,7 @@ export function DiffView({
selectedKey={selectedKey}
commentCountByPath={commentCountByPath}
draftCountByPath={draftCountByPath}
conflictPaths={conflictPaths}
onSelect={(f) => setSelectedKey(fileKey(f))}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -12,6 +12,8 @@ interface FileTreeProps {
commentCountByPath: Map<string, number>;
/** path → 本地待发布草稿数 (pending + edited)。跟 PR header "提交评审 (N)" 同口径 */
draftCountByPath: Map<string, number>;
/** 合并会冲突的文件路径集合:命中的文件行在状态点左侧标三角警示图标。 */
conflictPaths: Set<string>;
onSelect: (file: DiffChangedFile) => void;
}

Expand Down Expand Up @@ -44,6 +46,7 @@ export function FileTree({
selectedKey,
commentCountByPath,
draftCountByPath,
conflictPaths,
onSelect,
}: FileTreeProps) {
const { t } = useTranslation();
Expand All @@ -70,6 +73,7 @@ export function FileTree({
selectedKey,
commentCountByPath,
draftCountByPath,
conflictPaths,
onSelect,
collapsed,
toggle,
Expand All @@ -84,6 +88,7 @@ interface RenderCtx {
selectedKey: string | null;
commentCountByPath: Map<string, number>;
draftCountByPath: Map<string, number>;
conflictPaths: Set<string>;
onSelect: (file: DiffChangedFile) => void;
collapsed: Set<string>;
toggle: (path: string) => void;
Expand Down Expand Up @@ -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(
<div
key={`f:${n.path}`}
Expand Down Expand Up @@ -172,6 +178,15 @@ function renderChildren(nodes: TreeNode[], depth: number, ctx: RenderCtx): React
{count}
</span>
)}
{conflict && (
<span
className="tree-conflict"
title={ctx.t('fileTree.conflictTitle')}
aria-label="merge conflict"
>
<ConflictIcon size={13} />
</span>
)}
<span
className={`tree-status diff-file-status file-${f.status}`}
aria-label={f.status}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { useFileListWidth } from './useFileListWidth';
export { useSyncProgress } from './useSyncProgress';
export { useDiffScope, type DiffScopeState } from './useDiffScope';
export { useChangedFiles, type ChangedFilesState } from './useChangedFiles';
export { useConflictFiles } from './useConflictFiles';
export { useFileContent, type FileContentState } from './useFileContent';
export { useDiffComments, type DiffCommentsState } from './useDiffComments';
export { useBlame, type BlameState } from './useBlame';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import type { StoredPullRequest } from '@meebox/shared';
import { invoke } from '../../../../../../api';

/**
* 拉「合并会冲突的文件」路径集合,供文件树标三角警示。仅当远端判定 PR 有冲突(pr.hasConflict)时才打
* 后端(后端再跑本地 merge-tree 试合并);无冲突直接给空集。失败保守返回空集(不标记,不报错)。
*/
export function useConflictFiles(pr: StoredPullRequest): Set<string> {
const [paths, setPaths] = useState<Set<string>>(() => 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;
}
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@
},
"fileTree": {
"commentCountTitle_other": "{{count}} 件のリモートコメント",
"conflictTitle": "ターゲットブランチへのマージで競合します",
"draftCountTitle_other": "{{count}} 件の公開待ち下書き"
},
"inlineCodeContext": {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@
},
"fileTree": {
"commentCountTitle_other": "{{count}} 条远端评论",
"conflictTitle": "合并到目标分支会产生冲突",
"draftCountTitle_other": "{{count}} 条待发布草稿"
},
"inlineCodeContext": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions packages/ipc/src/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
58 changes: 58 additions & 0 deletions packages/repo-mirror/src/repo-mirror-manager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>): Record<string, string> {
const out: Record<string, string> = {};
Expand Down Expand Up @@ -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<string[]> {
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。
Expand Down Expand Up @@ -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 头形如:
* `<sha> <origLine> <finalLine> [<numLines>]`
Expand Down
Loading
Loading