From 78dd1d569f092043bb72882650aa1e1c937b240c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Fri, 19 Jun 2026 16:11:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor(diff):=20DiffView=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=EF=BC=8C=E6=94=B6=E6=95=9B=E8=A1=8C=E5=86=85?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=B8=B2=E6=9F=93/zone=20=E8=A3=85=E9=85=8D/?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiffView.tsx 2647 → 380 行,退化为组合根(调 hooks + 渲染布局),行为零变化。 - zones/mountInlineZones:抽象评论 zone 与草稿 zone 共用的通用 view-zone 装配 (双层 dom/inner、stopPropagation 接管、宽度/横滚/高度同步、removeZone/unmount 清理) - inline-comments/InlineCommentZone:行内评论渲染独立成域 - blame/ · DiffPane · DiffScopeSelect · DiffStatus · DraftZoneList · diff-types 各自拆出 - hooks/(+ index barrel):数据流与三段 zone 装配 effect 拆成 12 个聚焦 hook - tabs/shared/:CommentItem(tab) 与 InlineCommentZone(diff) 共用 useCommentThread (reply/edit/delete 状态机)与 CommentMarkdown(markdown 样板) - i18n:评论操作 key 合并进 commentsPanel.*,四 locale 各删 9 个 diffView.* 重复项 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/pr/tabs/comments/CommentItem.tsx | 72 +- .../features/pr/tabs/diff/DiffPane.tsx | 128 + .../features/pr/tabs/diff/DiffScopeSelect.tsx | 169 ++ .../features/pr/tabs/diff/DiffStatus.tsx | 134 + .../features/pr/tabs/diff/DiffView.tsx | 2473 +---------------- .../features/pr/tabs/diff/DraftZoneList.tsx | 63 + .../pr/tabs/diff/blame/BlameColumn.tsx | 210 ++ .../pr/tabs/diff/blame/blame-utils.ts | 73 + .../features/pr/tabs/diff/diff-types.ts | 32 + .../features/pr/tabs/diff/hooks/index.ts | 14 + .../features/pr/tabs/diff/hooks/useBlame.ts | 111 + .../pr/tabs/diff/hooks/useChangedFiles.ts | 72 + .../pr/tabs/diff/hooks/useCommentZones.tsx | 127 + .../pr/tabs/diff/hooks/useDiffComments.ts | 68 + .../features/pr/tabs/diff/hooks/useDiffNav.ts | 149 + .../pr/tabs/diff/hooks/useDiffScope.ts | 70 + .../pr/tabs/diff/hooks/useDraftAutoEdit.ts | 51 + .../pr/tabs/diff/hooks/useDraftZones.tsx | 101 + .../pr/tabs/diff/hooks/useFileContent.ts | 73 + .../pr/tabs/diff/hooks/useFileListWidth.ts | 46 + .../pr/tabs/diff/hooks/useLineCommentAdder.ts | 218 ++ .../pr/tabs/diff/hooks/useSyncProgress.ts | 19 + .../inline-comments/InlineCommentZone.tsx | 322 +++ .../pr/tabs/diff/zones/line-mapping.ts | 55 + .../pr/tabs/diff/zones/mountInlineZones.ts | 237 ++ .../pr/tabs/shared/CommentMarkdown.tsx | 38 + .../pr/tabs/shared/useCommentThread.ts | 71 + .../src/renderer/src/i18n/locales/de-DE.json | 9 - .../src/renderer/src/i18n/locales/en-US.json | 9 - .../src/renderer/src/i18n/locales/ja-JP.json | 9 - .../src/renderer/src/i18n/locales/zh-CN.json | 9 - 31 files changed, 2779 insertions(+), 2453 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/blame-utils.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/diff-types.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useBlame.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useChangedFiles.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffScope.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftAutoEdit.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileContent.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileListWidth.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useLineCommentAdder.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSyncProgress.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/line-mapping.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/shared/CommentMarkdown.tsx create mode 100644 apps/desktop/src/renderer/src/components/features/pr/tabs/shared/useCommentThread.ts 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 6559b06..2fa0079 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 @@ -1,19 +1,16 @@ -import { lazy, Suspense, useMemo, useState } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import ReactMarkdown from 'react-markdown'; -import remarkBreaks from 'remark-breaks'; -import remarkGfm from 'remark-gfm'; import type { PrComment, PrCommentAnchor, StoredPullRequest } from '@meebox/shared'; -import { invoke } from '../../../../../api'; import i18n from '../../../../../i18n'; -import { REMOTE_REHYPE_PLUGINS } from '../../../../../lib/markdown'; import { Avatar } from '../../../../common/Avatar'; -import { makeBitbucketImageFor, transformBitbucketUrl } from '../../../../common/BitbucketImage'; +import { makeBitbucketImageFor } from '../../../../common/BitbucketImage'; import { ChatIcon } from '../../../../common/icons'; import { CommentEditEditor } from './CommentEditEditor'; import { CommentReplyEditor } from './CommentReplyEditor'; import { ConfirmModal } from '../../../../common/ConfirmModal'; import { mermaidComponents } from '../../../../common/markdownMermaid'; +import { CommentMarkdown } from '../shared/CommentMarkdown'; +import { useCommentThread } from '../shared/useCommentThread'; // 行内代码上下文用 Monaco,懒加载随 DiffView 同一套 Monaco chunk 按需拉取,不进入口包。 const InlineCodeContext = lazy(() => import('./InlineCodeContext').then((m) => ({ default: m.InlineCodeContext })), @@ -85,35 +82,21 @@ export function CommentItem({ () => ({ ...mermaidComponents, img: makeBitbucketImageFor(pr.localId, pr.url) }), [pr.localId, pr.url], ); - const [replyOpen, setReplyOpen] = useState(false); - const [editOpen, setEditOpen] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); - - // 删除/编辑条件 main 端预判好了 (annotateOwnership)。renderer 直读 flag, - // 不再自己比对 author / version / replies - const canDelete = comment.canDelete === true; - const canEdit = comment.canEdit === true; - - const handleDelete = async (): Promise => { - if (!canDelete || comment.version === undefined) return; - setConfirmDelete(false); - setDeleting(true); - setDeleteError(null); - try { - await invoke('comments:delete', { - localId: pr.localId, - commentId: comment.remoteId, - version: comment.version, - }); - // 成功 → main 端清 cache + 广播 comments:changed → 本面板 useEffect 重拉, - // 这条评论自然从列表里消失,不用手动维护本地 state - } catch (e) { - setDeleteError(e instanceof Error ? e.message : String(e)); - setDeleting(false); - } - }; + // 回复 / 编辑 / 删除 交互状态机(与 diff 行内评论 zone 共用,见 shared/useCommentThread) + const { + replyOpen, + setReplyOpen, + editOpen, + setEditOpen, + confirmDelete, + setConfirmDelete, + deleting, + deleteError, + setDeleteError, + canEdit, + canDelete, + handleDelete, + } = useCommentThread(pr.localId, comment); // inline 评论锚点 chip:path:line + 侧别 (old=base / new=head),让用户在评论里定位到代码位置。 // 提供 onJumpToAnchor 时(活动视图)chip 变可点击 → 跳到 Diff 对应文件/行。 @@ -163,17 +146,12 @@ export function CommentItem({ onSaved={() => setEditOpen(false)} /> ) : ( -
- {/* hardBreaks(Bitbucket/GitHub)挂 remarkBreaks 单 \n→
;GitLab CommonMark 不挂 */} - - {comment.body} - -
+ ); // 操作行:编辑态隐藏所有按钮(避免跟编辑器底部按钮组重复) 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 new file mode 100644 index 0000000..a44eaa3 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx @@ -0,0 +1,128 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DiffEditor } from '@monaco-editor/react'; +import { type editor as MonacoEditor } from 'monaco-editor'; +import type { DiffChangedFile } from '@meebox/ipc'; +import { editorFontSize } from '../../../../../lib/editor-font'; +import { languageFor } from '../../../../../utils/language'; +import { PaneLoading } from '../../../../common/Loading'; +import { Spinner } from './DiffStatus'; +import type { LoadedContent } from './diff-types'; + +export function DiffPane({ + file, + content, + loading, + renderSideBySide, + showBlame, + showWhitespace, + onMount, +}: { + file: DiffChangedFile; + content: LoadedContent | null; + loading: boolean; + renderSideBySide: boolean; + showBlame: boolean; + showWhitespace: boolean; + onMount: (editor: MonacoEditor.IStandaloneDiffEditor) => void; +}) { + const { t } = useTranslation(); + // Monaco 挂载后 diff 还要异步计算 + hideUnchangedRegions 折叠才稳定(见上文 reveal 逻辑), + // 期间编辑器是「空 → 跳一下」的重排。在它之上盖一层 overlay loading,首次 onDidUpdateDiff + // (或挂载即已算完)后卸载,遮住这段抖动一次性 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, + // keep-alive:tab 切走时本编辑器被 display:none(尺寸归 0),切回需重排。automaticLayout + // 让 Monaco 自带 ResizeObserver 在显隐/尺寸变化时自动 layout,避免切回空白/错位。 + automaticLayout: true, + 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); + // diff 算完触发 onDidUpdateDiff,但 hideUnchangedRegions 折叠的布局还要再 paint + // 一两帧才稳定 → 不在事件里立即揭开(否则露出折叠那一跳),略等 80ms 让折叠 paint + // 完成、overlay 一直盖着,再一次性 reveal。 + const reveal = (): void => { + window.setTimeout(() => setDiffReady(true), 80); + }; + if (editor.getLineChanges() != null) { + reveal(); + return; + } + const d = editor.onDidUpdateDiff(() => { + d.dispose(); + reveal(); + }); + }, + [onMount], + ); + if (loading || !content) { + return ( +
+ + {t('diffView.loadingContentPrefix')} {file.path}{' '} + {t('diffView.loadingContentSuffix')} +
+ {t('diffView.loadingContentHint')} +
+
+ ); + } + if (content.base.binary || content.head.binary) { + return
{t('diffView.binaryNotRendered')}
; + } + return ( +
+ {!diffReady && } + +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx new file mode 100644 index 0000000..aed1160 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import type { PrCommit } from '@meebox/shared'; +import { Avatar } from '../../../../common/Avatar'; +import { ChevronIcon, CommitIcon, PullRequestIcon } from '../../../../common/icons'; +import { formatRelativeTime } from '../comments/CommentItem'; +import type { DiffScope } from './diff-types'; + +/** + * 变更范围选择器:文件树头部「 个文件 · 全部变更 / 」,点击展开下拉切换查看范围。 + * commit 列表懒加载(首次展开才拉)。选「全部变更」= PR 全量 diff;选某 commit = 该 commit + * 的 parent..sha 只读 diff。 + */ +export function DiffScopeSelect({ + fileCount, + scope, + commits, + connectionId, + onOpen, + onPick, +}: { + fileCount: number; + scope: DiffScope; + commits: PrCommit[] | null; + connectionId: string; + onOpen: () => void; + onPick: (scope: DiffScope) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + // 下拉用 fixed 定位 + portal 挂到 body:否则会被 .diff-file-list 的 overflow 裁切、被右侧 Monaco 盖住。 + const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null); + const computePos = useCallback(() => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + // 菜单整体拉宽:不小于触发器宽度,且给 commit 主题留足空间 + setMenuPos({ top: r.bottom + 2, left: r.left, width: Math.max(r.width, 440) }); + }, []); + useEffect(() => { + if (!open) return; + computePos(); + const onDoc = (e: MouseEvent): void => { + const target = e.target as Node; + if (ref.current?.contains(target) || menuRef.current?.contains(target)) return; + setOpen(false); + }; + // 触发器在文件树头部(不随文件列表滚动),但窗口缩放 / 外层滚动时重算位置 + const onReflow = (): void => computePos(); + document.addEventListener('mousedown', onDoc); + window.addEventListener('resize', onReflow); + window.addEventListener('scroll', onReflow, true); + return () => { + document.removeEventListener('mousedown', onDoc); + window.removeEventListener('resize', onReflow); + window.removeEventListener('scroll', onReflow, true); + }; + }, [open, computePos]); + + const scopeLabel = scope.kind === 'all' ? t('diffView.scopeAll') : scope.abbreviatedSha; + const toggle = (): void => { + setOpen((o) => { + const next = !o; + if (next) onOpen(); + return next; + }); + }; + return ( +
+ + {open && + menuPos && + createPortal( +
    + {/* 「全部变更」:标题 + 提交数副行(参考 Bitbucket 两行布局) */} +
  • + +
  • + {/* 每个 commit:标题(首行 message)+ 作者 / 短 SHA / 时间副行 */} + {(commits ?? []).map((c) => { + const subject = c.message.split('\n', 1)[0]!; + const active = scope.kind === 'commit' && scope.sha === c.sha; + return ( +
  • + +
  • + ); + })} +
, + document.body, + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx new file mode 100644 index 0000000..4e5ebab --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx @@ -0,0 +1,134 @@ +import { useTranslation } from 'react-i18next'; +import type { SyncProgressEvent } from '@meebox/shared'; +import type { FormattedError } from '../../../../../errors'; + +export function Spinner() { + return