From 98f98cc61338c2e3220086f33f4f95dd01f7bab4 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 21:50:54 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(state-store):=20tmp=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E5=8A=A0=E8=BF=9B=E7=A8=8B=E5=86=85=E5=BA=8F?= =?UTF-8?q?=E5=8F=B7=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=90=8C=20key=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=86=99=20ENOENT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JsonFileStateStore.write 的 tmp 文件名原仅带 process.pid,同进程内对同一 key 的两次并发写共用同一 tmp 路径:先完成者 rename 走文件后,后完成者 rename 即 ENOENT。diff-base.json 恰会被前台 listChangedFiles 触发的 resolveDiffBaseSha 与 poller 后台刷新并发固化,表现为「拉取变更文件列表 失败」、重试可恢复的瞬时报错。 tmp 文件名追加进程内自增计数器(...tmp),各并发写各用 唯一 tmp。补一条 20 路并发写回归测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/state-store/src/json-file-state-store.ts | 6 +++++- packages/state-store/tests/json-file-state-store.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/state-store/src/json-file-state-store.ts b/packages/state-store/src/json-file-state-store.ts index d54f340..481f7f4 100644 --- a/packages/state-store/src/json-file-state-store.ts +++ b/packages/state-store/src/json-file-state-store.ts @@ -10,6 +10,8 @@ import type { StateStore } from './types.js'; */ export class JsonFileStateStore implements StateStore { private readonly rootResolved: string; + /** tmp 文件名去重计数器:避免同进程内对同一 key 的并发写撞用同一 tmp 路径 */ + private tmpSeq = 0; constructor(private readonly stateDir: string) { this.rootResolved = path.resolve(stateDir); @@ -31,7 +33,9 @@ export class JsonFileStateStore implements StateStore { const filePath = this.keyToPath(key); await fs.mkdir(path.dirname(filePath), { recursive: true }); - const tmp = `${filePath}.${String(process.pid)}.tmp`; + // pid 隔离多进程、tmpSeq 隔离同进程内对同一 key 的并发写——否则两次并发写 + // 共用同一 tmp,先完成者 rename 走文件后,后完成者 rename 即 ENOENT。 + const tmp = `${filePath}.${String(process.pid)}.${String(this.tmpSeq++)}.tmp`; const handle = await fs.open(tmp, 'w'); try { await handle.writeFile(JSON.stringify(data, null, 2) + '\n', 'utf8'); diff --git a/packages/state-store/tests/json-file-state-store.test.ts b/packages/state-store/tests/json-file-state-store.test.ts index be5d772..7a54bd1 100644 --- a/packages/state-store/tests/json-file-state-store.test.ts +++ b/packages/state-store/tests/json-file-state-store.test.ts @@ -40,6 +40,15 @@ describe('JsonFileStateStore', () => { expect(entries).toEqual(['atomic.json']); }); + it('handles concurrent writes to the same key without ENOENT', async () => { + // 回归:tmp 文件名仅带 pid 时,同 key 的并发写共用同一 tmp,先完成者 rename 走文件后 + // 后完成者 rename 即 ENOENT。各并发写须各用唯一 tmp。 + await Promise.all(Array.from({ length: 20 }, (_, i) => store.write('hot', { v: i }))); + const entries = await fs.readdir(tmpDir); + expect(entries).toEqual(['hot.json']); // 无遗留 .tmp + expect(await store.read<{ v: number }>('hot')).toMatchObject({ v: expect.any(Number) }); + }); + it('overwrites an existing key', async () => { await store.write('x', { v: 1 }); await store.write('x', { v: 2 }); From 04e94707b21b9ffb0e4939f820f09efc4c1147f6 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 21:56:05 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(pr):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=B1=85=E4=B8=AD=E9=99=90=E5=AE=BD=20+=20re?= =?UTF-8?q?viewer=20=E5=88=97=E8=A1=A8=E5=AD=97=E5=85=B8=E5=BA=8F=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详情 tab 原把 max-width 加在滚动容器 .pr-info-view 上,滚动条因此贴在 960px 内容右缘、停在布局中间。改为滚动容器铺满(滚动条回到右缘),内容 包进 .pr-info-content 限宽 960px 并居中。 reviewers 顺序由各平台 adapter 决定且不稳定(GitHub 按 Map 插入序,随评审 推进 requested_reviewers 被移除/补到末尾),每次 poll 列表抖动。展示层按 displayName 字典序固定排序(name 兜底),与平台无关。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/PrInfoView.tsx | 99 +++++++++++-------- .../renderer/src/styles/features/pr-info.scss | 6 ++ 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx index f847987..3cbcb30 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx @@ -23,54 +23,67 @@ export function PrInfoView({ pr }: PrInfoViewProps) { [pr.localId, pr.url], ); + // 各平台 adapter 产出的 reviewers 顺序不稳定(GitHub 按 Map 插入序,随评审推进 + // requested_reviewers 会被移除/补到末尾),每次 poll 列表抖动。展示层按 displayName + // 字典序固定排序,name 兜底兜稳,与平台无关。 + const reviewers = useMemo( + () => + [...pr.reviewers].sort( + (a, b) => a.displayName.localeCompare(b.displayName) || a.name.localeCompare(b.name), + ), + [pr.reviewers], + ); + return (
- {pr.description && ( +
+ {pr.description && ( +
+

描述

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

描述

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

Reviewers ({reviewers.length})

+ {reviewers.length === 0 ? ( +

+ ) : ( +
    + {reviewers.map((r) => ( +
  • + {r.displayName} +
  • + ))} +
+ )}
- )} -
-

Reviewers ({pr.reviewers.length})

- {pr.reviewers.length === 0 ? ( -

- ) : ( -
    - {pr.reviewers.map((r) => ( -
  • - {r.displayName} -
  • - ))} -
- )} -
- -
-

时间线

-
-
远端创建
-
{new Date(pr.createdAt).toLocaleString()}
-
远端更新
-
{new Date(pr.updatedAt).toLocaleString()}
-
本地首次发现
-
{new Date(pr.discoveredAt).toLocaleString()}
-
最近一次 poll 看到
-
{new Date(pr.lastSeenAt).toLocaleString()}
-
-
+
+

时间线

+
+
远端创建
+
{new Date(pr.createdAt).toLocaleString()}
+
远端更新
+
{new Date(pr.updatedAt).toLocaleString()}
+
本地首次发现
+
{new Date(pr.discoveredAt).toLocaleString()}
+
最近一次 poll 看到
+
{new Date(pr.lastSeenAt).toLocaleString()}
+
+
+
); } diff --git a/apps/desktop/src/renderer/src/styles/features/pr-info.scss b/apps/desktop/src/renderer/src/styles/features/pr-info.scss index d1b9bca..038ac02 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr-info.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr-info.scss @@ -1,10 +1,16 @@ @use '../tokens' as *; .pr-info-view { + // 滚动容器铺满,滚动条贴在外层容器右缘 flex: 1; overflow-y: auto; padding: 20px 24px; +} + +.pr-info-content { + // 内容行宽限制 + 居中,避免宽屏下正文铺满过散 max-width: 960px; + margin: 0 auto; } .status-tag { From 0dde77773a1daa58ffa645a0f800c944bc3e8aef Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 22:17:12 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(pr):=20=E8=AF=84=E8=AE=BA=20tab=20?= =?UTF-8?q?=E9=A1=B6=E5=B1=82=E5=88=97=E8=A1=A8=E9=99=90=E5=AE=BD=20960px?= =?UTF-8?q?=20=E5=B9=B6=E5=B1=85=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与详情页一致:内容限宽放在顶层评论列表 .pr-comments-list 上并 margin auto 居中,滚动条留在外层 .pr-comments-panel 右缘。仅约束直接子列表,嵌套 replies 的 .pr-comments-list 不受影响。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/renderer/src/styles/layout/main-pane.scss | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss index bef379b..6cdd79a 100644 --- a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss +++ b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss @@ -419,6 +419,12 @@ flex-direction: column; gap: $space-5; } +// 顶层评论列表限宽 + 居中,与详情页一致;滚动条留在外层 .pr-comments-panel +// 右缘。仅约束直接子列表,嵌套 replies 的 .pr-comments-list 不受影响。 +.pr-comments-panel > .pr-comments-list { + max-width: 960px; + margin: 0 auto; +} .pr-comments-replies { margin-top: $space-4; padding-left: $space-6; @@ -555,7 +561,7 @@ padding: $space-3 $space-4; font-size: $fs-sm; color: $color-danger; - background: rgba(244, 135, 113, 0.10); + background: rgba(244, 135, 113, 0.1); border-left: 3px solid $color-danger; border-radius: $radius-sm; word-break: break-word; @@ -681,7 +687,10 @@ cursor: pointer; text-align: left; width: 100%; - transition: background 0.12s, border-color 0.12s, color 0.12s; + transition: + background 0.12s, + border-color 0.12s, + color 0.12s; &:hover { background: $bg-hover; @@ -796,4 +805,3 @@ word-break: break-word; } } - From 65cf01cf6e43548e5fe7ee1ba17cb73c7db748a4 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 22:18:29 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(diff):=20DiffEditor=20options=20?= =?UTF-8?q?=E6=94=B6=E8=BF=9B=20useMemo=EF=BC=8C=E6=B6=88=E9=99=A4=20poll?= =?UTF-8?q?=20=E9=87=8D=E6=B8=B2=E6=9F=93=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @monaco-editor/react 对 options 按引用比对,引用一变即 editor.updateOptions()。 原 options 为每次渲染新建的对象字面量,poll 时 pr 换新引用 → DiffView/DiffPane 重渲染 → 每次 poll 都触发 updateOptions → hideUnchangedRegions 折叠布局重算 → 中央编辑器抖动。改为 useMemo 固定引用,仅并排/空白/字号变化时重建。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/diff/DiffView.tsx | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) 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 d4e890c..7aff3c7 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 @@ -2221,6 +2221,42 @@ function DiffPane({ // (或挂载即已算完)后卸载,遮住这段抖动一次性 reveal。DiffPane 按 file path keyed → // 切文件自然 remount,diffReady 随之复位。 const [diffReady, setDiffReady] = useState(false); + // options 必须 useMemo 稳定引用:@monaco-editor/react 对 options **按引用**比对,引用一变就 + // editor.updateOptions()。父级 DiffView 随 poll(pr 换新对象引用)重渲染 → DiffPane 重渲染, + // 若每次新建 options 字面量,每次 poll 都触发 updateOptions → hideUnchangedRegions 折叠布局重算 → + // 编辑器渲染抖动。只在真正影响项(并排/空白/字号)变化时重建。 + const fontSize = editorFontSize(14); + const editorOptions = useMemo( + () => ({ + readOnly: true, + renderSideBySide, + minimap: { enabled: false }, + fontSize, + scrollBeyondLastLine: false, + renderOverviewRuler: false, + // 显式开 glyph margin,给行内评论标记留位置 + glyphMargin: true, + // 空白字符可视化:toolbar 按钮控制;'all' 时空格显示 · / Tab 显示 → + renderWhitespace: showWhitespace ? 'all' : 'none', + // GitHub 风格折叠:未变更段缩成可展开占位行 + hideUnchangedRegions: { + enabled: true, + contextLineCount: 10, + minimumLineCount: 5, + revealLineCount: 20, + }, + // 关掉依赖 ts.worker 的高级特性(diff review 不需要),同时消掉 + // `Missing requestHandler` 噪音。hover 保留给 blame / 评论装饰用。 + inlayHints: { enabled: 'off' }, + quickSuggestions: false, + suggestOnTriggerCharacters: false, + parameterHints: { enabled: false }, + codeLens: false, + stickyScroll: { enabled: false }, + occurrencesHighlight: 'off', + }), + [renderSideBySide, showWhitespace, fontSize], + ); const handleMount = useCallback( (editor: MonacoEditor.IStandaloneDiffEditor) => { onMount(editor); @@ -2270,34 +2306,7 @@ function DiffPane({ .filter(Boolean) .join(' ') || undefined } - options={{ - readOnly: true, - renderSideBySide, - minimap: { enabled: false }, - fontSize: editorFontSize(14), - scrollBeyondLastLine: false, - renderOverviewRuler: false, - // 显式开 glyph margin,给行内评论标记留位置 - glyphMargin: true, - // 空白字符可视化:toolbar 按钮控制;'all' 时空格显示 · / Tab 显示 → - renderWhitespace: showWhitespace ? 'all' : 'none', - // GitHub 风格折叠:未变更段缩成可展开占位行 - hideUnchangedRegions: { - enabled: true, - contextLineCount: 10, - minimumLineCount: 5, - revealLineCount: 20, - }, - // 关掉依赖 ts.worker 的高级特性(diff review 不需要),同时消掉 - // `Missing requestHandler` 噪音。hover 保留给 blame / 评论装饰用。 - inlayHints: { enabled: 'off' }, - quickSuggestions: false, - suggestOnTriggerCharacters: false, - parameterHints: { enabled: false }, - codeLens: false, - stickyScroll: { enabled: false }, - occurrencesHighlight: 'off', - }} + options={editorOptions} theme="vs-dark" /> From 8afc5ae6a49890d6a2d9badf36f4a76697bde262 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 22:55:12 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(comments):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=97=B6=E8=AF=84=E8=AE=BA=E5=86=85=E5=B5=8C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=89=87=E6=AE=B5=20Monaco=20=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:i18n 语言切换 effect 依赖整个 boot 对象,poll/聚焦刷新 setBoot 后对同一 语言反复 i18n.changeLanguage,react-i18next 发 languageChanged 致所有 useTranslation 的 t 换新引用;InlineCodeContext 抓取代码片段的 effect 依赖 t, 被无谓重跑 → setSnippet(null) → 内嵌 Monaco 卸载重建。这是全局隐患:任何 effect 依赖 t 的组件都会在每次 poll 被重跑。 - useBootstrap:语言 effect 改为仅依赖 config.language,语言真正变化时才切换。 - InlineCodeContext:t 移出抓取 effect 依赖;并把 Monaco 抽成独立 memo 子组件 CodeSnippet(稳定 options/onMount),与父级重渲染隔离。 CHANGELOG 一并补本分支用户可见修复(刷新抖动 / 详情页与评论页排版 / diff-base 并发写 ENOENT)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 + .../pr/tabs/comments/InlineCodeContext.tsx | 122 +++++++++++------- .../src/renderer/src/hooks/useBootstrap.ts | 49 ++++--- 3 files changed, 102 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c6c17..a4c643a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ ### Fixed +- 刷新(后台轮询 / 窗口聚焦)时编辑器渲染抖动:评论页内嵌代码片段(Monaco)与 diff 编辑器此前每次刷新都重渲染 / 重建。根因有二——其一,i18n 语言切换 effect 依赖整个 boot 对象,poll 刷新 setBoot 后对同一语言反复 `changeLanguage`,触发 `languageChanged` 致所有 `useTranslation` 的 `t` 换新引用,凡 effect 依赖 `t` 的组件(如内嵌代码片段抓取逻辑)都被无谓重跑、连带 Monaco 卸载重建;其二,DiffEditor 的 `options` 为渲染期新建对象,被 `@monaco-editor/react` 按引用判变而反复 `updateOptions`。现语言 effect 仅在语言真正变化时切换、DiffEditor options 稳定化,刷新不再抖动。 +- PR 详情页与评论页排版:正文限宽 960px 并居中,滚动条回到外层容器右缘(此前 max-width 加在滚动容器上,滚动条停在中部);详情页 reviewers 列表按字典序固定排序,刷新不再随平台返回顺序抖动。 +- 拉取变更文件列表偶发失败(`ENOENT … diff-base.json`):状态存储对同一 key 的并发写共用同一临时文件,先完成者 rename 后,后完成者 rename 即 ENOENT。临时文件名追加进程内自增序号去重,并发写各用独立临时文件。 - Agent 评审 / 规划步骤行的固定文案(如「判断是否存在需追问的严重问题」「严重,追问 N 个」)此前在 `@meebox/agent` 层写死中文、被渲染层逐字显示,日 / 英 / 德界面下漏出中文;现按会话语言落地(zh-CN / en-US / ja-JP / de-DE,缺省回落英文),与评审总结骨架同策略。 - 设置页手动「检查更新」查到的新版此前不同步到状态栏、也不缓存:手动检查只把结果回给设置页本地,与定时检查各自为政、无共享。现 main 侧统一为单一真相源——手动 / 定时检查都缓存结果并在有新版时广播 `app:updateAvailable`,状态栏即时出现升级 chip;新增只读 `app:getUpdateStatus`,窗口 / 状态栏挂载时水合已知结果,不因重挂载而丢失。 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 40213a3..5516b3d 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 @@ -3,7 +3,7 @@ import '../../../../../lib/monaco-setup'; import { Editor, type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { PrCommentAnchor, StoredPullRequest } from '@meebox/shared'; import { invoke } from '../../../../../api'; @@ -81,7 +81,11 @@ function InlineCodeContextImpl({ return () => { cancelled = true; }; - }, [expanded, pr.localId, anchor.path, anchor.side, anchor.line, contextLines, t]); + // 故意不依赖 t:useTranslation 的 t 会在 i18n languageChanged 时换新引用(poll 刷新也可能触发), + // 把它放进依赖会让本 effect 无谓重跑 → setSnippet(null) → 内嵌 Monaco 卸载重建(刷新抖动)。 + // t 仅用于错误文案,重抓时机只该由 expanded / pr / anchor 决定。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expanded, pr.localId, anchor.path, anchor.side, anchor.line, contextLines]); if (!expanded) { return ( @@ -99,65 +103,93 @@ function InlineCodeContextImpl({ return
{error}
; } if (!snippet) { - return
{t('inlineCodeContext.loading')}
; + return ( +
{t('inlineCodeContext.loading')}
+ ); } - // 高度按片段行数算;19px 行高 (Monaco fs=12 时近似) + 8px 上下 padding - const lineHeight = 19; + return ; +} + +/** Monaco fs=12 时近似行高;上下各 6px padding */ +const SNIPPET_LINE_HEIGHT = 19; + +const READONLY_OPTIONS: editor.IStandaloneEditorConstructionOptions = { readOnly: true }; + +interface Snippet { + text: string; + startLine: number; + anchorInSnippet: number; +} + +/** + * 只读代码片段编辑器。**独立 memo 组件**:props 只有稳定的 snippet(值不变就同一引用)+ language, + * 与父级 CommentItem / CommentsPanel 的任何重渲染(poll / 焦点刷新触发的 pr 换引用等)彻底隔离。 + * 父级重渲染时本组件按 props 浅比较 bail → 不重建 元素 → @monaco-editor/react 的 value / + * options effect 都不触发(避免只读编辑器被无条件 setValue 重置 → 重新 tokenize 的刷新抖动)。 + * onMount / options 也用稳定引用,杜绝即便重渲染时的 updateOptions 抖动。 + */ +const CodeSnippet = memo(function CodeSnippet({ + snippet, + language, +}: { + snippet: Snippet; + language: string; +}) { const lineCount = snippet.text.split('\n').length; - const height = lineCount * lineHeight + 12; + const height = lineCount * SNIPPET_LINE_HEIGHT + 12; - const handleMount = ( - ed: editor.IStandaloneCodeEditor, - monaco: Monaco, - ): void => { - // 真实文件行号 = snippet 内部行号 + startLine - 1。Monaco lineNumbers 函数式 - // 完全可控,把内部 1..N 映射回去 - ed.updateOptions({ - readOnly: true, - domReadOnly: true, - lineNumbers: (lineNo) => String(lineNo + snippet.startLine - 1), - minimap: { enabled: false }, - scrollBeyondLastLine: false, - scrollbar: { vertical: 'hidden', horizontal: 'hidden', handleMouseWheel: false }, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - renderLineHighlight: 'none', - contextmenu: false, - folding: false, - glyphMargin: false, - fontSize: editorFontSize(12), - lineHeight, - padding: { top: 6, bottom: 6 }, - // 行宽自适应,长行用 word wrap 而不是横向滚动条 (滚动条已禁) - wordWrap: 'on', - }); - // 锚定行整行底色:用 Monaco decorations。线条 className 走 CSS 决定颜色 - ed.createDecorationsCollection([ - { - range: new monaco.Range(snippet.anchorInSnippet, 1, snippet.anchorInSnippet, 1), - options: { - isWholeLine: true, - className: 'comment-code-context-anchor-line', - marginClassName: 'comment-code-context-anchor-gutter', + const handleMount = useCallback( + (ed: editor.IStandaloneCodeEditor, monaco: Monaco): void => { + // 真实文件行号 = snippet 内部行号 + startLine - 1。Monaco lineNumbers 函数式 + // 完全可控,把内部 1..N 映射回去 + ed.updateOptions({ + readOnly: true, + domReadOnly: true, + lineNumbers: (lineNo) => String(lineNo + snippet.startLine - 1), + minimap: { enabled: false }, + scrollBeyondLastLine: false, + scrollbar: { vertical: 'hidden', horizontal: 'hidden', handleMouseWheel: false }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + renderLineHighlight: 'none', + contextmenu: false, + folding: false, + glyphMargin: false, + fontSize: editorFontSize(12), + lineHeight: SNIPPET_LINE_HEIGHT, + padding: { top: 6, bottom: 6 }, + // 行宽自适应,长行用 word wrap 而不是横向滚动条 (滚动条已禁) + wordWrap: 'on', + }); + // 锚定行整行底色:用 Monaco decorations。线条 className 走 CSS 决定颜色 + ed.createDecorationsCollection([ + { + range: new monaco.Range(snippet.anchorInSnippet, 1, snippet.anchorInSnippet, 1), + options: { + isWholeLine: true, + className: 'comment-code-context-anchor-line', + marginClassName: 'comment-code-context-anchor-gutter', + }, }, - }, - ]); - }; + ]); + }, + [snippet], + ); return (
); -} +}); /** * 按**锚点值**(path / line / side)+ pr.localId + 展示选项比较的 memo:父级(CommentsPanel)在 poll diff --git a/apps/desktop/src/renderer/src/hooks/useBootstrap.ts b/apps/desktop/src/renderer/src/hooks/useBootstrap.ts index 76c8a75..8334a7a 100644 --- a/apps/desktop/src/renderer/src/hooks/useBootstrap.ts +++ b/apps/desktop/src/renderer/src/hooks/useBootstrap.ts @@ -1,18 +1,6 @@ -import { - useCallback, - useEffect, - useState, - type Dispatch, - type SetStateAction, -} from 'react'; +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import type { ConnectionSummary } from '@meebox/ipc'; -import type { - AppInfo, - AppPaths, - Config, - PrAgentStatus, - StoredPullRequest, -} from '@meebox/shared'; +import type { AppInfo, AppPaths, Config, PrAgentStatus, StoredPullRequest } from '@meebox/shared'; import { invoke, subscribe } from '../api'; import i18n, { persistLanguage, resolveUiLanguage } from '../i18n'; import type { OnboardingResult } from '../components/features/onboarding'; @@ -76,15 +64,17 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { void (async () => { try { if (!window.api) throw new Error('preload bridge missing: window.api is undefined'); - const [info, paths, config, prAgent, initialPrs, connections, lastSync] = await Promise.all([ - invoke('app:info', undefined), - invoke('app:paths', undefined), - invoke('config:read', undefined), - invoke('app:prAgentStatus', undefined), - invoke('prs:list', undefined), - invoke('app:connections', undefined), - invoke('prs:lastSync', undefined), - ]); + const [info, paths, config, prAgent, initialPrs, connections, lastSync] = await Promise.all( + [ + invoke('app:info', undefined), + invoke('app:paths', undefined), + invoke('config:read', undefined), + invoke('app:prAgentStatus', undefined), + invoke('prs:list', undefined), + invoke('app:connections', undefined), + invoke('prs:lastSync', undefined), + ], + ); const lang = resolveUiLanguage(config.language); persistLanguage(lang); await i18n.changeLanguage(lang); @@ -97,13 +87,18 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { })(); }, [setPrs]); - // 运行时语言切换(如设置页改 config.language):boot 后 language 变化即切换并回写持久化。 + // 运行时语言切换(如设置页改 config.language):仅依赖 config.language。 + // **不能依赖整个 boot**——poll:tick 会 setBoot 刷新 connections 等字段,boot 频繁换新引用; + // 若依赖 boot,每次 poll 都会 i18n.changeLanguage(同一语言) → react-i18next 发 languageChanged + // → 所有 useTranslation 的 t 换新引用 → 凡是 effect 依赖 t 的组件(如 InlineCodeContext 抓取代码 + // 片段的 effect)都被无谓重跑,内嵌 Monaco 随之 setSnippet(null)→重建 → 刷新抖动。 + const configLanguage = boot?.config.language; useEffect(() => { - if (!boot) return; - const lang = resolveUiLanguage(boot.config.language); + if (configLanguage === undefined) return; + const lang = resolveUiLanguage(configLanguage); persistLanguage(lang); void i18n.changeLanguage(lang); - }, [boot]); + }, [configLanguage]); // poll tick:刷新「最近同步」+ 重拉列表(后台轮询新增/删除即时反映)+ 补连接摘要 // (启动 ping 在建窗后才完成,借首轮 tick 把状态栏用户/能力位补上)。 From 8c87804c806fc0a07fb661906a2a6751ac820e72 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 23:05:19 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(pr):=20=E4=B8=BB=E9=9D=A2=E6=9D=BF=20t?= =?UTF-8?q?ab=20keep-alive=EF=BC=8C=E6=B6=88=E9=99=A4=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原各 tab 内容按 {tab === X && } 条件渲染,切换即卸载旧面板 / 重挂新 面板 → 重新拉数据、闪「加载中」、内嵌 Monaco 重建。新增 KeepAliveTab:tab 首访才挂载(保留 DiffView 等懒加载),之后保活、仅 CSS display 显隐不卸载。 切走再切回瞬时、无重拉,滚动位置与展开态保留。 隐藏期 Monaco 容器尺寸归 0,切回需重排——给 diff 编辑器与评论内嵌代码片段开 automaticLayout(Monaco 自带 ResizeObserver 显隐时自动 layout),避免切回空白 /错位。新增 .pr-tab-pane 让 active 面板铺满。CHANGELOG 同步。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../src/components/features/pr/PrPanel.tsx | 42 +++++++++++++++---- .../pr/tabs/comments/InlineCodeContext.tsx | 7 +++- .../features/pr/tabs/diff/DiffView.tsx | 3 ++ .../renderer/src/styles/layout/main-pane.scss | 7 ++++ 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c643a..f51fadf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixed +- PR 主面板各 tab(diff / 评论 / 草稿 / 提交 / 信息)切换抖动:此前 tab 内容按条件渲染,每次切换旧面板卸载、新面板重挂 → 重新拉数据、闪「加载中」、内嵌 Monaco 重建。改为 keep-alive——tab 首访才挂载(保留懒加载)、之后保活仅 CSS 显隐不卸载,切走再切回瞬时、无重拉、滚动位置与展开态保留;配合 Monaco `automaticLayout` 处理显隐后的重排。 - 刷新(后台轮询 / 窗口聚焦)时编辑器渲染抖动:评论页内嵌代码片段(Monaco)与 diff 编辑器此前每次刷新都重渲染 / 重建。根因有二——其一,i18n 语言切换 effect 依赖整个 boot 对象,poll 刷新 setBoot 后对同一语言反复 `changeLanguage`,触发 `languageChanged` 致所有 `useTranslation` 的 `t` 换新引用,凡 effect 依赖 `t` 的组件(如内嵌代码片段抓取逻辑)都被无谓重跑、连带 Monaco 卸载重建;其二,DiffEditor 的 `options` 为渲染期新建对象,被 `@monaco-editor/react` 按引用判变而反复 `updateOptions`。现语言 effect 仅在语言真正变化时切换、DiffEditor options 稳定化,刷新不再抖动。 - PR 详情页与评论页排版:正文限宽 960px 并居中,滚动条回到外层容器右缘(此前 max-width 加在滚动容器上,滚动条停在中部);详情页 reviewers 列表按字典序固定排序,刷新不再随平台返回顺序抖动。 - 拉取变更文件列表偶发失败(`ENOENT … diff-base.json`):状态存储对同一 key 的并发写共用同一临时文件,先完成者 rename 后,后完成者 rename 即 ENOENT。临时文件名追加进程内自增序号去重,并发写各用独立临时文件。 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 beb627e..f0807e6 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import type { LocalPrStatus, PlatformCapabilities, StoredPullRequest } from '@meebox/shared'; import { invoke } from '../../../api'; @@ -154,7 +154,9 @@ export function PrPanel({ onSetRenderSideBySide={setRenderSideBySide} />
- {tab === 'diff' && ( + {/* keep-alive:各 tab 首访才挂载、之后保活仅 CSS 显隐(见 KeepAliveTab)。 + 切走再切回瞬时、无重拉、内嵌 Monaco / 滚动位置 / 展开态全部保留,消除切换抖动。 */} + }> - )} - {tab === 'comments' && ( + + setCommentCount(n)} capabilities={capabilities} /> - )} - {tab === 'drafts' && ( + + - )} - {tab === 'commits' && } - {tab === 'info' && } + + + + + + +
{publishModalOpen && ( ); } + +/** + * tab 内容保活容器:首次 active 才挂载(保留 DiffView 等的懒加载优势),此后**不卸载**, + * 仅靠 CSS `display` 显隐。切走再切回瞬时、无重拉、内嵌 Monaco / 滚动位置 / 展开态全保留 → + * 消除切换抖动。隐藏期 Monaco 容器尺寸为 0,再显示需重排——由编辑器侧 `automaticLayout` + * 自动处理(见 DiffView / InlineCodeContext)。 + */ +function KeepAliveTab({ active, children }: { active: boolean; children: ReactNode }) { + // 「一旦 active 过就保活」latch:ref 在 render 期写入是幂等闩锁,与本仓 stablePr 同模式。 + const mounted = useRef(false); + if (active) mounted.current = true; + if (!mounted.current) return null; + return ( +
+ {children} +
+ ); +} 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 5516b3d..8c5a058 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 @@ -114,7 +114,12 @@ function InlineCodeContextImpl({ /** Monaco fs=12 时近似行高;上下各 6px padding */ const SNIPPET_LINE_HEIGHT = 19; -const READONLY_OPTIONS: editor.IStandaloneEditorConstructionOptions = { readOnly: true }; +const READONLY_OPTIONS: editor.IStandaloneEditorConstructionOptions = { + readOnly: true, + // keep-alive:评论 tab 切走时本编辑器被 display:none(尺寸归 0),切回需重排。 + // automaticLayout 让 Monaco 自带 ResizeObserver 在显隐时自动 layout,避免切回空白/错位。 + automaticLayout: true, +}; interface Snippet { text: string; 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 7aff3c7..b376e6a 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 @@ -2230,6 +2230,9 @@ function DiffPane({ () => ({ readOnly: true, renderSideBySide, + // keep-alive:tab 切走时本编辑器被 display:none(尺寸归 0),切回需重排。automaticLayout + // 让 Monaco 自带 ResizeObserver 在显隐/尺寸变化时自动 layout,避免切回空白/错位。 + automaticLayout: true, minimap: { enabled: false }, fontSize, scrollBeyondLastLine: false, diff --git a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss index 6cdd79a..a54d41f 100644 --- a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss +++ b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss @@ -401,6 +401,13 @@ min-height: 0; display: flex; } +// keep-alive tab 保活容器:active 时铺满 .pr-tab-content(内部面板各自 flex:1), +// inactive 由内联 display:none 隐藏但保留挂载(见 PrPanel KeepAliveTab)。 +.pr-tab-pane { + flex: 1; + min-height: 0; + display: flex; +} // === 评论标签页 === // 区别于 DiffView 内嵌 inline 评论:这里是 PR 全量评论 (含 summary + inline), From 0fbbdb010c2877170ea7beb050c54bae71cb65f3 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 10:15:17 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(pr):=20=E8=AF=84=E8=AE=BA=E5=88=87?= =?UTF-8?q?=E6=8D=A2=20stale-while-loading=20+=20tab=20=E8=A7=92=E6=A0=87?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E9=98=B2=E6=8A=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切不同 PR 时评论页此前先 setComments(null) 闪「加载评论中」再渲新内容。改为 stale-while-loading:评论与配对 pr 一起冻结为 view,切 PR 不清空、旧内容继续 渲染并上盖加载遮罩,新数据 ready 整体替换;遮罩延迟 150ms,命中缓存的快切换 直接换新、零闪。滚动下沉到 .pr-comments-scroll,外层做定位锚让遮罩铺满视口。 tab 栏角标(评论 / 提交计数)异步加载:新增 TabCountBadge,加载中渲染等宽 占位 chip 预留宽度,消除计数到达的横向弹簧;.pr-tab 改 inline-flex + 固定行高, 角标占位 / 出现 / 消失不再改变 tab 高度,消除 1~2px 竖向跳动。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + .../components/features/pr/tabs/PrTabs.tsx | 50 +++++--- .../pr/tabs/comments/CommentsPanel.tsx | 107 ++++++++++-------- .../renderer/src/styles/layout/main-pane.scss | 40 ++++++- 4 files changed, 134 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f51fadf..d84affc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ ### Fixed +- 切换不同 PR 时评论页先闪「加载评论中」再渲新内容的空窗:改为 stale-while-loading——切 PR 期间保留旧评论渲染、上盖加载遮罩,新数据 ready 后整体替换;遮罩延迟 150ms 显示,命中本地缓存的快切换直接换新、零闪。 +- PR 主面板 tab 栏角标(评论 / 提交计数)异步加载导致的抖动:计数加载中渲染等宽占位 chip 预留宽度,消除计数到达时的横向弹簧拉伸;`.pr-tab` 改 flex 布局 + 固定行高,角标占位 / 出现 / 消失不再改变 tab 高度,消除 tab 栏 1~2px 竖向跳动。 - PR 主面板各 tab(diff / 评论 / 草稿 / 提交 / 信息)切换抖动:此前 tab 内容按条件渲染,每次切换旧面板卸载、新面板重挂 → 重新拉数据、闪「加载中」、内嵌 Monaco 重建。改为 keep-alive——tab 首访才挂载(保留懒加载)、之后保活仅 CSS 显隐不卸载,切走再切回瞬时、无重拉、滚动位置与展开态保留;配合 Monaco `automaticLayout` 处理显隐后的重排。 - 刷新(后台轮询 / 窗口聚焦)时编辑器渲染抖动:评论页内嵌代码片段(Monaco)与 diff 编辑器此前每次刷新都重渲染 / 重建。根因有二——其一,i18n 语言切换 effect 依赖整个 boot 对象,poll 刷新 setBoot 后对同一语言反复 `changeLanguage`,触发 `languageChanged` 致所有 `useTranslation` 的 `t` 换新引用,凡 effect 依赖 `t` 的组件(如内嵌代码片段抓取逻辑)都被无谓重跑、连带 Monaco 卸载重建;其二,DiffEditor 的 `options` 为渲染期新建对象,被 `@monaco-editor/react` 按引用判变而反复 `updateOptions`。现语言 effect 仅在语言真正变化时切换、DiffEditor options 稳定化,刷新不再抖动。 - PR 详情页与评论页排版:正文限宽 960px 并居中,滚动条回到外层容器右缘(此前 max-width 加在滚动容器上,滚动条停在中部);详情页 reviewers 列表按字典序固定排序,刷新不再随平台返回顺序抖动。 diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx index 2a2fe30..1287777 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx @@ -55,14 +55,10 @@ export function PrTabs({ aria-selected={tab === 'comments'} > {t('mainPane.tabComments')} - {commentCount !== null && commentCount > 0 && ( - - {commentCount} - - )} + t('mainPane.commentCountAria', { count: n })} + /> {/* 草稿 tab:显示条件用总数 — 全发完仍能进 tab 看 posted/rejected 历史; 从未创建草稿的 PR 才完全隐藏 tab,避免冗余入口 */} @@ -94,14 +90,10 @@ export function PrTabs({ aria-selected={tab === 'commits'} > {t('mainPane.tabCommits')} - {commitCount !== null && commitCount > 0 && ( - - {commitCount} - - )} + t('mainPane.commitCountAria', { count: n })} + />