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