Skip to content
Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@

### Fixed

- 切换不同 PR 时 diff 文件树「左栏空白 → 文件树整体弹出」的抖动:DiffView 改为 stale-while-loading——引入 `loadedPrId` 标记当前已渲染内容所属 PR,切 PR 期间保留旧树 / 旧内容渲染、上盖加载遮罩(延迟 150ms,命中缓存的快切换直接换新),并门控 content / comments / blame 拉取(避免「新 localId + 旧选中文件」错拉),新文件列表 ready 后整体替换。
- diff 文件树首次加载时文件名被图标渲染推移的抖动:图标改用固定 16px 占位槽包裹,iconify 的 svg 晚一帧进 DOM 也不塌缩,文件名位置稳定。
- 切换不同 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 列表按字典序固定排序,刷新不再随平台返回顺序抖动。
- 拉取变更文件列表偶发失败(`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`,窗口 / 状态栏挂载时水合已知结果,不因重挂载而丢失。

Expand Down
42 changes: 33 additions & 9 deletions apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -154,7 +154,9 @@ export function PrPanel({
onSetRenderSideBySide={setRenderSideBySide}
/>
<div className="pr-tab-content">
{tab === 'diff' && (
{/* keep-alive:各 tab 首访才挂载、之后保活仅 CSS 显隐(见 KeepAliveTab)。
切走再切回瞬时、无重拉、内嵌 Monaco / 滚动位置 / 展开态全部保留,消除切换抖动。 */}
<KeepAliveTab active={tab === 'diff'}>
<Suspense fallback={<PaneLoading label={t('mainPane.loadingEditor')} />}>
<DiffView
pr={pr}
Expand All @@ -166,15 +168,15 @@ export function PrPanel({
onNavConsumed={onDiffNavConsumed}
/>
</Suspense>
)}
{tab === 'comments' && (
</KeepAliveTab>
<KeepAliveTab active={tab === 'comments'}>
<CommentsPanel
pr={pr}
onCommentsLoaded={(n) => setCommentCount(n)}
capabilities={capabilities}
/>
)}
{tab === 'drafts' && (
</KeepAliveTab>
<KeepAliveTab active={tab === 'drafts'}>
<DraftsPanel
pr={pr}
capabilities={capabilities}
Expand All @@ -190,9 +192,13 @@ export function PrPanel({
});
}}
/>
)}
{tab === 'commits' && <CommitsPanel pr={pr} />}
{tab === 'info' && <PrInfoView pr={pr} />}
</KeepAliveTab>
<KeepAliveTab active={tab === 'commits'}>
<CommitsPanel pr={pr} />
</KeepAliveTab>
<KeepAliveTab active={tab === 'info'}>
<PrInfoView pr={pr} />
</KeepAliveTab>
</div>
{publishModalOpen && (
<PublishReviewModal
Expand All @@ -218,3 +224,21 @@ export function PrPanel({
</>
);
}

/**
* 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 (
<div className="pr-tab-pane" style={{ display: active ? undefined : 'none' }}>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="pr-info-view">
{pr.description && (
<div className="pr-info-content">
{pr.description && (
<section className="pr-detail-section">
<h3>描述</h3>
<div className="pr-detail-description markdown">
{/* Bitbucket 远端用 \r\n 行尾,remark 解析时 CR 跟 LF 各算一次换行 → 单换行
被当成段落分隔,每个 list item 之间多一段空白。归一化成 \n */}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={mdComponents}
urlTransform={transformBitbucketUrl}
>
{pr.description.replace(/\r\n?/g, '\n')}
</ReactMarkdown>
</div>
</section>
)}

<section className="pr-detail-section">
<h3>描述</h3>
<div className="pr-detail-description markdown">
{/* Bitbucket 远端用 \r\n 行尾,remark 解析时 CR 跟 LF 各算一次换行 → 单换行
被当成段落分隔,每个 list item 之间多一段空白。归一化成 \n */}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={mdComponents}
urlTransform={transformBitbucketUrl}
>
{pr.description.replace(/\r\n?/g, '\n')}
</ReactMarkdown>
</div>
<h3>Reviewers ({reviewers.length})</h3>
{reviewers.length === 0 ? (
<p className="muted">无</p>
) : (
<ul className="reviewer-list">
{reviewers.map((r) => (
<li key={r.name}>
{r.displayName} <ReviewerStatusTag status={r.status} />
</li>
))}
</ul>
)}
</section>
)}

