From 2f4d14d6a02e1f50d982d4f53253b3fa6e679ed7 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 10:32:55 +0800 Subject: [PATCH 1/4] feat(diff): show Git LFS status on binary-file diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binary files (images, office docs, PDFs, …) render a "binary, not rendered" placeholder with no indication of whether they are Git LFS-managed. Detect LFS and surface the status. - repo-mirror getFileContent: detect the Git LFS pointer blob (the mirror never smudges, so an LFS-managed file's content is the pointer text) and return it as binary + the real byte size from the pointer, instead of dumping pointer text as a bogus diff - contract: extend FileContent / DiffFileContent binary variant with an optional lfs { size } field - DiffPane: on the binary placeholder show a "Git LFS · " tag for LFS-managed files, or a "⚠ Not LFS" tag for plain inline binaries (per side, preferring head); i18n across all four locales - test: cover LFS pointer detection + size parsing in repo-mirror Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/diff/DiffPane.tsx | 23 ++++++++++++- .../pr/tabs/diff/search/diff-search.ts | 4 +-- .../src/renderer/src/i18n/locales/de-DE.json | 3 ++ .../src/renderer/src/i18n/locales/en-US.json | 3 ++ .../src/renderer/src/i18n/locales/ja-JP.json | 3 ++ .../src/renderer/src/i18n/locales/zh-CN.json | 3 ++ .../src/styles/features/diff/view.scss | 32 +++++++++++++++++++ packages/ipc/src/common.ts | 9 +++++- .../repo-mirror/src/repo-mirror-manager.ts | 10 +++++- packages/repo-mirror/src/types.ts | 10 +++++- .../tests/repo-mirror-manager.test.ts | 22 +++++++++++++ 11 files changed, 116 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx index 0d5c984b..132b2b9d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx @@ -8,6 +8,7 @@ import { useMonacoEditorTheme } from '../../../../../hooks/useTheme'; import { useEditorAppearance } from '../../../../../stores/editor-appearance-store'; import { resolveEditorFontFamily } from '../../../../../theme'; import { languageFor } from '../../../../../utils/language'; +import { formatBytes } from '../../../settings/utils'; import { PaneLoading } from '../../../../common'; import { Spinner } from './DiffStatus'; import type { LoadedContent } from './diff-types'; @@ -119,7 +120,27 @@ export function DiffPane({ ); } if (content.base.binary || content.head.binary) { - return
{t('diffView.binaryNotRendered')}
; + // Git LFS status: prefer the head side's pointer info (the current version), fall back to base (e.g. a deleted file). + const headLfs = content.head.binary ? content.head.lfs : undefined; + const baseLfs = content.base.binary ? content.base.lfs : undefined; + const lfs = headLfs ?? baseLfs; + return ( +
+ {t('diffView.binaryNotRendered')} + {lfs ? ( + + Git LFS{lfs.size != null ? ` · ${formatBytes(lfs.size)}` : ''} + + ) : ( + + + {t('diffView.notLfs')} + + )} +
+ ); } return (
diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts index 69962153..d678f52e 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts @@ -208,8 +208,8 @@ export async function loadContent( side, path, }); - // DiffFileContent union: {binary:false, content:string} or {binary:true}. - // binary files skip search (no comparable text); non-binary takes the content field + // DiffFileContent union: {binary:false, content:string} or {binary:true} (the latter incl. Git LFS pointers). + // binary / LFS files skip search (no comparable text); only non-binary takes the content field const text = c.binary === false ? c.content : null; cache.set(k, text); return text; 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 e0967820..80cefd4e 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -345,6 +345,7 @@ "dismissNotificationTitle": "Diese Benachrichtigung schließen", "fileCount_one": "{{count}} Datei", "fileCount_other": "{{count}} Dateien", + "lfsManagedTitle": "Von Git LFS verwaltet", "loadChangedFilesFailed": "Geänderte Dateien konnten nicht geladen werden", "loadCommentsFailed": "Kommentare konnten nicht geladen werden", "loadingContentHint": "git blob wird aus dem lokalen Mirror gelesen; große oder binäre Dateien können langsamer sein", @@ -352,6 +353,8 @@ "loadingContentSuffix": "Inhalt…", "noFileChanges": "Dieser PR enthält keine Dateiänderungen", "noResultFromMain": "Kein Ergebnis vom main-Prozess zurückgegeben", + "notLfs": "Kein LFS", + "notLfsTitle": "Binärdatei direkt in Git gespeichert, nicht von Git LFS verwaltet", "preparingSync": "Synchronisierung wird vorbereitet", "readFileContentFailed": "Dateiinhalt konnte nicht gelesen werden", "readFileContentFailedNamed": "Inhalt von {{path}} konnte nicht gelesen werden", 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 605e6276..8404f98e 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -345,6 +345,7 @@ "dismissNotificationTitle": "Dismiss this notification", "fileCount_one": "{{count}} file", "fileCount_other": "{{count}} files", + "lfsManagedTitle": "Managed by Git LFS", "loadChangedFilesFailed": "Failed to load changed files", "loadCommentsFailed": "Failed to load comments", "loadingContentHint": "Reading git blob from the local mirror; large or binary files may be slower", @@ -352,6 +353,8 @@ "loadingContentSuffix": "content…", "noFileChanges": "This PR has no file changes", "noResultFromMain": "No result returned from main process", + "notLfs": "Not LFS", + "notLfsTitle": "Binary stored inline in git, not managed by Git LFS", "preparingSync": "Preparing sync", "readFileContentFailed": "Failed to read file content", "readFileContentFailedNamed": "Failed to read content of {{path}}", 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 658352e6..783b3c52 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -337,6 +337,7 @@ "diffRenderFailedHint": "ファイルを切り替えるか再試行すると通常は復旧します。元のエラーは console に記録されています。", "dismissNotificationTitle": "この通知を閉じる", "fileCount_other": "{{count}} ファイル", + "lfsManagedTitle": "Git LFS で管理", "loadChangedFilesFailed": "変更ファイルの読み込みに失敗しました", "loadCommentsFailed": "コメントの読み込みに失敗しました", "loadingContentHint": "ローカルミラーから git blob を読み込んでいます。大きいファイルやバイナリファイルは時間がかかる場合があります", @@ -344,6 +345,8 @@ "loadingContentSuffix": "の内容…", "noFileChanges": "この PR にはファイル変更がありません", "noResultFromMain": "main プロセスから結果が返されませんでした", + "notLfs": "非 LFS", + "notLfsTitle": "バイナリが git に直接保存されており、Git LFS で管理されていません", "preparingSync": "同期を準備中", "readFileContentFailed": "ファイル内容の読み込みに失敗しました", "readFileContentFailedNamed": "{{path}} の内容の読み込みに失敗しました", 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 d70b1112..ca3d5247 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -337,6 +337,7 @@ "diffRenderFailedHint": "切换文件 / 重试通常能恢复。底层异常已记录到 console。", "dismissNotificationTitle": "收起此通知", "fileCount_other": "{{count}} 个文件", + "lfsManagedTitle": "由 Git LFS 管理", "loadChangedFilesFailed": "拉取变更文件列表失败", "loadCommentsFailed": "拉取评论失败", "loadingContentHint": "从本地镜像读 git blob,大文件 / 二进制判定时可能略慢", @@ -344,6 +345,8 @@ "loadingContentSuffix": "内容…", "noFileChanges": "该 PR 无文件变更", "noResultFromMain": "main 端未返回结果", + "notLfs": "非 LFS", + "notLfsTitle": "二进制内联存储于 git,未由 Git LFS 管理", "preparingSync": "准备同步", "readFileContentFailed": "读取文件内容失败", "readFileContentFailedNamed": "读取 {{path}} 内容失败", diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index a79dd917..6bcc6963 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -272,6 +272,7 @@ // Binary file (image/binary) placeholder .diff-binary { + position: relative; display: flex; align-items: center; justify-content: center; @@ -280,3 +281,34 @@ color: $color-warning-bright; font-size: $fs-lg; } +// Git LFS status tag, pinned to the pane's top-right corner: LFS-managed (neutral) vs inline binary / not LFS (warning). +.diff-lfs-tag, +.diff-nonlfs-tag { + position: absolute; + top: $space-3; + right: $space-3; + display: inline-flex; + align-items: center; + gap: 4px; + line-height: 1; + font-size: $fs-sm; + padding: 2px 8px; + border-radius: $radius-sm; + border: 1px solid transparent; + white-space: nowrap; +} +// The ⚠️ emoji sits on the text baseline by default; give it its own tight box so the flex align-items centers it against the label. +.diff-lfs-icon { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 0.9em; +} +.diff-lfs-tag { + color: $text-muted; + border-color: $border-default; +} +.diff-nonlfs-tag { + color: $color-warning-bright; + border-color: $color-warning-bright; +} diff --git a/packages/ipc/src/common.ts b/packages/ipc/src/common.ts index 6fe8221d..dbb94b5d 100644 --- a/packages/ipc/src/common.ts +++ b/packages/ipc/src/common.ts @@ -16,7 +16,14 @@ export interface DiffChangedFile { similarity?: number; } -export type DiffFileContent = { binary: false; content: string } | { binary: true }; +/** + * File content for diff rendering. `binary: true` = not rendered as a text diff; the optional `lfs` field marks a Git + * LFS-managed file (the mirror holds the LFS pointer, so we show an LFS placeholder + the real byte size instead of the + * pointer text). `binary: true` with no `lfs` is a plain inline binary (committed to git directly, not LFS). + */ +export type DiffFileContent = + | { binary: false; content: string } + | { binary: true; lfs?: { size: number | null } }; export type DiffSide = 'base' | 'head'; diff --git a/packages/repo-mirror/src/repo-mirror-manager.ts b/packages/repo-mirror/src/repo-mirror-manager.ts index 46acc269..43e1a09e 100644 --- a/packages/repo-mirror/src/repo-mirror-manager.ts +++ b/packages/repo-mirror/src/repo-mirror-manager.ts @@ -508,7 +508,8 @@ export class RepoMirrorManager { /** * Read a file's content at a given commit. Under a full bare clone all blobs are local, so git show directly. * If the file is not in that commit (add/delete scenarios) returns empty content. - * Simple null-byte heuristic to detect binary (first 8000 characters). + * A Git LFS pointer is detected first (surfaced as an LFS marker + real size); otherwise a simple null-byte + * heuristic (first 8000 characters) flags an inline binary. */ async getFileContent(repo: RepoIdentity, sha: string, filePath: string): Promise { const mirrorPath = this.mirrorPath(repo); @@ -519,6 +520,13 @@ export class RepoMirrorManager { // File does not exist at that commit (before an add / after a delete), return empty return { binary: false, content: '' }; } + // Git LFS: the mirror stores the pointer blob (LFS objects are never smudged, see the worktree filter config above), + // so an LFS-managed file's content here is the small pointer text. Detect it and surface an LFS marker + the real + // byte size (from the pointer's `size` line), instead of rendering the pointer text as a bogus diff. + if (content.startsWith('version https://git-lfs.github.com/spec/v1')) { + const m = /^size (\d+)$/m.exec(content); + return { binary: true, lfs: { size: m ? Number(m[1]) : null } }; + } if (content.slice(0, 8000).includes('')) { return { binary: true }; } diff --git a/packages/repo-mirror/src/types.ts b/packages/repo-mirror/src/types.ts index fa1e8f96..b97687bd 100644 --- a/packages/repo-mirror/src/types.ts +++ b/packages/repo-mirror/src/types.ts @@ -35,7 +35,15 @@ export interface ChangedFile { similarity?: number; } -export type FileContent = { binary: false; content: string } | { binary: true }; +/** + * File content at a commit for diff rendering. `binary: true` means "not rendered as a text diff"; the optional `lfs` + * field marks a Git LFS-managed file — the mirror stores the small LFS pointer blob (objects are never smudged), so we + * detect the pointer and surface it as an LFS placeholder (with the real byte size from the pointer) instead of dumping + * the pointer text as a diff. A `binary: true` without `lfs` is a plain inline binary (stored in git directly, not LFS). + */ +export type FileContent = + | { binary: false; content: string } + | { binary: true; lfs?: { size: number | null } }; /** Single-line blame info. Parsed from `git blame --porcelain -- `. */ export interface BlameLine { diff --git a/packages/repo-mirror/tests/repo-mirror-manager.test.ts b/packages/repo-mirror/tests/repo-mirror-manager.test.ts index 7e18299f..ddc752de 100644 --- a/packages/repo-mirror/tests/repo-mirror-manager.test.ts +++ b/packages/repo-mirror/tests/repo-mirror-manager.test.ts @@ -395,6 +395,28 @@ describe('RepoMirrorManager diff/content', () => { const r = await mgr.getFileContent(repo, sha, 'icon.png'); expect(r.binary).toBe(true); + // A plain inline binary is not LFS-managed. + expect(r.binary === true && r.lfs).toBeUndefined(); + }); + + it('getFileContent detects a Git LFS pointer and surfaces its size', async () => { + // An LFS-managed file is stored in git as a small pointer blob (the mirror never smudges), so getFileContent sees + // the pointer text; it should flag binary + carry the declared size instead of returning the pointer as text. + const upstream = simpleGit(upstreamPath); + const pointer = + 'version https://git-lfs.github.com/spec/v1\n' + + 'oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n' + + 'size 12345\n'; + await fs.writeFile(path.join(upstreamPath, 'big.psd'), pointer); + await upstream.add('.'); + await upstream.commit('add lfs pointer'); + const sha = (await upstream.revparse(['HEAD'])).trim(); + + const mgr = makeManager(); + await mgr.syncMirror(repo); + + const r = await mgr.getFileContent(repo, sha, 'big.psd'); + expect(r).toEqual({ binary: true, lfs: { size: 12345 } }); }); it('parseMergeTreeConflictsZ takes conflict file names between the first OID and the section-separating double NUL (deduped)', () => { From 9f338c0720287b343ab35f1ba890bb84cfa8b751 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 11:32:38 +0800 Subject: [PATCH 2/4] feat(review): support file-level comments (whole-file, not line-anchored) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments could only be PR summaries or line-anchored inline comments. Add file-level comments — anchored to a whole file — which Bitbucket and GitHub support. Previously such remote comments were silently collapsed to summaries, losing their file association (notably Bitbucket file comments). - model: PrCommentAnchor.line/lineType become optional (absent line = file-level); comment kind gains 'file'; new capability fileLevelComments - adapters: Bitbucket maps/publishes a line-less anchor; GitHub uses subject_type: "file"; GitLab has no file-level diff-comment API → capability false + defensive guard (degrades to local menu / hides the entry) - ipc: comments:createFile channel + controller (posts via the inline-publish path with a line-less anchor) - renderer: a file-level comment strip above the diff editor shows the file's file-level comments (reusing CommentItem for full interaction parity) plus an icon "comment on file" entry (capability-gated); the header shows the file's project-relative path as a breadcrumb. CommentItem renders a path-only, non-clickable chip for file-level anchors and skips the line code context; useCommentZones excludes line-less anchors from Monaco line zones - docs: capability field + per-platform table + a file-level comments section in the comment-interactions design Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/controllers/pr.ts | 19 +++ apps/desktop/src/main/ipc.ts | 1 + .../src/main/services/notifications.ts | 8 +- .../src/components/features/pr/PrPanel.tsx | 8 +- .../pr/tabs/comments/CommentComposer.tsx | 14 +- .../features/pr/tabs/comments/CommentItem.tsx | 33 ++-- .../pr/tabs/comments/InlineCodeContext.tsx | 10 +- .../features/pr/tabs/diff/DiffView.tsx | 16 ++ .../pr/tabs/diff/FileCommentStrip.tsx | 146 ++++++++++++++++++ .../pr/tabs/diff/hooks/useCommentZones.tsx | 8 +- .../features/pr/tabs/shared/ReactionBar.tsx | 6 +- .../src/renderer/src/i18n/locales/de-DE.json | 7 +- .../src/renderer/src/i18n/locales/en-US.json | 7 +- .../src/renderer/src/i18n/locales/ja-JP.json | 7 +- .../src/renderer/src/i18n/locales/zh-CN.json | 7 +- .../src/styles/features/diff/view.scss | 86 +++++++++++ docs/arch/01-platform/01-adapter.md | 3 +- .../01-platform/04-comment-interactions.md | 9 ++ packages/ipc/src/pr.ts | 9 ++ .../src/features/comment.ts | 34 ++-- .../src/features/connection.ts | 2 + .../tests/adapter.test.ts | 6 +- .../platform-github/src/features/comment.ts | 36 +++-- .../src/features/connection.ts | 2 + packages/platform-github/src/types.ts | 2 + .../platform-gitlab/src/features/comment.ts | 5 + .../src/features/connection.ts | 2 + packages/poller/tests/poller.test.ts | 2 + packages/shared/src/platform.ts | 31 ++-- 29 files changed, 447 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index ca3d2537..ba170ae2 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -70,6 +70,25 @@ export const createComment: IpcController<'comments:create'> = async (_event, re return created; }; +/** + * Post a file-level comment (anchored to a whole file, no line) via the inline-comment publish path with a line-less + * anchor. Only reachable where the fileLevelComments capability is true (the UI gates the entry). On success clears the + * comments cache + broadcasts comments:changed so the UI refetches. + */ +export const createFileComment: IpcController<'comments:createFile'> = async (_event, req) => { + const ctx = getContext(); + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const created = await adapter.comments.publishInlineComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + { path: req.path, side: req.side ?? 'new' }, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return created; +}; + /** Minimum query length before hitting the remote user-search endpoint (avoids a request per keystroke on 1 char). */ const MENTION_SEARCH_MIN_QUERY = 2; diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index bf9945a2..736ace14 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -65,6 +65,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { */ ipcMain.handle('comments:reply', pr.replyComment); // Reply to a comment ipcMain.handle('comments:create', pr.createComment); // Create a summary comment + ipcMain.handle('comments:createFile', pr.createFileComment); // Create a file-level comment (whole file, no line) ipcMain.handle('comments:delete', pr.deleteComment); // Delete your own comment ipcMain.handle('comments:edit', pr.editComment); // Edit your own comment ipcMain.handle('comments:toggleReaction', pr.toggleReaction); // Toggle a comment emoji reaction diff --git a/apps/desktop/src/main/services/notifications.ts b/apps/desktop/src/main/services/notifications.ts index baaa8ec5..29865c7e 100644 --- a/apps/desktop/src/main/services/notifications.ts +++ b/apps/desktop/src/main/services/notifications.ts @@ -64,9 +64,11 @@ function activateOnClick(e: PollNotificationEvent): () => void { broadcast('notification:activate', { localId: e.localId, kind: e.kind, - anchor: e.comment?.anchor - ? { path: e.comment.anchor.path, line: e.comment.anchor.line } - : null, + anchor: + // File-level comments (no line) aren't line-navigable → no anchor (clicking just opens the PR). + e.comment?.anchor && e.comment.anchor.line != null + ? { path: e.comment.anchor.path, line: e.comment.anchor.line } + : null, }); }; } 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 d2e27763..6b69f74b 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -229,11 +229,13 @@ export function PrPanel({ onComposeClose={() => setComposingComment(false)} currentUserName={currentUserName} onViewCommit={viewCommit} - onJumpToAnchor={(a: PrCommentAnchor) => + onJumpToAnchor={(a: PrCommentAnchor) => { + // File-level anchors (no line) aren't line-navigable; CommentItem doesn't make them clickable, guard anyway. + if (a.line == null) return; onRequestDiffNav?.({ anchor: { path: a.path, startLine: a.line, endLine: a.line }, - }) - } + }); + }} /> diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx index 9996fb68..6b5f7ea0 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx @@ -16,14 +16,20 @@ interface CommentComposerProps { attachmentsEnabled?: boolean; /** Whether the platform supports remote user search (capabilities.userSearch); enables the mention editor's remote fallback when true. */ userSearchEnabled?: boolean; + /** + * Override the post action. Default posts a PR summary comment (`comments:create`); pass this to post elsewhere with + * the same composer (e.g. a file-level comment via `comments:createFile`). Receives the body, resolves on success. + */ + onSubmit?: (body: string) => Promise; onCancel: () => void; /** Called after posting succeeds (collapses the composer; the timeline auto-refreshes via the comments:changed event, the new comment appears at the top) */ onPosted: () => void; } /** - * Composer for a new summary (not anchored to a file) comment: textarea + send/cancel. Appears at the top of the activity timeline. - * Cmd/Ctrl+Enter sends, Esc cancels; send is disabled on an empty body. Layout reuses the reply composer's styles. + * Composer for a new comment: textarea + send/cancel. Posts a PR summary comment by default (top of the activity + * timeline), or whatever `onSubmit` overrides it to (e.g. a file-level comment). Cmd/Ctrl+Enter sends, Esc cancels; + * send is disabled on an empty body. Layout reuses the reply composer's styles. */ export function CommentComposer({ prLocalId, @@ -31,6 +37,7 @@ export function CommentComposer({ platform, attachmentsEnabled = false, userSearchEnabled = false, + onSubmit, onCancel, onPosted, }: CommentComposerProps) { @@ -46,7 +53,8 @@ export function CommentComposer({ setPosting(true); setError(null); try { - await invoke('comments:create', { localId: prLocalId, body }); + if (onSubmit) await onSubmit(body); + else await invoke('comments:create', { localId: prLocalId, body }); onPosted(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx index b0cf0c18..3ffcb5d2 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx @@ -141,32 +141,43 @@ export function CommentItem({ // inline comment anchor chip: path:line + side (old=base / new=head), letting the user locate the code position from the comment. // When onJumpToAnchor is provided (activity view) the chip becomes clickable → jump to the corresponding file/line in the Diff. const anchor = comment.anchor; + // File-level comments (no line) show just the path; line comments show `path:line`. + const anchorLabel = anchor ? ( + <> + {anchor.path} + {anchor.line != null ? `:${String(anchor.line)}` : ''} + + ) : null; + const anchorHint = anchor + ? anchor.line == null + ? t('commentsPanel.anchorFileTitle') + : t('commentsPanel.anchorTitle', { + side: anchor.side === 'old' ? 'base' : 'head', + lineType: anchor.lineType, + }) + : ''; const anchorChip = anchor ? ( - onJumpToAnchor ? ( + // File-level anchors (no line) have no line to jump to → render as a non-clickable chip even in the activity view. + onJumpToAnchor && anchor.line != null ? ( ) : ( - - {anchor.path}:{anchor.line} + + {anchorLabel} ) ) : null; // inline comment: embed a code context (Monaco read-only) above the body. replies (depth > 0) do not repeat it. + // File-level comments (no line) have no single line to show → skip the code context. const inlineCode = - comment.anchor && depth === 0 ? ( + comment.anchor && comment.anchor.line != null && depth === 0 ? ( {t('commentsPanel.loadingCodeContext')}
} > diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx index c8beaf97..214bc030 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx @@ -58,6 +58,10 @@ function InlineCodeContextImpl({ let cancelled = false; setSnippet(null); setError(null); + // File-level anchors (no line) have no line to contextualize; the caller doesn't mount this for them, but guard anyway. + // Capture as a const so the narrowing carries into the async closure below. + const line = anchor.line; + if (line == null) return; void (async () => { try { const c = await invoke('diff:getFileContent', { @@ -72,10 +76,10 @@ function InlineCodeContextImpl({ return; } const allLines = c.content.split('\n'); - const startLine = Math.max(1, anchor.line - contextLines); - const endLine = Math.min(allLines.length, anchor.line + contextLines); + const startLine = Math.max(1, line - contextLines); + const endLine = Math.min(allLines.length, line + contextLines); const text = allLines.slice(startLine - 1, endLine).join('\n'); - setSnippet({ text, startLine, anchorInSnippet: anchor.line - startLine + 1 }); + setSnippet({ text, startLine, anchorInSnippet: line - startLine + 1 }); } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : String(e)); 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 87cfa2e5..02070298 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 @@ -16,6 +16,7 @@ import { DiffSearchPanel } from './DiffSearchPanel'; import { FileTree } from './FileTree'; import { DiffScopeSelect } from './DiffScopeSelect'; import { DiffPane } from './DiffPane'; +import { FileCommentStrip } from './FileCommentStrip'; import { BackendErrorBanner, BackendErrorView, SyncProgress } from './DiffStatus'; import { BlameColumn } from './blame/BlameColumn'; import { fileKey, type PendingCommitView } from './diff-types'; @@ -396,6 +397,21 @@ export function DiffView({ onDismiss={() => setBlameError(null)} /> )} + {selected && ( + + )} {selected && (
{showBlame && blame && blameLayout && diffEditor && ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx new file mode 100644 index 00000000..465b37a5 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileCommentStrip.tsx @@ -0,0 +1,146 @@ +import { useMemo, useState } from 'react'; +import type { + PlatformCapabilities, + PlatformUser, + PrComment, + StoredPullRequest, +} from '@meebox/shared'; +import { invoke } from '../../../../../api'; +import { ChatIcon } from '../../../../common'; +import { CommentComposer } from '../comments/CommentComposer'; +import { CommentItem } from '../comments/CommentItem'; + +/** + * File-level comments for the currently open diff file: comments anchored to the whole file (no line) — which the + * line-based inline zones can't host — plus a "comment on this file" entry. Rendered above the diff editor. + * + * Reuses {@link CommentItem} / {@link CommentComposer} so file-level comments have the **same** interactions (reactions, + * mention, reply, edit, delete) as every other comment surface (see the comment-interaction consistency rule). The + * compose entry is gated on the `fileLevelComments` capability (Bitbucket / GitHub; GitLab has none). + */ +export function FileCommentStrip({ + pr, + path, + oldPath, + comments, + capabilities, + hardBreaks, + reactionsMode, + mentionCandidates, + attachmentsEnabled = false, + userSearchEnabled = false, + readOnly = false, +}: { + pr: StoredPullRequest; + path: string; + oldPath?: string; + comments: PrComment[]; + capabilities?: PlatformCapabilities; + hardBreaks: boolean; + reactionsMode?: 'fixed' | 'free'; + mentionCandidates?: PlatformUser[]; + attachmentsEnabled?: boolean; + userSearchEnabled?: boolean; + readOnly?: boolean; +}) { + const [composing, setComposing] = useState(false); + const [copied, setCopied] = useState(false); + // The file's project-relative path as breadcrumb segments (last = the file name). + const segments = useMemo(() => path.split('/'), [path]); + // Whole-breadcrumb click copies the relative path (VS Code-style, no per-segment navigation), with a brief ✓. + const copyPath = (): void => { + void navigator.clipboard.writeText(path).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }, + () => { + /* clipboard denied — best-effort, ignore */ + }, + ); + }; + const fileComments = useMemo( + () => + comments.filter( + (c) => + c.anchor && + c.anchor.line == null && + (c.anchor.path === path || (oldPath != null && c.anchor.path === oldPath)), + ), + [comments, path, oldPath], + ); + const canComment = !readOnly && (capabilities?.fileLevelComments ?? false); + // Nothing to show and nothing to add → render nothing (keep the diff clean for files without file-level comments). + if (fileComments.length === 0 && !canComment) return null; + + return ( +
+
+ + {canComment && !composing && ( + + )} +
+ {(fileComments.length > 0 || composing) && ( +
+ {fileComments.length > 0 && ( +
    + {fileComments.map((c) => ( + + ))} +
+ )} + {composing && ( + + invoke('comments:createFile', { localId: pr.localId, path, body }) + } + onCancel={() => setComposing(false)} + onPosted={() => setComposing(false)} + /> + )} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx index 3ced7b84..bf3cfbad 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx @@ -63,6 +63,8 @@ export function useCommentZones(opts: { const fileComments = comments.filter( (c) => c.anchor && + // File-level comments (no line) are not line-anchored; they render in DiffView's file-level strip, not here. + c.anchor.line != null && (c.anchor.path === selected.path || (selected.oldPath && c.anchor.path === selected.oldPath)), ); @@ -70,10 +72,12 @@ export function useCommentZones(opts: { const oldByLine = new Map(); const newByLine = new Map(); for (const c of fileComments) { + // fileComments already excludes line-less (file-level) anchors, so line is present here. + const line = c.anchor!.line!; const target = c.anchor!.side === 'old' ? oldByLine : newByLine; - const arr = target.get(c.anchor!.line) ?? []; + const arr = target.get(line) ?? []; arr.push(c); - target.set(c.anchor!.line, arr); + target.set(line, arr); } const buildDecorations = ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx index 02253388..ca3f3ee6 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx @@ -38,8 +38,10 @@ export function useReactions( ): { reactions: PrReaction[]; busy: boolean; toggle: (emoji: string, add: boolean) => void } { const [busy, setBusy] = useState(false); const reactions = comment.reactions ?? []; - // GitHub picks the issue / review reaction endpoint by kind; other platforms ignore it. anchor is the fallback (old data has no kind). - const kind: 'summary' | 'inline' = comment.kind ?? (comment.anchor ? 'inline' : 'summary'); + // GitHub picks the issue / review reaction endpoint by kind; other platforms ignore it. anchor is the fallback (old + // data has no kind). File-level comments are review comments too → use the 'inline' (review) reaction endpoint. + const kind: 'summary' | 'inline' = + (comment.kind ?? (comment.anchor ? 'inline' : 'summary')) === 'summary' ? 'summary' : 'inline'; const toggle = useCallback( (emoji: string, add: boolean): void => { if (busy || readOnly) return; 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 80cefd4e..0a299ee9 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -246,6 +246,7 @@ "textareaAria": "Editor für Kommentarantworten" }, "commentsPanel": { + "anchorFileTitle": "Dateikommentar (ganze Datei)", "anchorJumpTitle": "Zur Stelle im Diff springen", "anchorTitle": "Verankert an {{side}}-Seite · {{lineType}}", "daysAgo_one": "vor {{count}} Tag", @@ -339,11 +340,9 @@ "binaryNotRendered": "⚠️ Binärdatei, Diff wird nicht dargestellt", "blameChangeRangeTitle": "Dieser Bereich wurde durch diesen PR geändert", "blameFailed": "blame fehlgeschlagen", - "blameFailedNamed": "blame für {{path}} fehlgeschlagen", - "diffRenderFailed": "Diff-Darstellung fehlgeschlagen: {{message}}", + "blameFailedNamed": "blame für {{path}} fehlgeschlagen", "diffRenderFailed": "Diff-Darstellung fehlgeschlagen: {{message}}", "diffRenderFailedHint": "Ein Dateiwechsel oder erneuter Versuch behebt das Problem meist. Der zugrunde liegende Fehler wurde in der console protokolliert.", - "dismissNotificationTitle": "Diese Benachrichtigung schließen", - "fileCount_one": "{{count}} Datei", + "dismissNotificationTitle": "Diese Benachrichtigung schließen", "fileCount_one": "{{count}} Datei", "fileCount_other": "{{count}} Dateien", "lfsManagedTitle": "Von Git LFS verwaltet", "loadChangedFilesFailed": "Geänderte Dateien konnten nicht geladen werden", 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 8404f98e..7f9498cf 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -246,6 +246,7 @@ "textareaAria": "Comment reply editor" }, "commentsPanel": { + "anchorFileTitle": "File-level comment (whole file)", "anchorJumpTitle": "Jump to this line in Diff", "anchorTitle": "Anchored to {{side}} side · {{lineType}}", "daysAgo_one": "{{count}} day ago", @@ -339,11 +340,9 @@ "binaryNotRendered": "⚠️ Binary file, diff not rendered", "blameChangeRangeTitle": "This range was changed by this PR", "blameFailed": "Blame failed", - "blameFailedNamed": "Blame failed for {{path}}", - "diffRenderFailed": "Diff render failed: {{message}}", + "blameFailedNamed": "Blame failed for {{path}}", "diffRenderFailed": "Diff render failed: {{message}}", "diffRenderFailedHint": "Switching files or retrying usually recovers. The underlying error has been logged to the console.", - "dismissNotificationTitle": "Dismiss this notification", - "fileCount_one": "{{count}} file", + "dismissNotificationTitle": "Dismiss this notification", "fileCount_one": "{{count}} file", "fileCount_other": "{{count}} files", "lfsManagedTitle": "Managed by Git LFS", "loadChangedFilesFailed": "Failed to load changed files", 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 783b3c52..9bf1cfe5 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -246,6 +246,7 @@ "textareaAria": "コメント返信エディタ" }, "commentsPanel": { + "anchorFileTitle": "ファイルレベルのコメント(ファイル全体)", "anchorJumpTitle": "Diff の該当位置へ移動", "anchorTitle": "{{side}} 側にアンカー · {{lineType}}", "daysAgo_other": "{{count}} 日前", @@ -332,12 +333,10 @@ "binaryNotRendered": "⚠️ バイナリファイル、差分は表示されません", "blameChangeRangeTitle": "この範囲はこの PR で変更されました", "blameFailed": "blame に失敗しました", - "blameFailedNamed": "{{path}} の blame に失敗しました", - "diffRenderFailed": "差分の表示に失敗しました: {{message}}", + "blameFailedNamed": "{{path}} の blame に失敗しました", "diffRenderFailed": "差分の表示に失敗しました: {{message}}", "diffRenderFailedHint": "ファイルを切り替えるか再試行すると通常は復旧します。元のエラーは console に記録されています。", "dismissNotificationTitle": "この通知を閉じる", - "fileCount_other": "{{count}} ファイル", - "lfsManagedTitle": "Git LFS で管理", + "fileCount_other": "{{count}} ファイル", "lfsManagedTitle": "Git LFS で管理", "loadChangedFilesFailed": "変更ファイルの読み込みに失敗しました", "loadCommentsFailed": "コメントの読み込みに失敗しました", "loadingContentHint": "ローカルミラーから git blob を読み込んでいます。大きいファイルやバイナリファイルは時間がかかる場合があります", 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 ca3d5247..c6d00b0d 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -246,6 +246,7 @@ "textareaAria": "评论回复编辑器" }, "commentsPanel": { + "anchorFileTitle": "文件级评论(整个文件)", "anchorJumpTitle": "跳转到 Diff 对应位置", "anchorTitle": "锚定 {{side}} 侧 · {{lineType}}", "daysAgo_other": "{{count}} 天前", @@ -332,12 +333,10 @@ "binaryNotRendered": "⚠️ 二进制文件,不渲染 diff", "blameChangeRangeTitle": "此区段为本 PR 引入的改动", "blameFailed": "blame 失败", - "blameFailedNamed": "{{path}} blame 失败", - "diffRenderFailed": "diff 渲染失败:{{message}}", + "blameFailedNamed": "{{path}} blame 失败", "diffRenderFailed": "diff 渲染失败:{{message}}", "diffRenderFailedHint": "切换文件 / 重试通常能恢复。底层异常已记录到 console。", "dismissNotificationTitle": "收起此通知", - "fileCount_other": "{{count}} 个文件", - "lfsManagedTitle": "由 Git LFS 管理", + "fileCount_other": "{{count}} 个文件", "lfsManagedTitle": "由 Git LFS 管理", "loadChangedFilesFailed": "拉取变更文件列表失败", "loadCommentsFailed": "拉取评论失败", "loadingContentHint": "从本地镜像读 git blob,大文件 / 二进制判定时可能略慢", diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index 6bcc6963..dcaebbd6 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -67,6 +67,92 @@ flex-direction: column; } +// File-level comment strip: sits above the diff editor inside .diff-content (a flex column). Natural height, capped so +// many comments never crowd the editor out; the editor (.diff-pane-wrapper, flex:1) keeps the remaining space. +.diff-file-comments { + flex: 0 0 auto; + display: flex; + flex-direction: column; + max-height: 40%; + overflow: hidden; +} +// Header bar: mirrors .diff-file-list-header (same padding + divider) so its bottom border lines up with the file tree's. +.diff-file-comments-head { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-4; + padding: $space-3 $space-6; + font-size: $fs-sm; + color: $text-muted; + border-bottom: 1px solid $border-muted; +} +// Comments / composer scroll below the header bar within the capped strip. +.diff-file-comments-body { + overflow-y: auto; + padding: $space-2 $space-3; +} +// Project-relative path breadcrumb of the open file (VS Code-style, chevron-separated). The whole thing is one button: +// clicking copies the relative path (no per-segment navigation / expansion). Single line, truncates on overflow. +.diff-file-crumbs { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; + // Tighten so the text line never exceeds the 22px content height that keeps the header bar level with the file tree's. + line-height: 1; + // Button reset. + padding: 0; + background: transparent; + border: none; + color: inherit; + font: inherit; + cursor: pointer; + + &:hover { + color: $text-body; + } +} +.diff-file-crumb { + display: inline-flex; + align-items: center; +} +.diff-file-crumb-sep { + margin: 0 3px; + opacity: 0.6; +} +.diff-file-crumb-name { + font-weight: 600; +} +.diff-file-crumb-copied { + margin-left: 6px; + font-weight: 600; + color: $color-success; +} +// Icon-only "comment on file" button (pushed right by the header's space-between). Fixed 22px box so it matches the +// file tree header's content height exactly (row = 12px padding + 1px border + 22px = 35px, level with the file tree). +.diff-file-comment-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + color: $text-muted; + background: transparent; + border: none; + border-radius: $radius-sm; + cursor: pointer; + + &:hover { + background: $bg-hover; + color: inherit; + } +} + // Main area wrapper: the left blame-column is fixed width; ErrorBoundary or DiffPane take flex:1 .diff-pane-wrapper { flex: 1; diff --git a/docs/arch/01-platform/01-adapter.md b/docs/arch/01-platform/01-adapter.md index c4f955e8..3b78b80d 100644 --- a/docs/arch/01-platform/01-adapter.md +++ b/docs/arch/01-platform/01-adapter.md @@ -138,7 +138,7 @@ Neutral-type highlights: Capabilities that cannot be implemented equivalently on all platforms are declared explicitly via the **`PlatformCapabilities`** returned by `capabilities()`; the UI shows / hides / greys accordingly, and the business layer switches strategy accordingly — **never `try/catch` to guess at the call site, and never write `if (platform === ...)`**. -`PlatformCapabilities` fields: `reviewStatuses` (supported review decisions), `inlineComments`, `inlineMultiline`, `commentOptimisticLock`, `commentHardBreaks` (whether a single `\n` renders as a hard break), `mergeVetoFidelity` ('full' | 'partial'), `discoveryRateLimited`, `discoveryFilters` (PR discovery categories), `resolvableThreads`, `suggestions`, `reviewGrouping`, `activityTimeline` (whether a review-decision activity event stream is provided), `commentCountIncludesReplies` (whether `PullRequest.commentCount` includes replies — determines the poller's comment-tracking trigger strategy, see [Notifications](../03-gui/03-notifications.md); true for GitHub/GitLab, false for Bitbucket), `userSearch` (whether the platform exposes a user-search endpoint for `@mention` autocomplete — repo-scoped where a normal reviewer can call it, else instance-wide; true on all three, see §3). +`PlatformCapabilities` fields: `reviewStatuses` (supported review decisions), `inlineComments`, `fileLevelComments` (whether a comment can anchor to a whole file, not a line — Bitbucket / GitHub yes, GitLab no; see §3), `inlineMultiline`, `commentOptimisticLock`, `commentHardBreaks` (whether a single `\n` renders as a hard break), `mergeVetoFidelity` ('full' | 'partial'), `discoveryRateLimited`, `discoveryFilters` (PR discovery categories), `resolvableThreads`, `suggestions`, `reviewGrouping`, `activityTimeline` (whether a review-decision activity event stream is provided), `commentCountIncludesReplies` (whether `PullRequest.commentCount` includes replies — determines the poller's comment-tracking trigger strategy, see [Notifications](../03-gui/03-notifications.md); true for GitHub/GitLab, false for Bitbucket), `userSearch` (whether the platform exposes a user-search endpoint for `@mention` autocomplete — repo-scoped where a normal reviewer can call it, else instance-wide; true on all three, see §3). **Merge-veto reasons go through neutral codes**: `MergeVeto` does not assemble user-facing localized text in the backend. GitHub / GitLab normalize their derived reasons to the stable codes `MergeVetoCode` in `@meebox/platform-core` (`conflict` / `branchProtected` / `behind` / `checksFailed` / `checking` / `draft` / `discussionsUnresolved` / `notApproved` / `notOpen` / `blockedByDependency` / `notMergeable`), and the frontend does i18n by code (`mergeVeto.`); Bitbucket passes the server text through directly (`summary`, no code). Likewise, backend user-facing errors such as unsupported version from the connection probe are carried by error codes (see [Error codes](../99-core/04-error-codes.md)), not assembled as Chinese text in the backend. @@ -151,6 +151,7 @@ Per-platform capability overview: | mergeVetoFidelity | full (/merge vetoes) | partial (assembled from mergeable_state) | full (detailed_merge_status) | | discoveryRateLimited | no | yes (search 30/min) | no | | resolvableThreads / suggestions | no / no | conceptually present, not yet implemented | conceptually present, not yet implemented | +| fileLevelComments | yes (anchor without a line) | yes (`subject_type: "file"`) | no (no file-level diff-comment API) | ### The three degradation states & the decision criteria diff --git a/docs/arch/01-platform/04-comment-interactions.md b/docs/arch/01-platform/04-comment-interactions.md index cae6bd54..89ee0131 100644 --- a/docs/arch/01-platform/04-comment-interactions.md +++ b/docs/arch/01-platform/04-comment-interactions.md @@ -20,6 +20,7 @@ Each of the three is declared by a capability flag (`commentReactions` / `commen | Attachments `commentAttachments` | ✗ (no public upload API) | ✓ | ✓ | | @mention local completion | ✓ (no flag; always on) | ✓ | ✓ | | @mention remote search `userSearch` | ✓ `collaborators` → `/search/users` on 401/403 | ✓ `/users?permission=LICENSED_USER&filter=` (native picker endpoint) | ✓ `/projects/:id/users` (any member) | +| File-level comments `fileLevelComments` | ✓ `subject_type: "file"` | ✓ anchor without a line | ✗ (no file-level diff-comment API) | ### emoji reactions: unify the emoji character as a neutral key @@ -71,6 +72,14 @@ Both layers are a **pure convenience**: the user can still freely type any `@nam **Consistency across surfaces (design philosophy)**: every comment-interaction behavior — reactions, `@mention` (local + remote), attachments, reply / edit / delete — must be **identical on all comment surfaces**: the comments/activity page (`CommentItem`), the inline diff comment zone (`InlineCommentZone`), and the inline draft editor (`DraftZone`). This is enforced by **sharing the leaf components / hooks** (`CommentReplyEditor`, `MentionTextarea`, `useReactions`, `useCommentThread`) rather than reimplementing per surface, so a surface can only differ in layout, never in interaction behavior. When adding or changing an interaction, wire it into **all** surfaces (thread the same props down each path) — a capability reaching only one surface is a bug, not a scope choice. +### File-level comments: whole-file anchor + capability degradation + +A comment can anchor to a **whole file** (not a specific line) where the platform supports it (`fileLevelComments`). This is modeled by a `PrCommentAnchor` **without a `line`** (path + side only): `anchor == null` → PR summary; `anchor` with a line → inline; `anchor` without a line → file-level. The comment `kind` (`'summary' | 'inline' | 'file'`) mirrors this. + +- **Read (all platforms)**: previously a line-less remote anchor was collapsed to a summary, losing its file association (notably Bitbucket, whose web UI creates file comments). Now each adapter maps it to a file-level anchor: Bitbucket (anchor without `line`), GitHub (`subject_type: "file"`). GitLab has no file-level diff-comment concept, so its notes stay summary. File-level comments render in a **strip above the diff editor** for that file (line-based inline zones can't host a line-less anchor), and in the comments/activity list they show a path-only chip (non-clickable — there's no line to jump to). +- **Write (Bitbucket / GitHub)**: a "comment on file" entry in the diff file-comment strip posts via `comments:createFile` → the adapter's inline-publish path with a line-less anchor (Bitbucket sends only path + fileType; GitHub sends `subject_type: "file"`). GitLab's `fileLevelComments` is `false`, so the entry is hidden; the adapter also guards `publishInlineComment` against a line-less anchor defensively. +- **Interaction parity**: the file-comment strip reuses `CommentItem` / `CommentComposer`, so reactions / mention / reply / edit / delete behave identically to every other comment surface (see the consistency rule below). File-level comments are review comments, so reactions use the inline (review) endpoint. + ### Image attachments: platform-native upload + reuse of existing rendering Paste an image → the render layer intercepts → hand the bytes to the adapter via IPC to upload → backfill the platform-returned markdown into the body. Per platform: diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 5c64cc53..4ceebad4 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -40,6 +40,15 @@ export interface PrChannels { request: { localId: string; body: string }; response: PrComment; }; + /** + * Post a file-level comment — anchored to a whole file rather than a specific line (Bitbucket / GitHub). Exposed only + * where the `fileLevelComments` capability is true; `side` defaults to 'new' (the head-side file). On success the main + * side clears the comment cache + broadcasts comments:changed, and the activity / comments panel refetches. + */ + 'comments:createFile': { + request: { localId: string; path: string; side?: 'old' | 'new'; body: string }; + response: PrComment; + }; /** * Delete a remote comment you authored. Bitbucket requires a version (optimistic lock), which the caller takes from an existing PrComment; * mismatch / comment already has replies / not being the author all fail (Bitbucket 409/403). On success the main diff --git a/packages/platform-bitbucket-server/src/features/comment.ts b/packages/platform-bitbucket-server/src/features/comment.ts index 70f3058e..853da15d 100644 --- a/packages/platform-bitbucket-server/src/features/comment.ts +++ b/packages/platform-bitbucket-server/src/features/comment.ts @@ -182,13 +182,15 @@ export class BitbucketCommentService extends BaseCommentService { * Passes through the Bitbucket optimistic-lock version (the caller must carry it back on edit/delete, otherwise 409); an empty anchor means a summary comment. */ private mapBitbucketComment(c: BitbucketComment, anchor?: BitbucketCommentAnchor): PrComment { + const mappedAnchor = anchor ? this.mapBitbucketAnchor(anchor) : null; return { remoteId: String(c.id), author: mapUser(c.author), body: c.text, createdAt: new Date(c.createdDate).toISOString(), updatedAt: new Date(c.updatedDate).toISOString(), - anchor: anchor ? this.mapBitbucketAnchor(anchor) : null, + anchor: mappedAnchor, + kind: mappedAnchor == null ? 'summary' : mappedAnchor.line == null ? 'file' : 'inline', replies: (c.comments ?? []).map((r) => this.mapBitbucketComment(r)), reactions: this.mapReactions(c.properties?.reactions), version: c.version, @@ -221,32 +223,40 @@ export class BitbucketCommentService extends BaseCommentService { /** * Bitbucket comment anchor → neutral anchor. * - * No line number = file-level / orphan anchor, cannot anchor to a specific line → return null (degrade to - * summary); when lineType is occasionally absent, fall back to 'context' (the most conservative value, - * consistent with the publish-anchor fallback). + * No line number = a file-level comment (attached to the whole file) or an orphaned anchor (the anchored line no + * longer exists) → keep it as a **file-level** anchor (path + side, no line) so the UI can still associate it with its + * file, rather than degrading to a summary. When lineType is occasionally absent on a line anchor, fall back to + * 'context' (the most conservative value, consistent with the publish-anchor fallback). */ - private mapBitbucketAnchor(a: BitbucketCommentAnchor): PrCommentAnchor | null { - if (a.line == null) return null; + private mapBitbucketAnchor(a: BitbucketCommentAnchor): PrCommentAnchor { + const side: PrCommentAnchor['side'] = a.fileType === 'FROM' ? 'old' : 'new'; + if (a.line == null) return { path: a.path, side }; return { path: a.path, line: a.line, - side: a.fileType === 'FROM' ? 'old' : 'new', - lineType: (a.lineType?.toLowerCase() ?? 'context') as PrCommentAnchor['lineType'], + side, + lineType: (a.lineType?.toLowerCase() ?? 'context') as NonNullable, }; } /** - * Neutral anchor → Bitbucket REST anchor fields (for publishing inline comments, the reverse of mapBitbucketAnchor). + * Neutral anchor → Bitbucket REST anchor fields (for publishing comments, the reverse of mapBitbucketAnchor). * - * diffType is explicitly set to 'EFFECTIVE', anchoring the comment to the "currently effective diff" rather than a specific commit, so it still follows across subsequent PR pushes. + * A file-level anchor (no line) sends only path + fileType (Bitbucket attaches the comment to the file). diffType is + * explicitly set to 'EFFECTIVE', anchoring the comment to the "currently effective diff" rather than a specific + * commit, so it still follows across subsequent PR pushes. */ private toBBAnchor(a: PrCommentAnchor): BitbucketCommentAnchor { + const fileType: BitbucketCommentAnchor['fileType'] = a.side === 'old' ? 'FROM' : 'TO'; + if (a.line == null) { + return { diffType: 'EFFECTIVE', path: a.path, fileType }; + } return { diffType: 'EFFECTIVE', path: a.path, line: a.line, - lineType: a.lineType.toUpperCase() as BitbucketCommentAnchor['lineType'], - fileType: a.side === 'old' ? 'FROM' : 'TO', + lineType: (a.lineType?.toUpperCase() ?? 'CONTEXT') as BitbucketCommentAnchor['lineType'], + fileType, }; } } diff --git a/packages/platform-bitbucket-server/src/features/connection.ts b/packages/platform-bitbucket-server/src/features/connection.ts index dcfcaa31..8013461f 100644 --- a/packages/platform-bitbucket-server/src/features/connection.ts +++ b/packages/platform-bitbucket-server/src/features/connection.ts @@ -36,6 +36,8 @@ export class BitbucketServerConnection extends BaseConnection { return { reviewStatuses: ['approved', 'needsWork', 'unapproved'], inlineComments: true, + // Bitbucket supports commenting on a whole file (anchor without a line). + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: true, // Comment emoji reactions since 7.x (minimum supported version is 7.0); emoticon supports any emoji → free. diff --git a/packages/platform-bitbucket-server/tests/adapter.test.ts b/packages/platform-bitbucket-server/tests/adapter.test.ts index 411f59c8..8975531c 100644 --- a/packages/platform-bitbucket-server/tests/adapter.test.ts +++ b/packages/platform-bitbucket-server/tests/adapter.test.ts @@ -517,7 +517,7 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor mapping', () => expect(cs[0]!.anchor).toEqual({ path: 'src/a.ts', line: 42, side: 'new', lineType: 'added' }); }); - it('binary / file-level comment anchor with no line/lineType → degrades to anchor=null (no crash)', async () => { + it('file-level comment anchor (no line/lineType) → maps to a file-level anchor (path + side, no line)', async () => { const adapter = makeAdapter( mockFetch( activities([ @@ -536,7 +536,9 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor mapping', () => '1022', ); expect(cs).toHaveLength(1); - expect(cs[0]!.anchor).toBeNull(); + // No line = a file-level comment: keep it anchored to the file (path + side), not degraded to a summary. + expect(cs[0]!.anchor).toEqual({ path: 'assets/logo.png', side: 'new' }); + expect(cs[0]!.kind).toBe('file'); }); it('has line but missing lineType → lineType falls back to context', async () => { diff --git a/packages/platform-github/src/features/comment.ts b/packages/platform-github/src/features/comment.ts index e6d0721a..623a768d 100644 --- a/packages/platform-github/src/features/comment.ts +++ b/packages/platform-github/src/features/comment.ts @@ -165,7 +165,8 @@ export class GitHubCommentService extends BaseCommentService { } /** - * Publish an inline comment: first fetch the PR to get head sha as commit_id, then create a review comment by the anchor (path / line / side). + * Publish a comment anchored to the diff: to a specific line (path / line / side) or, when the anchor has no line, to + * the whole file via `subject_type: "file"` (a file-level comment). First fetches the PR to get head sha as commit_id. */ async publishInlineComment( repo: RepoRef, @@ -176,13 +177,20 @@ export class GitHubCommentService extends BaseCommentService { const prefix = `/repos/${repo.projectKey}/${repo.repoSlug}`; // Inline comments need commit_id = head sha; per the Phase 0 decision, the adapter internally fetches the PR to get head sha const pull = await this.client.get(`${prefix}/pulls/${prId}`); - const created = await this.client.post(`${prefix}/pulls/${prId}/comments`, { - body, - commit_id: pull.head.sha, - path: anchor.path, - line: anchor.line, - side: anchor.side === 'old' ? 'LEFT' : 'RIGHT', - }); + const req = + anchor.line == null + ? { body, commit_id: pull.head.sha, path: anchor.path, subject_type: 'file' as const } + : { + body, + commit_id: pull.head.sha, + path: anchor.path, + line: anchor.line, + side: anchor.side === 'old' ? 'LEFT' : 'RIGHT', + }; + const created = await this.client.post( + `${prefix}/pulls/${prId}/comments`, + req, + ); return this.mapReviewComment(created); } @@ -302,16 +310,20 @@ export class GitHubCommentService extends BaseCommentService { */ private mapReviewComment(c: GhReviewComment, mine?: Set): PrComment { const line = c.line ?? c.original_line ?? null; + const side: PrCommentAnchor['side'] = c.side === 'LEFT' ? 'old' : 'new'; const anchor: PrCommentAnchor | null = line != null ? { path: c.path, line, - side: c.side === 'LEFT' ? 'old' : 'new', + side, // GitHub does not directly give added/removed/context; take a conservative default by side (display-only) - lineType: c.side === 'LEFT' ? 'removed' : 'added', + lineType: side === 'old' ? 'removed' : 'added', } - : null; + : c.subject_type === 'file' + ? // file-level review comment (no line): keep it anchored to the file, don't degrade to summary + { path: c.path, side } + : null; return { remoteId: String(c.id), author: mapUser(c.user), @@ -320,7 +332,7 @@ export class GitHubCommentService extends BaseCommentService { updatedAt: c.updated_at, anchor, replies: [], - kind: 'inline', + kind: anchor != null && anchor.line == null ? 'file' : 'inline', threadId: String(c.id), nativeId: String(c.id), reactions: this.buildReactions(c.reactions, mine), diff --git a/packages/platform-github/src/features/connection.ts b/packages/platform-github/src/features/connection.ts index d00e22f1..6c96b225 100644 --- a/packages/platform-github/src/features/connection.ts +++ b/packages/platform-github/src/features/connection.ts @@ -23,6 +23,8 @@ export class GitHubConnection extends BaseConnection { return { reviewStatuses: ['approved', 'needsWork', 'unapproved'], inlineComments: true, + // GitHub supports file-level review comments (subject_type: "file"). + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: false, // GitHub Reactions API has only a fixed 8 kinds → fixed. diff --git a/packages/platform-github/src/types.ts b/packages/platform-github/src/types.ts index 810e8029..bc6ce74c 100644 --- a/packages/platform-github/src/types.ts +++ b/packages/platform-github/src/types.ts @@ -87,6 +87,8 @@ export interface GhReviewComment { original_line?: number | null; side?: 'LEFT' | 'RIGHT'; start_line?: number | null; + /** 'file' = a file-level review comment (no line); 'line' (default) = anchored to a line. */ + subject_type?: 'line' | 'file'; in_reply_to_id?: number; html_url?: string; reactions?: GhReactionRollup; diff --git a/packages/platform-gitlab/src/features/comment.ts b/packages/platform-gitlab/src/features/comment.ts index cdf4b602..0af5ec62 100644 --- a/packages/platform-gitlab/src/features/comment.ts +++ b/packages/platform-gitlab/src/features/comment.ts @@ -138,6 +138,11 @@ export class GitLabCommentService extends BaseCommentService { anchor: PrCommentAnchor, body: string, ): Promise { + // GitLab has no file-level diff-comment API (position_type is only text/image), so file-level anchors are + // unsupported — the fileLevelComments capability is false, so the UI never offers it; guard defensively here. + if (anchor.line == null) { + throw new Error('GitLab does not support file-level diff comments'); + } const base = `/projects/${projectId(repo)}/merge_requests/${prId}`; // inline comment = discussion with a position; position needs the three base/start/head shas → first fetch the MR to get diff_refs. const mr = await this.client.get(base); diff --git a/packages/platform-gitlab/src/features/connection.ts b/packages/platform-gitlab/src/features/connection.ts index 419c3fcd..abb4ad2e 100644 --- a/packages/platform-gitlab/src/features/connection.ts +++ b/packages/platform-gitlab/src/features/connection.ts @@ -33,6 +33,8 @@ export class GitLabConnection extends BaseConnection { return { reviewStatuses, inlineComments: true, + // GitLab has no file-level diff-comment API (position_type is text/image only) → unsupported. + fileLevelComments: false, inlineMultiline: false, commentOptimisticLock: false, // GitLab Award Emoji supports arbitrary emoji → free. diff --git a/packages/poller/tests/poller.test.ts b/packages/poller/tests/poller.test.ts index 7d3d018f..0856aa05 100644 --- a/packages/poller/tests/poller.test.ts +++ b/packages/poller/tests/poller.test.ts @@ -80,6 +80,7 @@ class FakeAdapter implements PlatformAdapter { capabilities: () => ({ reviewStatuses: ['approved', 'needsWork', 'unapproved'] as const, inlineComments: true, + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: true, commentReactions: 'free' as const, @@ -303,6 +304,7 @@ describe('Poller.tick', () => { capabilities: () => ({ reviewStatuses: ['approved', 'needsWork', 'unapproved'], inlineComments: true, + fileLevelComments: true, inlineMultiline: true, commentOptimisticLock: true, commentReactions: 'free', diff --git a/packages/shared/src/platform.ts b/packages/shared/src/platform.ts index b91c6674..20bd6940 100644 --- a/packages/shared/src/platform.ts +++ b/packages/shared/src/platform.ts @@ -183,12 +183,15 @@ export interface PingResult { export interface PrCommentAnchor { /** Current path (for a renamed file, the dst side) */ path: string; - /** Anchor line number */ - line: number; + /** + * Anchor line number. **Absent = a file-level comment** — anchored to the whole file rather than a specific line + * (Bitbucket / GitHub support this; see {@link PlatformCapabilities.fileLevelComments}). + */ + line?: number; /** 'old' = anchor to base / FROM; 'new' = anchor to head / TO */ side: 'old' | 'new'; - /** The diff role of the anchored line */ - lineType: 'added' | 'removed' | 'context'; + /** The diff role of the anchored line; absent for a file-level comment (no line). */ + lineType?: 'added' | 'removed' | 'context'; } /** @@ -510,7 +513,10 @@ export interface PrComment { createdAt: string; /** ISO */ updatedAt: string; - /** null = PR top-level summary comment; set = inline comment anchored to a specific file line */ + /** + * null = PR top-level summary comment; set = anchored to a file — to a specific line (`anchor.line` present, an inline + * comment) or to the whole file (`anchor.line` absent, a file-level comment). + */ anchor: PrCommentAnchor | null; /** Nested replies (Bitbucket uses comment.comments[]) */ replies: PrComment[]; @@ -538,11 +544,12 @@ export interface PrComment { */ canEdit?: boolean; /** - * Comment kind (multi-platform abstraction): 'summary' = PR-level discussion; 'inline' = anchored to a file line. - * Currently whether anchor is null already distinguishes them; this field is the explicit label during normalization for GitHub (issue/review comments split across two APIs) / - * GitLab (note/discussion), convenient for UI and write-back. Optional, not filled for old data. + * Comment kind (multi-platform abstraction): 'summary' = PR-level discussion; 'inline' = anchored to a file line; + * 'file' = anchored to a whole file (no line). Redundant with anchor (null → summary; anchor with line → inline; + * anchor without line → file), but an explicit label during normalization for GitHub (issue/review comments split + * across two APIs) / GitLab (note/discussion), convenient for UI and write-back. Optional, not filled for old data. */ - kind?: 'summary' | 'inline'; + kind?: 'summary' | 'inline' | 'file'; /** * Thread identifier (abstraction of the reply target). Bitbucket=parent comment id, GitHub=review-comment id, * GitLab=discussion id. Passed through to the adapter on reply; Bitbucket currently just uses remoteId. @@ -608,6 +615,12 @@ export interface PlatformCapabilities { reviewStatuses: ReadonlyArray; /** Whether inline comments are supported */ inlineComments: boolean; + /** + * Whether a comment can be anchored to a **whole file** (not a specific line). Bitbucket (anchor without a line) and + * GitHub (`subject_type: "file"`) support it → true; GitLab has no file-level diff-comment API → false (the UI hides + * the "comment on file" entry, and any remote file-level comment degrades to a summary). See {@link PrCommentAnchor}. + */ + fileLevelComments: boolean; /** Whether multi-line inline comments are supported */ inlineMultiline: boolean; /** Whether comment edit/delete requires a version optimistic lock (Bitbucket only) */ From 3d4d8345b30eefc76a065bb1dc7db912291276c1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 11:56:46 +0800 Subject: [PATCH 3/4] docs(changelog): add Git LFS status + file-level comments under Unreleased Recreate the [Unreleased] section (consumed by the 0.11.0 release) with Added entries for the two features on this branch, in both locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ CHANGELOG.zh-CN.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ad06fe..a36082a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are recorded here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versioning follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### ✨ Added + +- Binary files in the diff (images, office documents, PDFs, …) now show their Git LFS status — a "Git LFS · <size>" tag for LFS-managed files, or a "⚠ Not LFS" tag for files stored inline in git. +- Comments can now be anchored to a whole file (not only a single line) where the platform supports it (Bitbucket / GitHub): a "comment on file" entry in the diff, and existing file-level comments now display correctly instead of being shown as generic PR comments. + ## [0.11.0] - 2026-07-07 > Highlights of this release: @@ -465,6 +472,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/). License: [Apache-2.0](LICENSE). The package bundles third-party components (pr-agent, Electron, etc.), each distributed under its own license, see [NOTICE](NOTICE). +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...HEAD [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 13ae6ca1..40995a5e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,6 +5,13 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### ✨ 新增 + +- diff 中的二进制文件(图片、Office 文档、PDF 等)现会显示其 Git LFS 状态——受 LFS 管理的文件显示「Git LFS · <大小>」标记,直接内联存入 git 的文件显示「⚠ 非 LFS」标记。 +- 现可对整个文件(而不仅是某一行)添加评论(平台支持时,Bitbucket / GitHub):diff 中新增「对文件评论」入口,且已存在的文件级评论现能正确归属显示,不再被当作 PR 通用评论。 + ## [0.11.0] - 2026-07-07 > 本次发布要点: @@ -465,6 +472,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...HEAD [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 From 5c6d268d93ca4977b5af1028fa6629930ae2f120 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 8 Jul 2026 12:58:23 +0800 Subject: [PATCH 4/4] style(diff): match file breadcrumb font size to the file tree Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/renderer/src/styles/features/diff/view.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index dcaebbd6..8358d071 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -101,6 +101,8 @@ min-width: 0; overflow: hidden; white-space: nowrap; + // Match the file tree's node font size (.tree-row) rather than the header bar's, since it reads as a path. + font-size: $fs-md; // Tighten so the text line never exceeds the 22px content height that keeps the header bar level with the file tree's. line-height: 1; // Button reset.