@@ -1034,7 +1201,7 @@ const DiffChunkView = memo(function DiffChunkView(props: {
@@ -1160,17 +1327,438 @@ function DiffContent(props: {
showStat?: boolean;
}) {
const { diff, title, error, loading = false, showStat = true } = props;
- const { t } = useLocale();
+ const { locale, t } = useLocale();
const isDark = useIsDark();
+ const rootRef = useRef(null);
+ const scrollViewportRef = useRef(null);
+ const contextMenuRef = useRef(null);
+ const selectionAutoscrollViewportsRef = useRef([]);
+ const selectionAutoscrollPointerRef = useRef<{
+ x: number;
+ y: number;
+ } | null>(null);
+ const selectionAutoscrollFrameRef = useRef(null);
+ const diffHorizontalScrollbarTrackRef = useRef(null);
+ const diffHorizontalScrollTargetsRef = useRef([]);
+ const diffHorizontalActiveTargetRef = useRef(null);
+ const [selectionContextMenu, setSelectionContextMenu] =
+ useState(null);
+ const [diffHorizontalScrollbar, setDiffHorizontalScrollbar] =
+ useState({
+ visible: false,
+ thumbWidth: 0,
+ thumbLeft: 0,
+ maxScrollLeft: 0,
+ scrollLeft: 0,
+ });
const patchChunks = useMemo(
() => buildPatchChunks(diff?.patch ?? "", title),
[diff?.patch, title],
);
const showLoadingState = loading && !error && !diff;
const showDiffStat = showStat && Boolean(diff?.stat);
+ const closeSelectionContextMenu = useCallback(() => {
+ setSelectionContextMenu(null);
+ }, []);
+
+ const updateDiffHorizontalScrollbar = useCallback(() => {
+ const root = rootRef.current;
+ const trackWidth =
+ diffHorizontalScrollbarTrackRef.current?.clientWidth ??
+ scrollViewportRef.current?.clientWidth ??
+ root?.clientWidth ??
+ 0;
+ const target = chooseDiffHorizontalScrollTarget(
+ diffHorizontalScrollTargetsRef.current,
+ diffHorizontalActiveTargetRef.current,
+ );
+
+ if (!target || trackWidth <= 0) {
+ diffHorizontalActiveTargetRef.current = null;
+ setDiffHorizontalScrollbar((current) =>
+ current.visible
+ ? { visible: false, thumbWidth: 0, thumbLeft: 0, maxScrollLeft: 0, scrollLeft: 0 }
+ : current,
+ );
+ return;
+ }
+
+ diffHorizontalActiveTargetRef.current = target;
+ const maxScrollLeft = getDiffHorizontalScrollOverflow(target);
+ if (maxScrollLeft <= 0 || target.scrollWidth <= 0) {
+ setDiffHorizontalScrollbar((current) =>
+ current.visible
+ ? { visible: false, thumbWidth: 0, thumbLeft: 0, maxScrollLeft: 0, scrollLeft: 0 }
+ : current,
+ );
+ return;
+ }
+
+ const thumbWidth = Math.max(
+ DIFF_HORIZONTAL_SCROLLBAR_MIN_THUMB_PX,
+ Math.min(trackWidth, (target.clientWidth / target.scrollWidth) * trackWidth),
+ );
+ const travelWidth = Math.max(1, trackWidth - thumbWidth);
+ const thumbLeft = (target.scrollLeft / maxScrollLeft) * travelWidth;
+ setDiffHorizontalScrollbar((current) => {
+ if (
+ current.visible &&
+ Math.abs(current.thumbWidth - thumbWidth) < 0.5 &&
+ Math.abs(current.thumbLeft - thumbLeft) < 0.5 &&
+ Math.abs(current.maxScrollLeft - maxScrollLeft) < 0.5 &&
+ Math.abs(current.scrollLeft - target.scrollLeft) < 0.5
+ ) {
+ return current;
+ }
+ return {
+ visible: true,
+ thumbWidth,
+ thumbLeft,
+ maxScrollLeft,
+ scrollLeft: target.scrollLeft,
+ };
+ });
+ }, []);
+
+ const setDiffHorizontalScrollRatio = useCallback(
+ (ratio: number) => {
+ const nextRatio = Math.min(1, Math.max(0, ratio));
+ for (const target of diffHorizontalScrollTargetsRef.current) {
+ const maxScrollLeft = getDiffHorizontalScrollOverflow(target);
+ if (maxScrollLeft <= 0) continue;
+ target.scrollLeft = Math.min(maxScrollLeft, Math.max(0, nextRatio * maxScrollLeft));
+ }
+ updateDiffHorizontalScrollbar();
+ },
+ [updateDiffHorizontalScrollbar],
+ );
+
+ useLayoutEffect(() => {
+ const root = rootRef.current;
+ if (!root) return;
+
+ let animationFrame: number | null = null;
+ let targets: HTMLElement[] = [];
+ const scheduleUpdate = () => {
+ if (animationFrame !== null) return;
+ animationFrame = window.requestAnimationFrame(() => {
+ animationFrame = null;
+ updateDiffHorizontalScrollbar();
+ });
+ };
+ const handleTargetScroll = (event: Event) => {
+ if (event.currentTarget instanceof HTMLElement) {
+ diffHorizontalActiveTargetRef.current = event.currentTarget;
+ }
+ scheduleUpdate();
+ };
+ const resizeObserver =
+ typeof ResizeObserver === "undefined"
+ ? null
+ : new ResizeObserver(() => {
+ scheduleUpdate();
+ });
+
+ const detachTargets = () => {
+ for (const target of targets) {
+ target.removeEventListener("scroll", handleTargetScroll);
+ resizeObserver?.unobserve(target);
+ }
+ };
+ const attachTargets = (nextTargets: HTMLElement[]) => {
+ for (const target of nextTargets) {
+ target.addEventListener("scroll", handleTargetScroll, { passive: true });
+ resizeObserver?.observe(target);
+ }
+ };
+ const refreshTargets = () => {
+ detachTargets();
+ targets = resolveDiffHorizontalScrollTargets(root, scrollViewportRef.current);
+ diffHorizontalScrollTargetsRef.current = targets;
+ diffHorizontalActiveTargetRef.current = chooseDiffHorizontalScrollTarget(
+ targets,
+ diffHorizontalActiveTargetRef.current,
+ );
+ attachTargets(targets);
+ scheduleUpdate();
+ };
+
+ resizeObserver?.observe(root);
+ if (scrollViewportRef.current) {
+ resizeObserver?.observe(scrollViewportRef.current);
+ }
+ const mutationObserver =
+ typeof MutationObserver === "undefined"
+ ? null
+ : new MutationObserver(() => {
+ refreshTargets();
+ });
+ mutationObserver?.observe(root, { childList: true, subtree: true });
+ window.addEventListener("resize", refreshTargets);
+ refreshTargets();
+
+ return () => {
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ }
+ window.removeEventListener("resize", refreshTargets);
+ mutationObserver?.disconnect();
+ detachTargets();
+ resizeObserver?.disconnect();
+ diffHorizontalScrollTargetsRef.current = [];
+ diffHorizontalActiveTargetRef.current = null;
+ };
+ }, [diff?.patch, error, loading, patchChunks.length, updateDiffHorizontalScrollbar]);
+
+ const handleDiffHorizontalScrollbarPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.button !== 0 || !diffHorizontalScrollbar.visible) return;
+ const track = diffHorizontalScrollbarTrackRef.current;
+ if (!track) return;
+ const target = chooseDiffHorizontalScrollTarget(
+ diffHorizontalScrollTargetsRef.current,
+ diffHorizontalActiveTargetRef.current,
+ );
+ if (!target) return;
+
+ const maxScrollLeft = getDiffHorizontalScrollOverflow(target);
+ if (maxScrollLeft <= 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ diffHorizontalActiveTargetRef.current = target;
+
+ const rect = track.getBoundingClientRect();
+ const thumbWidth = Math.max(
+ DIFF_HORIZONTAL_SCROLLBAR_MIN_THUMB_PX,
+ Math.min(rect.width, (target.clientWidth / target.scrollWidth) * rect.width),
+ );
+ const travelWidth = Math.max(1, rect.width - thumbWidth);
+ const clickedThumb =
+ event.target instanceof HTMLElement &&
+ event.target.closest(".git-review-diff-horizontal-scrollbar-thumb") !== null;
+ const pointerStartX = event.clientX;
+ const scrollStart = clickedThumb
+ ? target.scrollLeft
+ : Math.min(
+ maxScrollLeft,
+ Math.max(
+ 0,
+ ((event.clientX - rect.left - thumbWidth / 2) / travelWidth) * maxScrollLeft,
+ ),
+ );
+ setDiffHorizontalScrollRatio(scrollStart / maxScrollLeft);
+
+ let cleanup = () => {};
+ const handleMove = (moveEvent: PointerEvent) => {
+ if ((moveEvent.buttons & 1) === 0) {
+ cleanup();
+ return;
+ }
+ const nextScrollLeft = Math.min(
+ maxScrollLeft,
+ Math.max(
+ 0,
+ scrollStart + ((moveEvent.clientX - pointerStartX) / travelWidth) * maxScrollLeft,
+ ),
+ );
+ setDiffHorizontalScrollRatio(nextScrollLeft / maxScrollLeft);
+ };
+ cleanup = () => {
+ window.removeEventListener("pointermove", handleMove, true);
+ window.removeEventListener("pointerup", cleanup, true);
+ window.removeEventListener("pointercancel", cleanup, true);
+ window.removeEventListener("blur", cleanup);
+ };
+
+ window.addEventListener("pointermove", handleMove, true);
+ window.addEventListener("pointerup", cleanup, true);
+ window.addEventListener("pointercancel", cleanup, true);
+ window.addEventListener("blur", cleanup);
+ },
+ [diffHorizontalScrollbar.visible, setDiffHorizontalScrollRatio],
+ );
+
+ const runSelectionAutoscroll = useCallback(() => {
+ selectionAutoscrollFrameRef.current = null;
+ const viewports = selectionAutoscrollViewportsRef.current;
+ const pointer = selectionAutoscrollPointerRef.current;
+ if (viewports.length === 0 || !pointer) return;
+
+ let verticalScrolled = false;
+ let horizontalScrolled = false;
+ for (const viewport of viewports) {
+ if (!viewport.isConnected) continue;
+ if (
+ !verticalScrolled &&
+ scrollDiffSelectionViewportForPointer(viewport, pointer.x, pointer.y, "vertical")
+ ) {
+ verticalScrolled = true;
+ syncGitReviewAutoscrollScrollbar(viewport);
+ }
+ if (
+ !horizontalScrolled &&
+ scrollDiffSelectionViewportForPointer(viewport, pointer.x, pointer.y, "horizontal")
+ ) {
+ horizontalScrolled = true;
+ syncGitReviewAutoscrollScrollbar(viewport);
+ }
+ if (verticalScrolled && horizontalScrolled) break;
+ }
+
+ selectionAutoscrollFrameRef.current = window.requestAnimationFrame(runSelectionAutoscroll);
+ }, []);
+
+ const requestSelectionAutoscroll = useCallback(() => {
+ if (selectionAutoscrollFrameRef.current !== null) return;
+ selectionAutoscrollFrameRef.current = window.requestAnimationFrame(runSelectionAutoscroll);
+ }, [runSelectionAutoscroll]);
+
+ const stopSelectionAutoscroll = useCallback(() => {
+ if (selectionAutoscrollFrameRef.current !== null) {
+ window.cancelAnimationFrame(selectionAutoscrollFrameRef.current);
+ selectionAutoscrollFrameRef.current = null;
+ }
+ selectionAutoscrollViewportsRef.current = [];
+ selectionAutoscrollPointerRef.current = null;
+ }, []);
+
+ useEffect(() => stopSelectionAutoscroll, [stopSelectionAutoscroll]);
+
+ useEffect(() => {
+ closeSelectionContextMenu();
+ }, [closeSelectionContextMenu, diff?.patch, error, loading]);
+
+ useEffect(() => {
+ if (!selectionContextMenu) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof Node)) {
+ closeSelectionContextMenu();
+ return;
+ }
+ if (contextMenuRef.current?.contains(target)) {
+ return;
+ }
+ closeSelectionContextMenu();
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ closeSelectionContextMenu();
+ }
+ };
+
+ const handleSelectionChange = () => {
+ if (!resolveContainedSelectionText(rootRef.current)) {
+ closeSelectionContextMenu();
+ }
+ };
+
+ const handleViewportChange = () => {
+ closeSelectionContextMenu();
+ };
+
+ window.addEventListener("pointerdown", handlePointerDown, true);
+ window.addEventListener("keydown", handleKeyDown, true);
+ window.addEventListener("scroll", handleViewportChange, true);
+ window.addEventListener("resize", handleViewportChange);
+ window.addEventListener("blur", handleViewportChange);
+ document.addEventListener("selectionchange", handleSelectionChange);
+
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown, true);
+ window.removeEventListener("keydown", handleKeyDown, true);
+ window.removeEventListener("scroll", handleViewportChange, true);
+ window.removeEventListener("resize", handleViewportChange);
+ window.removeEventListener("blur", handleViewportChange);
+ document.removeEventListener("selectionchange", handleSelectionChange);
+ };
+ }, [closeSelectionContextMenu, selectionContextMenu]);
+
+ const handleContextMenu = useCallback(
+ (event: ReactMouseEvent) => {
+ if (!isDiffSelectableContentTarget(rootRef.current, event.target)) {
+ closeSelectionContextMenu();
+ return;
+ }
+ const selectedText = resolveContainedSelectionText(rootRef.current);
+ if (!selectedText) {
+ closeSelectionContextMenu();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ setSelectionContextMenu({
+ x: event.clientX,
+ y: event.clientY,
+ selectedText,
+ });
+ },
+ [closeSelectionContextMenu],
+ );
+
+ const handleSelectionPointerDownCapture = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.button !== 0) return;
+ if (!isDiffSelectableContentTarget(rootRef.current, event.target)) return;
+
+ const target = event.target instanceof Element ? event.target : null;
+ const viewports = resolveDiffSelectionScrollViewports(
+ target,
+ rootRef.current,
+ scrollViewportRef.current,
+ );
+ if (viewports.length === 0) return;
+
+ closeSelectionContextMenu();
+ selectionAutoscrollViewportsRef.current = viewports;
+ selectionAutoscrollPointerRef.current = { x: event.clientX, y: event.clientY };
+ requestSelectionAutoscroll();
+
+ let cleanup = () => {};
+ const handleMove = (moveEvent: PointerEvent) => {
+ if ((moveEvent.buttons & 1) === 0) {
+ cleanup();
+ return;
+ }
+ selectionAutoscrollPointerRef.current = {
+ x: moveEvent.clientX,
+ y: moveEvent.clientY,
+ };
+ };
+ cleanup = () => {
+ stopSelectionAutoscroll();
+ window.removeEventListener("pointermove", handleMove, true);
+ window.removeEventListener("pointerup", cleanup, true);
+ window.removeEventListener("pointercancel", cleanup, true);
+ window.removeEventListener("blur", cleanup);
+ };
+
+ window.addEventListener("pointermove", handleMove, true);
+ window.addEventListener("pointerup", cleanup, true);
+ window.addEventListener("pointercancel", cleanup, true);
+ window.addEventListener("blur", cleanup);
+ },
+ [closeSelectionContextMenu, requestSelectionAutoscroll, stopSelectionAutoscroll],
+ );
+
+ const selectionContextMenuPosition = selectionContextMenu
+ ? clampDiffSelectionContextMenuPosition(selectionContextMenu.x, selectionContextMenu.y)
+ : null;
+ const copySelectedTextLabel =
+ locale === "en-US" ? "Copy selected text" : "复制选中文本";
return (
-
+
{error ?
{error}
: null}
{!error && showDiffStat ?
: null}
{showLoadingState ? (
@@ -1181,7 +1769,13 @@ function DiffContent(props: {
) : null}
{!error && !showLoadingState && patchChunks.length > 0 ? (
{
+ scrollViewportRef.current = node;
+ }}
+ className={cn(
+ GIT_REVIEW_TRANSIENT_SCROLLBAR_CLASS,
+ "git-review-diff-selectable-content min-h-0 flex-1 select-text overflow-auto",
+ )}
onScroll={handleGitReviewTransientScroll}
>
{patchChunks.map((item) => (
@@ -1191,9 +1785,12 @@ function DiffContent(props: {
) : null}
{!error && !showLoadingState && diff?.patch.trim() && patchChunks.length === 0 ? (
{
+ scrollViewportRef.current = node;
+ }}
className={cn(
GIT_REVIEW_TRANSIENT_SCROLLBAR_CLASS,
- "min-h-0 flex-1 overflow-auto px-3 py-3 text-[11px] leading-relaxed text-muted-foreground",
+ "git-review-diff-selectable-content min-h-0 flex-1 select-text overflow-auto px-3 py-3 text-[11px] leading-relaxed text-muted-foreground",
)}
onScroll={handleGitReviewTransientScroll}
>
@@ -1210,6 +1807,60 @@ function DiffContent(props: {
{t("projectTools.gitReview.diffOutputTruncated")}
) : null}
+ {diffHorizontalScrollbar.visible ? (
+
+ ) : null}
+ {selectionContextMenu && selectionContextMenuPosition
+ ? createPortal(
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+
+
,
+ document.body,
+ )
+ : null}
);
}
@@ -1879,8 +2530,8 @@ function revealTargetForEntry(entry: GitStatusEntry) {
}
function writeTextToClipboard(text: string) {
- const value = text.trim();
- if (!value) return;
+ if (!text.trim()) return;
+ const value = text;
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(value).catch(() => {
fallbackWriteTextToClipboard(value);
@@ -1903,6 +2554,63 @@ function fallbackWriteTextToClipboard(text: string) {
document.body.removeChild(textarea);
}
+function elementForSelectionNode(node: Node) {
+ return node instanceof Element ? node : node.parentElement;
+}
+
+function isDiffSelectableContentNode(root: HTMLElement | null, node: Node) {
+ const element = elementForSelectionNode(node);
+ const selectable = element?.closest(".git-review-diff-selectable-content");
+ return Boolean(root && selectable && root.contains(selectable));
+}
+
+function isDiffSelectableContentTarget(root: HTMLElement | null, target: EventTarget | null) {
+ if (!(target instanceof Node)) return false;
+ return isDiffSelectableContentNode(root, target);
+}
+
+function resolveContainedSelectionText(root: HTMLElement | null) {
+ if (!root) return "";
+
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
+ return "";
+ }
+
+ const selectedText = selection.toString();
+ if (!selectedText.trim()) return "";
+
+ const range = selection.getRangeAt(0);
+ if (
+ !isDiffSelectableContentNode(root, range.startContainer) ||
+ !isDiffSelectableContentNode(root, range.endContainer)
+ ) {
+ return "";
+ }
+
+ return selectedText;
+}
+
+function clampDiffSelectionContextMenuPosition(x: number, y: number) {
+ const maxLeft = Math.max(
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ window.innerWidth -
+ DIFF_SELECTION_CONTEXT_MENU_WIDTH -
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ );
+ const maxTop = Math.max(
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ window.innerHeight -
+ DIFF_SELECTION_CONTEXT_MENU_HEIGHT -
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ );
+
+ return {
+ left: Math.min(Math.max(DIFF_SELECTION_CONTEXT_MENU_MARGIN, x), maxLeft),
+ top: Math.min(Math.max(DIFF_SELECTION_CONTEXT_MENU_MARGIN, y), maxTop),
+ };
+}
+
function gitRepositoryStateSignature(state: GitRepositoryState) {
const dirty = state.dirtyCounts;
const header = [
diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css
index 4892ac0f..ed8862bf 100644
--- a/crates/agent-gateway/web/src/index.css
+++ b/crates/agent-gateway/web/src/index.css
@@ -315,12 +315,12 @@
background-color: hsl(var(--muted-foreground) / 0.52);
}
.git-review-floating-scrollbar-vertical {
- width: 4px;
- min-height: 28px;
+ width: 3px;
+ min-height: 24px;
}
.git-review-floating-scrollbar-horizontal {
- height: 4px;
- min-width: 28px;
+ height: 3px;
+ min-width: 24px;
}
.git-review-history-row {
@@ -1702,3 +1702,21 @@ body > div:not(#root) [data-streamdown="mermaid"] svg {
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
+
+/* Git review diff views live under app chrome that disables selection by default. */
+.git-review-diff-selectable-content,
+.git-review-diff-selectable-content pre,
+.git-review-diff-selectable-content .diff-tailwindcss-wrapper,
+.git-review-diff-selectable-content .diff-line-content,
+.git-review-diff-selectable-content .diff-line-content-item,
+.git-review-diff-selectable-content .diff-line-content-raw,
+.git-review-diff-selectable-content .diff-line-syntax-raw,
+.git-review-diff-selectable-content .diff-line-content-raw *,
+.git-review-diff-selectable-content .diff-line-syntax-raw * {
+ -webkit-user-select: text;
+ user-select: text;
+}
+.git-review-diff-selectable-content .select-none {
+ -webkit-user-select: none;
+ user-select: none;
+}
diff --git a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx
index 5e7c73c4..1bbb44fa 100644
--- a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx
+++ b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx
@@ -6,6 +6,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import {
memo,
type MouseEvent as ReactMouseEvent,
+ type PointerEvent as ReactPointerEvent,
type UIEvent as ReactUIEvent,
useCallback,
useEffect,
@@ -75,6 +76,9 @@ const GIT_REVIEW_SPLIT_GRID_CLASS =
"grid-cols-[clamp(9.5rem,38%,18rem)_minmax(10rem,1fr)] grid-rows-1";
const GIT_REVIEW_STACKED_PANE_BUTTON_CLASS =
"inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
+const DIFF_SELECTION_AUTOSCROLL_EDGE_PX = 40;
+const DIFF_SELECTION_AUTOSCROLL_MAX_STEP_PX = 22;
+const DIFF_HORIZONTAL_SCROLLBAR_MIN_THUMB_PX = 32;
const gitReviewScrollbarTimers = new WeakMap
();
type GitReviewScrollbarAxis = "vertical" | "horizontal";
type GitReviewScrollbarOverlay = {
@@ -333,6 +337,152 @@ function handleGitReviewTransientScroll(event: ReactUIEvent) {
scheduleGitReviewScrollbarHide(element);
}
+function diffSelectionAutoScrollDelta(
+ pointer: number,
+ start: number,
+ end: number,
+ canScroll: boolean,
+) {
+ if (!canScroll) return 0;
+ if (pointer < start + DIFF_SELECTION_AUTOSCROLL_EDGE_PX) {
+ const ratio = Math.min(
+ 1,
+ (start + DIFF_SELECTION_AUTOSCROLL_EDGE_PX - pointer) /
+ DIFF_SELECTION_AUTOSCROLL_EDGE_PX,
+ );
+ return -Math.max(2, Math.round(ratio * DIFF_SELECTION_AUTOSCROLL_MAX_STEP_PX));
+ }
+ if (pointer > end - DIFF_SELECTION_AUTOSCROLL_EDGE_PX) {
+ const ratio = Math.min(
+ 1,
+ (pointer - (end - DIFF_SELECTION_AUTOSCROLL_EDGE_PX)) /
+ DIFF_SELECTION_AUTOSCROLL_EDGE_PX,
+ );
+ return Math.max(2, Math.round(ratio * DIFF_SELECTION_AUTOSCROLL_MAX_STEP_PX));
+ }
+ return 0;
+}
+
+type DiffSelectionScrollAxis = "vertical" | "horizontal";
+
+function scrollDiffSelectionViewportForPointer(
+ viewport: HTMLElement,
+ clientX: number,
+ clientY: number,
+ axis: DiffSelectionScrollAxis,
+) {
+ const rect = viewport.getBoundingClientRect();
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ const maxScrollLeft = viewport.scrollWidth - viewport.clientWidth;
+
+ if (axis === "vertical") {
+ const topDelta = diffSelectionAutoScrollDelta(clientY, rect.top, rect.bottom, maxScrollTop > 0);
+ if (topDelta === 0) return false;
+ const previousTop = viewport.scrollTop;
+ viewport.scrollTop = Math.min(maxScrollTop, Math.max(0, previousTop + topDelta));
+ return viewport.scrollTop !== previousTop;
+ }
+
+ const leftDelta = diffSelectionAutoScrollDelta(clientX, rect.left, rect.right, maxScrollLeft > 0);
+ if (leftDelta === 0) return false;
+ const previousLeft = viewport.scrollLeft;
+ viewport.scrollLeft = Math.min(maxScrollLeft, Math.max(0, previousLeft + leftDelta));
+ return viewport.scrollLeft !== previousLeft;
+}
+
+function isScrollableDiffSelectionElement(element: HTMLElement) {
+ const style = window.getComputedStyle(element);
+ const canScrollY =
+ /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1;
+ const canScrollX =
+ /(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1;
+ return canScrollY || canScrollX;
+}
+
+function syncGitReviewAutoscrollScrollbar(viewport: HTMLElement) {
+ if (!viewport.classList.contains(GIT_REVIEW_TRANSIENT_SCROLLBAR_CLASS)) return;
+ viewport.dataset.scrollActive = "true";
+ updateGitReviewScrollbarOverlay(viewport);
+ scheduleGitReviewScrollbarHide(viewport);
+}
+
+function resolveDiffSelectionScrollViewports(
+ target: Element | null,
+ root: HTMLElement | null,
+ fallback: HTMLElement | null,
+) {
+ const viewports: HTMLElement[] = [];
+ const addViewport = (element: HTMLElement | null) => {
+ if (!element || viewports.includes(element) || !isScrollableDiffSelectionElement(element)) {
+ return;
+ }
+ viewports.push(element);
+ };
+
+ if (!target || !root) {
+ addViewport(fallback);
+ return viewports;
+ }
+
+ let current: HTMLElement | null =
+ target instanceof HTMLElement ? target : target.parentElement;
+ while (current && current !== root) {
+ addViewport(current);
+ current = current.parentElement;
+ }
+ addViewport(fallback);
+ return viewports;
+}
+
+function getDiffHorizontalScrollOverflow(element: HTMLElement) {
+ return Math.max(0, element.scrollWidth - element.clientWidth);
+}
+
+function isDiffHorizontalScrollableElement(element: HTMLElement) {
+ return getDiffHorizontalScrollOverflow(element) > 0;
+}
+
+function resolveDiffHorizontalScrollTargets(
+ root: HTMLElement | null,
+ fallback: HTMLElement | null,
+) {
+ const targets: HTMLElement[] = [];
+ const addTarget = (element: HTMLElement | null) => {
+ if (!element || targets.includes(element) || !isDiffHorizontalScrollableElement(element)) {
+ return;
+ }
+ targets.push(element);
+ };
+
+ if (fallback) {
+ addTarget(fallback);
+ }
+ if (!root) return targets;
+
+ root.querySelectorAll(".diff-table-scroll-container").forEach(addTarget);
+ return targets;
+}
+
+function chooseDiffHorizontalScrollTarget(
+ targets: HTMLElement[],
+ preferred: HTMLElement | null,
+) {
+ if (preferred && targets.includes(preferred) && isDiffHorizontalScrollableElement(preferred)) {
+ return preferred;
+ }
+
+ let bestTarget: HTMLElement | null = null;
+ let bestOverflow = 0;
+ for (const target of targets) {
+ const overflow = getDiffHorizontalScrollOverflow(target);
+ if (overflow > bestOverflow) {
+ bestOverflow = overflow;
+ bestTarget = target;
+ }
+ }
+ return bestTarget;
+}
+
type PatchChunk = {
key: string;
label: string;
@@ -406,6 +556,20 @@ type HistoryContextMenuState =
path: string;
};
+type DiffSelectionContextMenuState = {
+ x: number;
+ y: number;
+ selectedText: string;
+};
+
+type DiffHorizontalScrollbarState = {
+ visible: boolean;
+ thumbWidth: number;
+ thumbLeft: number;
+ maxScrollLeft: number;
+ scrollLeft: number;
+};
+
type ChangesMenuState = {
x: number;
y: number;
@@ -435,6 +599,9 @@ const CHANGES_MENU_HEIGHT = 170;
const HISTORY_CONTEXT_MENU_WIDTH = 232;
const HISTORY_CONTEXT_MENU_HEIGHT = 270;
const HISTORY_FILE_CONTEXT_MENU_HEIGHT = 90;
+const DIFF_SELECTION_CONTEXT_MENU_WIDTH = 184;
+const DIFF_SELECTION_CONTEXT_MENU_HEIGHT = 52;
+const DIFF_SELECTION_CONTEXT_MENU_MARGIN = 12;
const GIT_HISTORY_PAGE_SIZE = 50;
const GIT_HISTORY_LOAD_MORE_SCROLL_THRESHOLD_PX = 96;
const CHANGE_CONTEXT_MENU_ITEM_CLASS =
@@ -1018,7 +1185,7 @@ const DiffChunkView = memo(function DiffChunkView(props: { item: PatchChunk; isD
return (
-
+
{item.label}
{item.large ? (
@@ -1039,7 +1206,7 @@ const DiffChunkView = memo(function DiffChunkView(props: { item: PatchChunk; isD
@@ -1165,17 +1332,438 @@ function DiffContent(props: {
showStat?: boolean;
}) {
const { diff, title, error, loading = false, showStat = true } = props;
- const { t } = useLocale();
+ const { locale, t } = useLocale();
const isDark = useIsDark();
+ const rootRef = useRef(null);
+ const scrollViewportRef = useRef(null);
+ const contextMenuRef = useRef(null);
+ const selectionAutoscrollViewportsRef = useRef([]);
+ const selectionAutoscrollPointerRef = useRef<{
+ x: number;
+ y: number;
+ } | null>(null);
+ const selectionAutoscrollFrameRef = useRef(null);
+ const diffHorizontalScrollbarTrackRef = useRef(null);
+ const diffHorizontalScrollTargetsRef = useRef([]);
+ const diffHorizontalActiveTargetRef = useRef(null);
+ const [selectionContextMenu, setSelectionContextMenu] =
+ useState(null);
+ const [diffHorizontalScrollbar, setDiffHorizontalScrollbar] =
+ useState({
+ visible: false,
+ thumbWidth: 0,
+ thumbLeft: 0,
+ maxScrollLeft: 0,
+ scrollLeft: 0,
+ });
const patchChunks = useMemo(
() => buildPatchChunks(diff?.patch ?? "", title),
[diff?.patch, title],
);
const showLoadingState = loading && !error && !diff;
const showDiffStat = showStat && Boolean(diff?.stat);
+ const closeSelectionContextMenu = useCallback(() => {
+ setSelectionContextMenu(null);
+ }, []);
+
+ const updateDiffHorizontalScrollbar = useCallback(() => {
+ const root = rootRef.current;
+ const trackWidth =
+ diffHorizontalScrollbarTrackRef.current?.clientWidth ??
+ scrollViewportRef.current?.clientWidth ??
+ root?.clientWidth ??
+ 0;
+ const target = chooseDiffHorizontalScrollTarget(
+ diffHorizontalScrollTargetsRef.current,
+ diffHorizontalActiveTargetRef.current,
+ );
+
+ if (!target || trackWidth <= 0) {
+ diffHorizontalActiveTargetRef.current = null;
+ setDiffHorizontalScrollbar((current) =>
+ current.visible
+ ? { visible: false, thumbWidth: 0, thumbLeft: 0, maxScrollLeft: 0, scrollLeft: 0 }
+ : current,
+ );
+ return;
+ }
+
+ diffHorizontalActiveTargetRef.current = target;
+ const maxScrollLeft = getDiffHorizontalScrollOverflow(target);
+ if (maxScrollLeft <= 0 || target.scrollWidth <= 0) {
+ setDiffHorizontalScrollbar((current) =>
+ current.visible
+ ? { visible: false, thumbWidth: 0, thumbLeft: 0, maxScrollLeft: 0, scrollLeft: 0 }
+ : current,
+ );
+ return;
+ }
+
+ const thumbWidth = Math.max(
+ DIFF_HORIZONTAL_SCROLLBAR_MIN_THUMB_PX,
+ Math.min(trackWidth, (target.clientWidth / target.scrollWidth) * trackWidth),
+ );
+ const travelWidth = Math.max(1, trackWidth - thumbWidth);
+ const thumbLeft = (target.scrollLeft / maxScrollLeft) * travelWidth;
+ setDiffHorizontalScrollbar((current) => {
+ if (
+ current.visible &&
+ Math.abs(current.thumbWidth - thumbWidth) < 0.5 &&
+ Math.abs(current.thumbLeft - thumbLeft) < 0.5 &&
+ Math.abs(current.maxScrollLeft - maxScrollLeft) < 0.5 &&
+ Math.abs(current.scrollLeft - target.scrollLeft) < 0.5
+ ) {
+ return current;
+ }
+ return {
+ visible: true,
+ thumbWidth,
+ thumbLeft,
+ maxScrollLeft,
+ scrollLeft: target.scrollLeft,
+ };
+ });
+ }, []);
+
+ const setDiffHorizontalScrollRatio = useCallback(
+ (ratio: number) => {
+ const nextRatio = Math.min(1, Math.max(0, ratio));
+ for (const target of diffHorizontalScrollTargetsRef.current) {
+ const maxScrollLeft = getDiffHorizontalScrollOverflow(target);
+ if (maxScrollLeft <= 0) continue;
+ target.scrollLeft = Math.min(maxScrollLeft, Math.max(0, nextRatio * maxScrollLeft));
+ }
+ updateDiffHorizontalScrollbar();
+ },
+ [updateDiffHorizontalScrollbar],
+ );
+
+ useLayoutEffect(() => {
+ const root = rootRef.current;
+ if (!root) return;
+
+ let animationFrame: number | null = null;
+ let targets: HTMLElement[] = [];
+ const scheduleUpdate = () => {
+ if (animationFrame !== null) return;
+ animationFrame = window.requestAnimationFrame(() => {
+ animationFrame = null;
+ updateDiffHorizontalScrollbar();
+ });
+ };
+ const handleTargetScroll = (event: Event) => {
+ if (event.currentTarget instanceof HTMLElement) {
+ diffHorizontalActiveTargetRef.current = event.currentTarget;
+ }
+ scheduleUpdate();
+ };
+ const resizeObserver =
+ typeof ResizeObserver === "undefined"
+ ? null
+ : new ResizeObserver(() => {
+ scheduleUpdate();
+ });
+
+ const detachTargets = () => {
+ for (const target of targets) {
+ target.removeEventListener("scroll", handleTargetScroll);
+ resizeObserver?.unobserve(target);
+ }
+ };
+ const attachTargets = (nextTargets: HTMLElement[]) => {
+ for (const target of nextTargets) {
+ target.addEventListener("scroll", handleTargetScroll, { passive: true });
+ resizeObserver?.observe(target);
+ }
+ };
+ const refreshTargets = () => {
+ detachTargets();
+ targets = resolveDiffHorizontalScrollTargets(root, scrollViewportRef.current);
+ diffHorizontalScrollTargetsRef.current = targets;
+ diffHorizontalActiveTargetRef.current = chooseDiffHorizontalScrollTarget(
+ targets,
+ diffHorizontalActiveTargetRef.current,
+ );
+ attachTargets(targets);
+ scheduleUpdate();
+ };
+
+ resizeObserver?.observe(root);
+ if (scrollViewportRef.current) {
+ resizeObserver?.observe(scrollViewportRef.current);
+ }
+ const mutationObserver =
+ typeof MutationObserver === "undefined"
+ ? null
+ : new MutationObserver(() => {
+ refreshTargets();
+ });
+ mutationObserver?.observe(root, { childList: true, subtree: true });
+ window.addEventListener("resize", refreshTargets);
+ refreshTargets();
+
+ return () => {
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ }
+ window.removeEventListener("resize", refreshTargets);
+ mutationObserver?.disconnect();
+ detachTargets();
+ resizeObserver?.disconnect();
+ diffHorizontalScrollTargetsRef.current = [];
+ diffHorizontalActiveTargetRef.current = null;
+ };
+ }, [diff?.patch, error, loading, patchChunks.length, updateDiffHorizontalScrollbar]);
+
+ const handleDiffHorizontalScrollbarPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.button !== 0 || !diffHorizontalScrollbar.visible) return;
+ const track = diffHorizontalScrollbarTrackRef.current;
+ if (!track) return;
+ const target = chooseDiffHorizontalScrollTarget(
+ diffHorizontalScrollTargetsRef.current,
+ diffHorizontalActiveTargetRef.current,
+ );
+ if (!target) return;
+
+ const maxScrollLeft = getDiffHorizontalScrollOverflow(target);
+ if (maxScrollLeft <= 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ diffHorizontalActiveTargetRef.current = target;
+
+ const rect = track.getBoundingClientRect();
+ const thumbWidth = Math.max(
+ DIFF_HORIZONTAL_SCROLLBAR_MIN_THUMB_PX,
+ Math.min(rect.width, (target.clientWidth / target.scrollWidth) * rect.width),
+ );
+ const travelWidth = Math.max(1, rect.width - thumbWidth);
+ const clickedThumb =
+ event.target instanceof HTMLElement &&
+ event.target.closest(".git-review-diff-horizontal-scrollbar-thumb") !== null;
+ const pointerStartX = event.clientX;
+ const scrollStart = clickedThumb
+ ? target.scrollLeft
+ : Math.min(
+ maxScrollLeft,
+ Math.max(
+ 0,
+ ((event.clientX - rect.left - thumbWidth / 2) / travelWidth) * maxScrollLeft,
+ ),
+ );
+ setDiffHorizontalScrollRatio(scrollStart / maxScrollLeft);
+
+ let cleanup = () => {};
+ const handleMove = (moveEvent: PointerEvent) => {
+ if ((moveEvent.buttons & 1) === 0) {
+ cleanup();
+ return;
+ }
+ const nextScrollLeft = Math.min(
+ maxScrollLeft,
+ Math.max(
+ 0,
+ scrollStart + ((moveEvent.clientX - pointerStartX) / travelWidth) * maxScrollLeft,
+ ),
+ );
+ setDiffHorizontalScrollRatio(nextScrollLeft / maxScrollLeft);
+ };
+ cleanup = () => {
+ window.removeEventListener("pointermove", handleMove, true);
+ window.removeEventListener("pointerup", cleanup, true);
+ window.removeEventListener("pointercancel", cleanup, true);
+ window.removeEventListener("blur", cleanup);
+ };
+
+ window.addEventListener("pointermove", handleMove, true);
+ window.addEventListener("pointerup", cleanup, true);
+ window.addEventListener("pointercancel", cleanup, true);
+ window.addEventListener("blur", cleanup);
+ },
+ [diffHorizontalScrollbar.visible, setDiffHorizontalScrollRatio],
+ );
+
+ const runSelectionAutoscroll = useCallback(() => {
+ selectionAutoscrollFrameRef.current = null;
+ const viewports = selectionAutoscrollViewportsRef.current;
+ const pointer = selectionAutoscrollPointerRef.current;
+ if (viewports.length === 0 || !pointer) return;
+
+ let verticalScrolled = false;
+ let horizontalScrolled = false;
+ for (const viewport of viewports) {
+ if (!viewport.isConnected) continue;
+ if (
+ !verticalScrolled &&
+ scrollDiffSelectionViewportForPointer(viewport, pointer.x, pointer.y, "vertical")
+ ) {
+ verticalScrolled = true;
+ syncGitReviewAutoscrollScrollbar(viewport);
+ }
+ if (
+ !horizontalScrolled &&
+ scrollDiffSelectionViewportForPointer(viewport, pointer.x, pointer.y, "horizontal")
+ ) {
+ horizontalScrolled = true;
+ syncGitReviewAutoscrollScrollbar(viewport);
+ }
+ if (verticalScrolled && horizontalScrolled) break;
+ }
+
+ selectionAutoscrollFrameRef.current = window.requestAnimationFrame(runSelectionAutoscroll);
+ }, []);
+
+ const requestSelectionAutoscroll = useCallback(() => {
+ if (selectionAutoscrollFrameRef.current !== null) return;
+ selectionAutoscrollFrameRef.current = window.requestAnimationFrame(runSelectionAutoscroll);
+ }, [runSelectionAutoscroll]);
+
+ const stopSelectionAutoscroll = useCallback(() => {
+ if (selectionAutoscrollFrameRef.current !== null) {
+ window.cancelAnimationFrame(selectionAutoscrollFrameRef.current);
+ selectionAutoscrollFrameRef.current = null;
+ }
+ selectionAutoscrollViewportsRef.current = [];
+ selectionAutoscrollPointerRef.current = null;
+ }, []);
+
+ useEffect(() => stopSelectionAutoscroll, [stopSelectionAutoscroll]);
+
+ useEffect(() => {
+ closeSelectionContextMenu();
+ }, [closeSelectionContextMenu, diff?.patch, error, loading]);
+
+ useEffect(() => {
+ if (!selectionContextMenu) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof Node)) {
+ closeSelectionContextMenu();
+ return;
+ }
+ if (contextMenuRef.current?.contains(target)) {
+ return;
+ }
+ closeSelectionContextMenu();
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ closeSelectionContextMenu();
+ }
+ };
+
+ const handleSelectionChange = () => {
+ if (!resolveContainedSelectionText(rootRef.current)) {
+ closeSelectionContextMenu();
+ }
+ };
+
+ const handleViewportChange = () => {
+ closeSelectionContextMenu();
+ };
+
+ window.addEventListener("pointerdown", handlePointerDown, true);
+ window.addEventListener("keydown", handleKeyDown, true);
+ window.addEventListener("scroll", handleViewportChange, true);
+ window.addEventListener("resize", handleViewportChange);
+ window.addEventListener("blur", handleViewportChange);
+ document.addEventListener("selectionchange", handleSelectionChange);
+
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown, true);
+ window.removeEventListener("keydown", handleKeyDown, true);
+ window.removeEventListener("scroll", handleViewportChange, true);
+ window.removeEventListener("resize", handleViewportChange);
+ window.removeEventListener("blur", handleViewportChange);
+ document.removeEventListener("selectionchange", handleSelectionChange);
+ };
+ }, [closeSelectionContextMenu, selectionContextMenu]);
+
+ const handleContextMenu = useCallback(
+ (event: ReactMouseEvent) => {
+ if (!isDiffSelectableContentTarget(rootRef.current, event.target)) {
+ closeSelectionContextMenu();
+ return;
+ }
+ const selectedText = resolveContainedSelectionText(rootRef.current);
+ if (!selectedText) {
+ closeSelectionContextMenu();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ setSelectionContextMenu({
+ x: event.clientX,
+ y: event.clientY,
+ selectedText,
+ });
+ },
+ [closeSelectionContextMenu],
+ );
+
+ const handleSelectionPointerDownCapture = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.button !== 0) return;
+ if (!isDiffSelectableContentTarget(rootRef.current, event.target)) return;
+
+ const target = event.target instanceof Element ? event.target : null;
+ const viewports = resolveDiffSelectionScrollViewports(
+ target,
+ rootRef.current,
+ scrollViewportRef.current,
+ );
+ if (viewports.length === 0) return;
+
+ closeSelectionContextMenu();
+ selectionAutoscrollViewportsRef.current = viewports;
+ selectionAutoscrollPointerRef.current = { x: event.clientX, y: event.clientY };
+ requestSelectionAutoscroll();
+
+ let cleanup = () => {};
+ const handleMove = (moveEvent: PointerEvent) => {
+ if ((moveEvent.buttons & 1) === 0) {
+ cleanup();
+ return;
+ }
+ selectionAutoscrollPointerRef.current = {
+ x: moveEvent.clientX,
+ y: moveEvent.clientY,
+ };
+ };
+ cleanup = () => {
+ stopSelectionAutoscroll();
+ window.removeEventListener("pointermove", handleMove, true);
+ window.removeEventListener("pointerup", cleanup, true);
+ window.removeEventListener("pointercancel", cleanup, true);
+ window.removeEventListener("blur", cleanup);
+ };
+
+ window.addEventListener("pointermove", handleMove, true);
+ window.addEventListener("pointerup", cleanup, true);
+ window.addEventListener("pointercancel", cleanup, true);
+ window.addEventListener("blur", cleanup);
+ },
+ [closeSelectionContextMenu, requestSelectionAutoscroll, stopSelectionAutoscroll],
+ );
+
+ const selectionContextMenuPosition = selectionContextMenu
+ ? clampDiffSelectionContextMenuPosition(selectionContextMenu.x, selectionContextMenu.y)
+ : null;
+ const copySelectedTextLabel =
+ locale === "en-US" ? "Copy selected text" : "复制选中文本";
return (
-
+
{error ?
{error}
: null}
{!error && showDiffStat ?
: null}
{showLoadingState ? (
@@ -1186,7 +1774,13 @@ function DiffContent(props: {
) : null}
{!error && !showLoadingState && patchChunks.length > 0 ? (
{
+ scrollViewportRef.current = node;
+ }}
+ className={cn(
+ GIT_REVIEW_TRANSIENT_SCROLLBAR_CLASS,
+ "git-review-diff-selectable-content min-h-0 flex-1 select-text overflow-auto",
+ )}
onScroll={handleGitReviewTransientScroll}
>
{patchChunks.map((item) => (
@@ -1196,9 +1790,12 @@ function DiffContent(props: {
) : null}
{!error && !showLoadingState && diff?.patch.trim() && patchChunks.length === 0 ? (
{
+ scrollViewportRef.current = node;
+ }}
className={cn(
GIT_REVIEW_TRANSIENT_SCROLLBAR_CLASS,
- "min-h-0 flex-1 overflow-auto px-3 py-3 text-[11px] leading-relaxed text-muted-foreground",
+ "git-review-diff-selectable-content min-h-0 flex-1 select-text overflow-auto px-3 py-3 text-[11px] leading-relaxed text-muted-foreground",
)}
onScroll={handleGitReviewTransientScroll}
>
@@ -1215,6 +1812,60 @@ function DiffContent(props: {
{t("projectTools.gitReview.diffOutputTruncated")}
) : null}
+ {diffHorizontalScrollbar.visible ? (
+
+ ) : null}
+ {selectionContextMenu && selectionContextMenuPosition
+ ? createPortal(
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+
+
,
+ document.body,
+ )
+ : null}
);
}
@@ -1443,8 +2094,8 @@ function revealTargetForEntry(entry: GitStatusEntry) {
}
function writeTextToClipboard(text: string) {
- const value = text.trim();
- if (!value) return;
+ if (!text.trim()) return;
+ const value = text;
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(value).catch(() => {
fallbackWriteTextToClipboard(value);
@@ -1467,6 +2118,63 @@ function fallbackWriteTextToClipboard(text: string) {
document.body.removeChild(textarea);
}
+function elementForSelectionNode(node: Node) {
+ return node instanceof Element ? node : node.parentElement;
+}
+
+function isDiffSelectableContentNode(root: HTMLElement | null, node: Node) {
+ const element = elementForSelectionNode(node);
+ const selectable = element?.closest(".git-review-diff-selectable-content");
+ return Boolean(root && selectable && root.contains(selectable));
+}
+
+function isDiffSelectableContentTarget(root: HTMLElement | null, target: EventTarget | null) {
+ if (!(target instanceof Node)) return false;
+ return isDiffSelectableContentNode(root, target);
+}
+
+function resolveContainedSelectionText(root: HTMLElement | null) {
+ if (!root) return "";
+
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
+ return "";
+ }
+
+ const selectedText = selection.toString();
+ if (!selectedText.trim()) return "";
+
+ const range = selection.getRangeAt(0);
+ if (
+ !isDiffSelectableContentNode(root, range.startContainer) ||
+ !isDiffSelectableContentNode(root, range.endContainer)
+ ) {
+ return "";
+ }
+
+ return selectedText;
+}
+
+function clampDiffSelectionContextMenuPosition(x: number, y: number) {
+ const maxLeft = Math.max(
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ window.innerWidth -
+ DIFF_SELECTION_CONTEXT_MENU_WIDTH -
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ );
+ const maxTop = Math.max(
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ window.innerHeight -
+ DIFF_SELECTION_CONTEXT_MENU_HEIGHT -
+ DIFF_SELECTION_CONTEXT_MENU_MARGIN,
+ );
+
+ return {
+ left: Math.min(Math.max(DIFF_SELECTION_CONTEXT_MENU_MARGIN, x), maxLeft),
+ top: Math.min(Math.max(DIFF_SELECTION_CONTEXT_MENU_MARGIN, y), maxTop),
+ };
+}
+
function gitRepositoryStateSignature(state: GitRepositoryState) {
const dirty = state.dirtyCounts;
const header = [
diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css
index 4169aeb7..307d7366 100644
--- a/crates/agent-gui/src/index.css
+++ b/crates/agent-gui/src/index.css
@@ -311,12 +311,12 @@
background-color: hsl(var(--muted-foreground) / 0.52);
}
.git-review-floating-scrollbar-vertical {
- width: 4px;
- min-height: 28px;
+ width: 3px;
+ min-height: 24px;
}
.git-review-floating-scrollbar-horizontal {
- height: 4px;
- min-width: 28px;
+ height: 3px;
+ min-width: 24px;
}
.git-review-history-row {
@@ -1973,3 +1973,20 @@ body > div:not(#root) [data-streamdown="mermaid"] svg {
* keep the moved row in a skipped paint state and render only the white row
* background, making the conversation title look blank.
*/
+/* Git review diff views live under app chrome that disables selection by default. */
+.git-review-diff-selectable-content,
+.git-review-diff-selectable-content pre,
+.git-review-diff-selectable-content .diff-tailwindcss-wrapper,
+.git-review-diff-selectable-content .diff-line-content,
+.git-review-diff-selectable-content .diff-line-content-item,
+.git-review-diff-selectable-content .diff-line-content-raw,
+.git-review-diff-selectable-content .diff-line-syntax-raw,
+.git-review-diff-selectable-content .diff-line-content-raw *,
+.git-review-diff-selectable-content .diff-line-syntax-raw * {
+ -webkit-user-select: text;
+ user-select: text;
+}
+.git-review-diff-selectable-content .select-none {
+ -webkit-user-select: none;
+ user-select: none;
+}
From ff4d58cd1de4ea79f489d319d6667aae46b914ca Mon Sep 17 00:00:00 2001
From: su-fen <715041@qq.com>
Date: Mon, 1 Jun 2026 11:51:17 +0800
Subject: [PATCH 5/5] feat(composer): add prompt input context menu
---
.../src/components/chat/MentionComposer.tsx | 506 +++++++++++++++++-
crates/agent-gui/src/components/icons.tsx | 8 +-
2 files changed, 509 insertions(+), 5 deletions(-)
diff --git a/crates/agent-gui/src/components/chat/MentionComposer.tsx b/crates/agent-gui/src/components/chat/MentionComposer.tsx
index 147adb36..38b3668d 100644
--- a/crates/agent-gui/src/components/chat/MentionComposer.tsx
+++ b/crates/agent-gui/src/components/chat/MentionComposer.tsx
@@ -5,8 +5,8 @@ import {
type FocusEvent,
forwardRef,
type KeyboardEvent,
- memo,
type MouseEvent,
+ memo,
type RefObject,
useCallback,
useEffect,
@@ -19,6 +19,7 @@ import {
import { createPortal } from "react-dom";
import { useLocale } from "../../i18n";
import { cn } from "../../lib/shared/utils";
+import { ClipboardPaste, Copy, ScanText, Scissors } from "../icons";
import { getFileTypeIcon, getFileTypeIconSvg } from "./fileTypeIcons";
/* ------------------------------------------------------------------ */
@@ -83,6 +84,13 @@ type MentionSuggestion =
| { type: "file"; entry: MentionFileEntry }
| { type: "skill"; skill: MentionComposerSkill };
+type ComposerContextMenuState = {
+ x: number;
+ y: number;
+ selectedText: string;
+ hasContent: boolean;
+};
+
/** Where the @/$ trigger lives inside a text node */
interface MentionContext {
trigger: "file" | "skill";
@@ -185,6 +193,9 @@ const LARGE_PASTE_TAG_ATTR = "data-large-paste-id";
const LARGE_PASTE_CHAR_THRESHOLD = 8_000;
const LARGE_PASTE_LINE_THRESHOLD = 200;
const LARGE_PASTE_PREVIEW_CHARS = 160;
+const COMPOSER_CONTEXT_MENU_WIDTH = 184;
+const COMPOSER_CONTEXT_MENU_HEIGHT = 154;
+const COMPOSER_CONTEXT_MENU_MARGIN = 12;
const CARET_ANCHOR_TEXT = "\u200B";
const IME_ENTER_SUPPRESS_WINDOW_MS = 300;
const IME_COMPOSITION_END_ENTER_TAIL_MS = 80;
@@ -360,6 +371,161 @@ function editorTextIsEmpty(editor: HTMLElement) {
return raw.trim().length === 0;
}
+function editorRangeIsInsideRoot(root: HTMLElement, range: Range) {
+ const commonAncestor = range.commonAncestorContainer;
+ return commonAncestor === root || root.contains(commonAncestor);
+}
+
+function editorSelectionRange(root: HTMLElement) {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return null;
+ const range = selection.getRangeAt(0);
+ return editorRangeIsInsideRoot(root, range) ? range : null;
+}
+
+function resolveComposerSelectionText(
+ root: HTMLElement | null,
+ largePastes: Map
,
+) {
+ if (!root) return "";
+
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
+ return "";
+ }
+
+ const range = selection.getRangeAt(0);
+ if (!editorRangeIsInsideRoot(root, range)) {
+ return "";
+ }
+
+ return normalizeSerializedText(serializeChildren(range.cloneContents(), largePastes));
+}
+
+function selectionContainsPoint(
+ root: HTMLElement,
+ selection: Selection | null,
+ x: number,
+ y: number,
+) {
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false;
+
+ const range = selection.getRangeAt(0);
+ if (!editorRangeIsInsideRoot(root, range)) return false;
+
+ for (const rect of Array.from(range.getClientRects())) {
+ if (x >= rect.left - 2 && x <= rect.right + 2 && y >= rect.top - 2 && y <= rect.bottom + 2) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function createCaretRangeFromPoint(x: number, y: number) {
+ const doc = document as Document & {
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
+ caretRangeFromPoint?: (x: number, y: number) => Range | null;
+ };
+
+ const position = doc.caretPositionFromPoint?.(x, y);
+ if (position) {
+ const range = document.createRange();
+ range.setStart(position.offsetNode, position.offset);
+ range.collapse(true);
+ return range;
+ }
+
+ return doc.caretRangeFromPoint?.(x, y) ?? null;
+}
+
+function closestComposerChipFromNode(root: HTMLElement, node: Node | null) {
+ let current: Node | null =
+ node?.nodeType === Node.ELEMENT_NODE ? node : (node?.parentNode ?? null);
+
+ while (current && current !== root) {
+ if (isComposerChipElement(current)) {
+ return current;
+ }
+ current = current.parentNode;
+ }
+
+ return null;
+}
+
+function placeComposerCaretFromPoint(root: HTMLElement, x: number, y: number) {
+ const range = createCaretRangeFromPoint(x, y);
+ if (!range || !editorRangeIsInsideRoot(root, range)) return false;
+
+ const chip = closestComposerChipFromNode(root, range.startContainer);
+ if (chip) {
+ const anchor = ensureCaretAnchorAfterChip(chip);
+ if (!anchor) return false;
+ placeCaretInTextNode(anchor.textNode, anchor.offset);
+ return true;
+ }
+
+ const selection = window.getSelection();
+ if (!selection) return false;
+
+ selection.removeAllRanges();
+ selection.addRange(range);
+ normalizeCaretAfterChip(root);
+ return true;
+}
+
+function selectComposerContents(root: HTMLElement) {
+ const range = document.createRange();
+ range.selectNodeContents(root);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+}
+
+function deleteComposerSelection(
+ root: HTMLElement,
+ largePastes: Map,
+) {
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false;
+
+ const range = selection.getRangeAt(0);
+ if (!editorRangeIsInsideRoot(root, range)) return false;
+
+ range
+ .cloneContents()
+ .querySelectorAll(`[${LARGE_PASTE_TAG_ATTR}]`)
+ .forEach((chip) => {
+ const largePasteId = chip.getAttribute(LARGE_PASTE_TAG_ATTR);
+ if (largePasteId) {
+ largePastes.delete(largePasteId);
+ }
+ });
+
+ range.deleteContents();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ normalizeCaretAfterChip(root);
+ ensureTrailingCaretAnchor(root);
+ return true;
+}
+
+function clampComposerContextMenuPosition(x: number, y: number) {
+ const maxLeft = Math.max(
+ COMPOSER_CONTEXT_MENU_MARGIN,
+ window.innerWidth - COMPOSER_CONTEXT_MENU_WIDTH - COMPOSER_CONTEXT_MENU_MARGIN,
+ );
+ const maxTop = Math.max(
+ COMPOSER_CONTEXT_MENU_MARGIN,
+ window.innerHeight - COMPOSER_CONTEXT_MENU_HEIGHT - COMPOSER_CONTEXT_MENU_MARGIN,
+ );
+
+ return {
+ left: Math.min(Math.max(COMPOSER_CONTEXT_MENU_MARGIN, x), maxLeft),
+ top: Math.min(Math.max(COMPOSER_CONTEXT_MENU_MARGIN, y), maxTop),
+ };
+}
+
function normalizeMentionQuery(query: string) {
return removeCaretAnchors(query).trim().replace(/\\/g, "/").toLowerCase();
}
@@ -451,6 +617,32 @@ function extractClipboardFiles(data: DataTransfer) {
return files;
}
+function writeTextToClipboard(text: string) {
+ if (!text) return;
+
+ if (navigator.clipboard?.writeText) {
+ void navigator.clipboard.writeText(text).catch(() => {
+ fallbackWriteTextToClipboard(text);
+ });
+ return;
+ }
+
+ fallbackWriteTextToClipboard(text);
+}
+
+function fallbackWriteTextToClipboard(text: string) {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.left = "-9999px";
+ textarea.style.top = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+}
+
function isImeKeyboardEvent(event: KeyboardEvent) {
const nativeEvent = event.nativeEvent as globalThis.KeyboardEvent & {
isComposing?: boolean;
@@ -1396,8 +1588,11 @@ export const MentionComposer = memo(
}: MentionComposerProps,
ref,
) {
+ const { locale } = useLocale();
const editorRef = useRef(null);
const wrapperRef = useRef(null);
+ const composerContextMenuRef = useRef(null);
+ const composerContextMenuRangeRef = useRef(null);
const commitTooltipCloseTimerRef = useRef(null);
const [isEmpty, setIsEmpty] = useState(true);
const lastIsEmptyRef = useRef(true);
@@ -1409,11 +1604,19 @@ export const MentionComposer = memo(
const isBusyRef = useRef(false);
const largePastesRef = useRef(new Map());
const largePasteCounterRef = useRef(0);
+ const [composerContextMenu, setComposerContextMenu] = useState(
+ null,
+ );
const [commitTooltip, setCommitTooltip] = useState<{
commit: MentionComposerCommitMention;
rect: DOMRect;
} | null>(null);
+ const closeComposerContextMenu = useCallback(() => {
+ composerContextMenuRangeRef.current = null;
+ setComposerContextMenu(null);
+ }, []);
+
const setBusy = useCallback(
(nextBusy: boolean) => {
if (isBusyRef.current === nextBusy) return;
@@ -1527,6 +1730,46 @@ export const MentionComposer = memo(
setBusy(false);
}, [disabled, closeMentionSession, setBusy]);
+ useEffect(() => {
+ if (!composerContextMenu) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof Node)) {
+ closeComposerContextMenu();
+ return;
+ }
+ if (composerContextMenuRef.current?.contains(target)) {
+ return;
+ }
+ closeComposerContextMenu();
+ };
+
+ const handleKeyDown = (event: globalThis.KeyboardEvent) => {
+ if (event.key === "Escape") {
+ closeComposerContextMenu();
+ }
+ };
+
+ const handleClose = () => {
+ closeComposerContextMenu();
+ };
+
+ window.addEventListener("pointerdown", handlePointerDown, true);
+ window.addEventListener("keydown", handleKeyDown, true);
+ window.addEventListener("scroll", handleClose, true);
+ window.addEventListener("resize", handleClose);
+ window.addEventListener("blur", handleClose);
+
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown, true);
+ window.removeEventListener("keydown", handleKeyDown, true);
+ window.removeEventListener("scroll", handleClose, true);
+ window.removeEventListener("resize", handleClose);
+ window.removeEventListener("blur", handleClose);
+ };
+ }, [closeComposerContextMenu, composerContextMenu]);
+
const normalizedMentionQuery = mentionCtx ? normalizeMentionQuery(mentionCtx.query) : "";
const suggestions = useMemo(() => {
if (mentionCtx === null) {
@@ -1729,6 +1972,7 @@ export const MentionComposer = memo(
el.innerHTML = "";
largePastesRef.current.clear();
setCommitTooltip(null);
+ closeComposerContextMenu();
if (isLargePasteText(text)) {
insertLargePaste(text);
} else {
@@ -1743,6 +1987,7 @@ export const MentionComposer = memo(
el.innerHTML = "";
largePastesRef.current.clear();
setCommitTooltip(null);
+ closeComposerContextMenu();
if (draft.segments.length === 0 && draft.text) {
if (isLargePasteText(draft.text)) {
@@ -1805,12 +2050,19 @@ export const MentionComposer = memo(
el.innerHTML = "";
largePastesRef.current.clear();
setCommitTooltip(null);
+ closeComposerContextMenu();
closeMentionSession();
refreshEmptyState();
},
focus: () => editorRef.current?.focus(),
}),
- [buildDraft, closeMentionSession, insertLargePaste, refreshEmptyState],
+ [
+ buildDraft,
+ closeComposerContextMenu,
+ closeMentionSession,
+ insertLargePaste,
+ refreshEmptyState,
+ ],
);
// ---- Select suggestion ----
@@ -1829,8 +2081,175 @@ export const MentionComposer = memo(
[closeMentionSession, mentionCtx, refreshEmptyState],
);
+ const restoreComposerContextSelection = useCallback(() => {
+ const el = editorRef.current;
+ const range = composerContextMenuRangeRef.current;
+ if (!el || !range || !editorRangeIsInsideRoot(el, range)) return false;
+
+ const selection = window.getSelection();
+ if (!selection) return false;
+
+ try {
+ selection.removeAllRanges();
+ selection.addRange(range);
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ const contextMenuPosition = composerContextMenu
+ ? clampComposerContextMenuPosition(composerContextMenu.x, composerContextMenu.y)
+ : null;
+ const contextMenuLabels =
+ locale === "en-US"
+ ? {
+ cut: "Cut",
+ copy: "Copy",
+ paste: "Paste",
+ selectAll: "Select all",
+ }
+ : {
+ cut: "剪切",
+ copy: "复制",
+ paste: "粘贴",
+ selectAll: "全选",
+ };
+ const contextMenuHasSelection = Boolean(composerContextMenu?.selectedText.length);
+ const contextMenuCanMutate = !disabled;
+
+ const handleComposerContextCopy = useCallback(() => {
+ if (!composerContextMenu?.selectedText) return;
+ restoreComposerContextSelection();
+ writeTextToClipboard(composerContextMenu.selectedText);
+ closeComposerContextMenu();
+ }, [closeComposerContextMenu, composerContextMenu, restoreComposerContextSelection]);
+
+ const handleComposerContextCut = useCallback(() => {
+ const el = editorRef.current;
+ const selectedText = composerContextMenu?.selectedText;
+ if (!el || disabled || !selectedText) return;
+
+ restoreComposerContextSelection();
+ if (!deleteComposerSelection(el, largePastesRef.current)) return;
+
+ writeTextToClipboard(selectedText);
+ closeMentionSession();
+ refreshEmptyState();
+ refreshMention();
+ el.focus({ preventScroll: true });
+ closeComposerContextMenu();
+ }, [
+ closeComposerContextMenu,
+ closeMentionSession,
+ composerContextMenu?.selectedText,
+ disabled,
+ refreshEmptyState,
+ refreshMention,
+ restoreComposerContextSelection,
+ ]);
+
+ const handleComposerContextPaste = useCallback(async () => {
+ const el = editorRef.current;
+ if (!el || disabled) return;
+
+ el.focus({ preventScroll: true });
+
+ let text: string | null = null;
+ try {
+ text = (await navigator.clipboard?.readText?.()) ?? "";
+ } catch {
+ text = null;
+ }
+
+ restoreComposerContextSelection();
+
+ if (text === null) {
+ document.execCommand("paste");
+ closeMentionSession();
+ refreshEmptyState();
+ refreshMention();
+ closeComposerContextMenu();
+ return;
+ }
+
+ if (!text) {
+ closeComposerContextMenu();
+ return;
+ }
+
+ if (isLargePasteText(text)) {
+ insertLargePaste(text);
+ refreshMention();
+ closeComposerContextMenu();
+ return;
+ }
+
+ document.execCommand("insertText", false, text);
+ closeMentionSession();
+ refreshEmptyState();
+ refreshMention();
+ closeComposerContextMenu();
+ }, [
+ closeComposerContextMenu,
+ closeMentionSession,
+ disabled,
+ insertLargePaste,
+ refreshEmptyState,
+ refreshMention,
+ restoreComposerContextSelection,
+ ]);
+
+ const handleComposerContextSelectAll = useCallback(() => {
+ const el = editorRef.current;
+ if (!el || !composerContextMenu?.hasContent) return;
+
+ el.focus({ preventScroll: true });
+ selectComposerContents(el);
+ closeMentionSession();
+ closeComposerContextMenu();
+ }, [closeComposerContextMenu, closeMentionSession, composerContextMenu?.hasContent]);
+
// ---- Event handlers ----
+ const handleContextMenu = useCallback(
+ (event: MouseEvent) => {
+ event.preventDefault();
+
+ const el = editorRef.current;
+ if (!el) return;
+
+ closeMentionSession();
+ setCommitTooltip(null);
+
+ const selection = window.getSelection();
+ let selectedText = "";
+ let rangeForMenu: Range | null = null;
+
+ if (selectionContainsPoint(el, selection, event.clientX, event.clientY)) {
+ selectedText = resolveComposerSelectionText(el, largePastesRef.current);
+ rangeForMenu = editorSelectionRange(el)?.cloneRange() ?? null;
+ } else {
+ el.focus({ preventScroll: true });
+ if (placeComposerCaretFromPoint(el, event.clientX, event.clientY)) {
+ rangeForMenu = editorSelectionRange(el)?.cloneRange() ?? null;
+ } else {
+ selection?.removeAllRanges();
+ }
+ }
+
+ composerContextMenuRangeRef.current = rangeForMenu;
+ setComposerContextMenu({
+ x: event.clientX,
+ y: event.clientY,
+ selectedText,
+ hasContent: !editorTextIsEmpty(el),
+ });
+ },
+ [closeMentionSession],
+ );
+
const handleInput = useCallback(() => {
+ closeComposerContextMenu();
const el = editorRef.current;
if (el) {
normalizeCaretAfterChip(el);
@@ -1839,7 +2258,7 @@ export const MentionComposer = memo(
if (!isComposingRef.current) {
refreshMention();
}
- }, [refreshEmptyState, refreshMention]);
+ }, [closeComposerContextMenu, refreshEmptyState, refreshMention]);
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
@@ -2111,10 +2530,11 @@ export const MentionComposer = memo(
busyReleaseTimerRef.current = null;
}
setBusy(false);
+ closeComposerContextMenu();
closeMentionSession();
cancelCommitTooltipClose();
setCommitTooltip(null);
- }, [cancelCommitTooltipClose, closeMentionSession, setBusy]);
+ }, [cancelCommitTooltipClose, closeComposerContextMenu, closeMentionSession, setBusy]);
return (
@@ -2138,6 +2558,83 @@ export const MentionComposer = memo(
onMouseLeave={scheduleCommitTooltipClose}
/>
) : null}
+ {composerContextMenu && contextMenuPosition
+ ? createPortal(
+
{
+ event.preventDefault();
+ }}
+ >
+
+
+
+
+
+
,
+ document.body,
+ )
+ : null}