<section className="pr-detail-section">
<h3>Reviewers ({pr.reviewers.length})</h3>
{pr.reviewers.length === 0 ? (
<p className="muted">无</p>
) : (
<ul className="reviewer-list">
{pr.reviewers.map((r) => (
<li key={r.name}>
{r.displayName} <ReviewerStatusTag status={r.status} />
</li>
))}
</ul>
)}
</section>

<section className="pr-detail-section">
<h3>时间线</h3>
<div className="pr-detail-kv">
<div className="modal-kv-key">远端创建</div>
<div className="modal-kv-val">{new Date(pr.createdAt).toLocaleString()}</div>
<div className="modal-kv-key">远端更新</div>
<div className="modal-kv-val">{new Date(pr.updatedAt).toLocaleString()}</div>
<div className="modal-kv-key">本地首次发现</div>
<div className="modal-kv-val">{new Date(pr.discoveredAt).toLocaleString()}</div>
<div className="modal-kv-key">最近一次 poll 看到</div>
<div className="modal-kv-val">{new Date(pr.lastSeenAt).toLocaleString()}</div>
</div>
</section>
<section className="pr-detail-section">
<h3>时间线</h3>
<div className="pr-detail-kv">
<div className="modal-kv-key">远端创建</div>
<div className="modal-kv-val">{new Date(pr.createdAt).toLocaleString()}</div>
<div className="modal-kv-key">远端更新</div>
<div className="modal-kv-val">{new Date(pr.updatedAt).toLocaleString()}</div>
<div className="modal-kv-key">本地首次发现</div>
<div className="modal-kv-val">{new Date(pr.discoveredAt).toLocaleString()}</div>
<div className="modal-kv-key">最近一次 poll 看到</div>
<div className="modal-kv-val">{new Date(pr.lastSeenAt).toLocaleString()}</div>
</div>
</section>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,10 @@ export function PrTabs({
aria-selected={tab === 'comments'}
>
{t('mainPane.tabComments')}
{commentCount !== null && commentCount > 0 && (
<span
className="pr-tab-badge"
aria-label={t('mainPane.commentCountAria', { count: commentCount })}
>
{commentCount}
</span>
)}
<TabCountBadge
count={commentCount}
ariaLabel={(n) => t('mainPane.commentCountAria', { count: n })}
/>
</button>
{/* 草稿 tab:显示条件用总数 — 全发完仍能进 tab 看 posted/rejected 历史;
从未创建草稿的 PR 才完全隐藏 tab,避免冗余入口 */}
Expand Down Expand Up @@ -94,14 +90,10 @@ export function PrTabs({
aria-selected={tab === 'commits'}
>
{t('mainPane.tabCommits')}
{commitCount !== null && commitCount > 0 && (
<span
className="pr-tab-badge"
aria-label={t('mainPane.commitCountAria', { count: commitCount })}
>
{commitCount}
</span>
)}
<TabCountBadge
count={commitCount}
ariaLabel={(n) => t('mainPane.commitCountAria', { count: n })}
/>
</button>
<button
type="button"
Expand Down Expand Up @@ -157,3 +149,29 @@ export function PrTabs({
</nav>
);
}

/**
* tab 计数角标。计数异步加载(评论 / 提交):
* - `null`(加载中):渲染等宽占位 chip,预留角标宽度,消除计数到达时 tab 的横向弹簧抖动;
* - `> 0`:真实数字角标;
* - `0`:不渲染(无角标)。
*/
function TabCountBadge({
count,
ariaLabel,
}: {
count: number | null;
ariaLabel: (n: number) => string;
}) {
if (count === null) {
return <span className="pr-tab-badge pr-tab-badge-loading" aria-hidden="true" />;
}
if (count > 0) {
return (
<span className="pr-tab-badge" aria-label={ariaLabel(count)}>
{count}
</span>
);
}
return null;
}
Loading
Loading