Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 })),
Expand Down Expand Up @@ -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<string | null>(null);

// 删除/编辑条件 main 端预判好了 (annotateOwnership)。renderer 直读 flag,
// 不再自己比对 author / version / replies
const canDelete = comment.canDelete === true;
const canEdit = comment.canEdit === true;

const handleDelete = async (): Promise<void> => {
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 对应文件/行。
Expand Down Expand Up @@ -163,17 +146,12 @@ export function CommentItem({
onSaved={() => setEditOpen(false)}
/>
) : (
<div className="pr-comment-body markdown">
{/* hardBreaks(Bitbucket/GitHub)挂 remarkBreaks 单 \n→<br>;GitLab CommonMark 不挂 */}
<ReactMarkdown
remarkPlugins={hardBreaks ? [remarkGfm, remarkBreaks] : [remarkGfm]}
rehypePlugins={REMOTE_REHYPE_PLUGINS}
components={mdComponents}
urlTransform={transformBitbucketUrl}
>
{comment.body}
</ReactMarkdown>
</div>
<CommentMarkdown
body={comment.body}
hardBreaks={hardBreaks}
components={mdComponents}
className="pr-comment-body markdown"
/>
);

// 操作行:编辑态隐藏所有按钮(避免跟编辑器底部按钮组重复)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MonacoEditor.IDiffEditorConstructionOptions>(
() => ({
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 (
<div className="diff-empty">
<span className="muted">
<Spinner /> {t('diffView.loadingContentPrefix')} <code>{file.path}</code>{' '}
{t('diffView.loadingContentSuffix')}
<br />
<small>{t('diffView.loadingContentHint')}</small>
</span>
</div>
);
}
if (content.base.binary || content.head.binary) {
return <div className="diff-binary">{t('diffView.binaryNotRendered')}</div>;
}
return (
<div className="diff-pane-editor">
{!diffReady && <PaneLoading overlay delayMs={0} />}
<DiffEditor
height="100%"
language={languageFor(file.path)}
original={content.base.content}
modified={content.head.content}
onMount={handleMount}
className={
[showBlame ? 'diff-editor-with-blame' : '', showWhitespace ? 'diff-editor-show-eol' : '']
.filter(Boolean)
.join(' ') || undefined
}
options={editorOptions}
theme="vs-dark"
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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';

/**
* 变更范围选择器:文件树头部「<n> 个文件 · 全部变更 / <commit>」,点击展开下拉切换查看范围。
* 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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(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 (
<div className="diff-scope-select" ref={ref}>
<button
ref={triggerRef}
type="button"
className="diff-scope-trigger"
onClick={toggle}
title={scope.kind === 'commit' ? scope.subject : undefined}
aria-expanded={open}
>
<span className="diff-scope-label">
{t('diffView.fileCount', { count: fileCount })} · {scopeLabel}
</span>
<ChevronIcon className={open ? 'diff-scope-chevron open' : 'diff-scope-chevron'} />
</button>
{open &&
menuPos &&
createPortal(
<ul
ref={menuRef}
className="diff-scope-menu"
role="listbox"
style={{ top: menuPos.top, left: menuPos.left, width: menuPos.width }}
>
{/* 「全部变更」:标题 + 提交数副行(参考 Bitbucket 两行布局) */}
<li>
<button
type="button"
className={`diff-scope-option ${scope.kind === 'all' ? 'active' : ''}`}
onClick={() => {
onPick({ kind: 'all' });
setOpen(false);
}}
>
<span className="diff-scope-option-icon" aria-hidden="true">
<PullRequestIcon size={16} />
</span>
<span className="diff-scope-option-body">
<span className="diff-scope-option-title">{t('diffView.scopeAll')}</span>
<span className="diff-scope-option-sub">
{commits === null
? t('diffView.scopeLoading')
: t('diffView.scopeCommitCount', { count: commits.length })}
</span>
</span>
</button>
</li>
{/* 每个 commit:标题(首行 message)+ 作者 / 短 SHA / 时间副行 */}
{(commits ?? []).map((c) => {
const subject = c.message.split('\n', 1)[0]!;
const active = scope.kind === 'commit' && scope.sha === c.sha;
return (
<li key={c.sha}>
<button
type="button"
className={`diff-scope-option diff-scope-option-commit ${active ? 'active' : ''}`}
title={subject}
onClick={() => {
onPick({
kind: 'commit',
sha: c.sha,
parent: c.parents[0] ?? null,
abbreviatedSha: c.abbreviatedSha,
subject,
});
setOpen(false);
}}
>
<span className="diff-scope-option-icon" aria-hidden="true">
<CommitIcon size={16} />
</span>
<span className="diff-scope-option-body">
<span className="diff-scope-option-title">{subject}</span>
<span className="diff-scope-option-sub">
<Avatar
connectionId={connectionId}
slug={c.author.slug ?? c.author.name}
displayName={c.author.displayName}
avatarUrl={c.author.avatarUrl}
size={16}
/>
<span className="diff-scope-author">{c.author.displayName}</span>
<code className="diff-scope-sha">{c.abbreviatedSha}</code>
<time className="diff-scope-time">
{formatRelativeTime(c.committedAt || c.authoredAt)}
</time>
</span>
</span>
</button>
</li>
);
})}
</ul>,
document.body,
)}
</div>
);
}
Loading
Loading