diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index a5edab12..e9982888 100644 --- a/crates/agent-gateway/web/src/components/icons.tsx +++ b/crates/agent-gateway/web/src/components/icons.tsx @@ -21,6 +21,7 @@ import Clock3Source from "~icons/lucide/clock-3"; import CloudSource from "~icons/lucide/cloud"; import CopySource from "~icons/lucide/copy"; import CpuSource from "~icons/lucide/cpu"; +import DownloadSource from "~icons/lucide/download"; import Edit3Source from "~icons/lucide/pen-line"; import ExternalLinkSource from "~icons/lucide/external-link"; import EyeSource from "~icons/lucide/eye"; @@ -138,6 +139,7 @@ export const Clock3 = createIcon(Clock3Source); export const Cloud = createIcon(CloudSource); export const Copy = createIcon(CopySource); export const Cpu = createIcon(CpuSource); +export const Download = createIcon(DownloadSource); export const Edit3 = createIcon(Edit3Source); export const ExternalLink = createIcon(ExternalLinkSource); export const Eye = createIcon(EyeSource); diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index f984ea11..f7e8708a 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/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, @@ -17,7 +18,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useLocale } from "../../i18n"; -import { computeGitGraph, GRAPH_COLORS, type GraphRow } from "../../lib/git/gitGraph"; +import { computeGitGraph, GRAPH_COLORS, type GraphColor, type GraphRow } from "../../lib/git/gitGraph"; import type { GitClient, GitCommitDetails, @@ -36,7 +37,9 @@ import { BrushCleaning, ChevronRight, CheckCircle2, + Cloud, Copy, + Download, ExternalLink, Eye, FilePenLine, @@ -73,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 = { @@ -327,6 +333,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; @@ -371,6 +523,9 @@ type GitHistoryRow = commit: GitCommitSummary; commitIndex: number; file: GitCommitFile; + } + | { + type: "loadMore"; }; type ChangeListSection = "staged" | "changes"; @@ -397,6 +552,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; @@ -426,12 +595,17 @@ 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 GIT_HISTORY_LIMIT = 120; +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 = "flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"; const GIT_REVIEW_POLL_INTERVAL_MS = 1500; type GitRefreshOptions = { + append?: boolean; force?: boolean; notifyChanged?: boolean; silent?: boolean; @@ -1006,7 +1180,7 @@ const DiffChunkView = memo(function DiffChunkView(props: { return (
-
+
{item.label} {item.large ? ( @@ -1027,7 +1201,7 @@ const DiffChunkView = memo(function DiffChunkView(props: {
@@ -1153,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 ? ( @@ -1174,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) => ( @@ -1184,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}
         >
@@ -1203,6 +1807,60 @@ function DiffContent(props: {
           {t("projectTools.gitReview.diffOutputTruncated")}
         
) : null} + {diffHorizontalScrollbar.visible ? ( +
+
+
+
+
+ ) : null} + {selectionContextMenu && selectionContextMenuPosition + ? createPortal( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + +
, + document.body, + ) + : null}
); } @@ -1311,77 +1969,79 @@ function commitFileStatusLabel(file: GitCommitFile) { return status === "R" || status === "C" || status === "A" || status === "D" ? status : "M"; } -const GRAPH_COL_WIDTH = 16; -const GRAPH_ROW_HEIGHT = 28; -const GRAPH_DOT_Y = 14; -const GRAPH_DOT_R = 3.5; -const GRAPH_LOCAL_ONLY_DOT_R = GRAPH_DOT_R + 0.9; -const GRAPH_ANCHOR_R = 5.2; -const GRAPH_ANCHOR_CENTER_R = 1.9; -const GRAPH_LINE_W = 2; -const GRAPH_CURVE_HORIZONTAL_TENSION = 0.7; -const GRAPH_CURVE_VERTICAL_TENSION = 0.82; -const GRAPH_MERGE_BRANCH_HORIZONTAL_TENSION = 0.92; -const GRAPH_MERGE_BRANCH_VERTICAL_TENSION = 0.55; +const GRAPH_SWIMLANE_WIDTH = 11; +const GRAPH_SVG_HEIGHT = 22; +const GRAPH_DOT_Y = GRAPH_SWIMLANE_WIDTH; +const GRAPH_DOT_R = 4; +const GRAPH_STROKE_W = 2; +const GRAPH_LINE_W = 1; +const GRAPH_CURVE_R = 5; const COMMIT_REF_TAG_LIMIT = 1; const COMMIT_DETAIL_REF_TAG_LIMIT = 3; function graphLayoutWidth(row: GraphRow) { - return graphDrawWidth(row); + return graphLaneWidth(graphColumnCount(row)); } -function graphDrawWidth(row: GraphRow) { - const maxPipeCol = Math.max( - row.commitCol, - ...row.topPipes.flatMap((pipe) => [pipe.fromCol, pipe.toCol]), - ...row.bottomPipes.flatMap((pipe) => [pipe.fromCol, pipe.toCol]), - ); - return (maxPipeCol + 1) * GRAPH_COL_WIDTH; +function graphColumnCount(row: GraphRow) { + return Math.max(row.inputLanes.length, row.outputLanes.length, row.commitCol + 1, 1); } -function graphContinuationWidth(row: GraphRow) { - const maxPipeCol = Math.max( - row.commitCol, - ...row.bottomPipes.flatMap((pipe) => [pipe.fromCol, pipe.toCol]), - ); - return (maxPipeCol + 1) * GRAPH_COL_WIDTH; +function graphLaneWidth(columnCount: number) { + return GRAPH_SWIMLANE_WIDTH * (columnCount + 1); } function graphLaneX(col: number) { - return col * GRAPH_COL_WIDTH + GRAPH_COL_WIDTH / 2; + return GRAPH_SWIMLANE_WIDTH * (col + 1); } -function graphAnchorSideX(cx: number, targetX: number) { - if (targetX === cx) return cx; - return cx + (targetX > cx ? GRAPH_ANCHOR_R : -GRAPH_ANCHOR_R); +function graphColor(color: GraphColor) { + if (typeof color === "string") return color; + return GRAPH_COLORS[((color % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length]; } -function graphBezierPath( - x1: number, - y1: number, - x2: number, - y2: number, - commitAnchor: "from" | "to", -) { - const xDirection = x2 > x1 ? 1 : -1; - const yDirection = y2 > y1 ? 1 : -1; - const xHandle = Math.abs(x2 - x1) * GRAPH_CURVE_HORIZONTAL_TENSION; - const yHandle = Math.abs(y2 - y1) * GRAPH_CURVE_VERTICAL_TENSION; - - if (commitAnchor === "from") { - return `M${x1} ${y1}C${x1 + xDirection * xHandle} ${y1},${x2} ${y2 - yDirection * yHandle},${x2} ${y2}`; +function findLastGraphLaneIndex(lanes: GraphRow["outputLanes"], id: string) { + for (let index = lanes.length - 1; index >= 0; index--) { + if (lanes[index].id === id) return index; } + return -1; +} - return `M${x1} ${y1}C${x1} ${y1 + yDirection * yHandle},${x2 - xDirection * xHandle} ${y2},${x2} ${y2}`; +function graphVerticalPath(col: number, y1 = 0, y2 = GRAPH_SVG_HEIGHT) { + const x = graphLaneX(col); + return `M ${x} ${y1} V ${y2}`; +} + +function graphCommitJoinPath(fromCol: number, toCol: number) { + if (fromCol === toCol) return graphVerticalPath(fromCol, 0, GRAPH_DOT_Y); + const x1 = graphLaneX(fromCol); + const x2 = graphLaneX(toCol); + const direction = toCol > fromCol ? 1 : -1; + return [ + `M ${x1} 0`, + `A ${GRAPH_SWIMLANE_WIDTH} ${GRAPH_SWIMLANE_WIDTH} 0 0 ${direction > 0 ? 0 : 1} ${ + x1 + direction * GRAPH_SWIMLANE_WIDTH + } ${GRAPH_DOT_Y}`, + `H ${x2}`, + ].join(" "); } -function graphMergeBranchPath(x1: number, y1: number, x2: number, y2: number) { - const xDirection = x2 > x1 ? 1 : -1; - const yDirection = y2 > y1 ? 1 : -1; - const xHandle = Math.abs(x2 - x1) * GRAPH_MERGE_BRANCH_HORIZONTAL_TENSION; - const yHandle = Math.abs(y2 - y1) * GRAPH_MERGE_BRANCH_VERTICAL_TENSION; +function graphParentBranchPath(fromCol: number, toCol: number) { + if (fromCol === toCol) return ""; + const circleX = graphLaneX(fromCol); + const branchX = GRAPH_SWIMLANE_WIDTH * toCol; + const parentX = graphLaneX(toCol); + return [ + `M ${branchX} ${GRAPH_DOT_Y}`, + `A ${GRAPH_SWIMLANE_WIDTH} ${GRAPH_SWIMLANE_WIDTH} 0 0 1 ${parentX} ${GRAPH_SVG_HEIGHT}`, + `M ${branchX} ${GRAPH_DOT_Y}`, + `H ${circleX}`, + ].join(" "); +} - return `M${x1} ${y1}C${x1 + xDirection * xHandle} ${y1},${x2} ${y2 - yDirection * yHandle},${x2} ${y2}`; +function graphCircleColor(row: GraphRow) { + const lane = row.outputLanes[row.commitCol] ?? row.inputLanes[row.commitCol]; + return graphColor(lane?.color ?? row.commitColor); } function orderedCommitRefs(refs: readonly string[]) { @@ -1458,23 +2118,46 @@ function CommitRefTags({ function GitGraphCommitMarker({ cx, color, + isHead, isMerge, - localOnly, }: { cx: number; color: string; + isHead: boolean; isMerge: boolean; - localOnly: boolean; }) { + if (isHead) { + return ( + + + + + ); + } + if (!isMerge) { return ( ); } @@ -1484,102 +2167,124 @@ function GitGraphCommitMarker({ - - {localOnly ? null : ( - - )} ); } -function GitGraphSvgCell({ - row, - isFirst, - isLast, - isMerge, - localOnly, -}: { - row: GraphRow; - isFirst: boolean; - isLast: boolean; - isMerge: boolean; - localOnly: boolean; -}) { - const drawW = graphDrawWidth(row); +function GitGraphSvgCell({ row }: { row: GraphRow }) { const layoutW = graphLayoutWidth(row); const cx = graphLaneX(row.commitCol); - const commitColor = GRAPH_COLORS[row.commitColor % GRAPH_COLORS.length]; + const commitColor = graphCircleColor(row); + const commitInputColor = graphColor(row.commitColor); + let outputIndex = 0; return ( -
+
); } -function GitGraphContinuationCell({ row, isLast }: { row: GraphRow; isLast: boolean }) { - const layoutW = graphContinuationWidth(row); - const pipes = isLast ? [] : row.bottomPipes; +function GitGraphContinuationCell({ row }: { row: GraphRow }) { + const layoutW = graphLayoutWidth(row); return ( ); } @@ -1802,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); @@ -1826,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 = [ @@ -1947,6 +2732,9 @@ export function GitReviewPanel(props: { const [reviewMode, setReviewMode] = useState("changes"); const [historyCommits, setHistoryCommits] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); + const [historyLoadingMore, setHistoryLoadingMore] = useState(false); + const [historyHasMore, setHistoryHasMore] = useState(false); + const [historyLoadMoreError, setHistoryLoadMoreError] = useState(""); const [historyError, setHistoryError] = useState(""); const [selectedCommitSha, setSelectedCommitSha] = useState(""); const [selectedCommitFilePath, setSelectedCommitFilePath] = useState(""); @@ -1975,6 +2763,8 @@ export function GitReviewPanel(props: { const selectedPathRef = useRef(""); const selectedCommitShaRef = useRef(""); const selectedCommitFilePathRef = useRef(""); + const historyCommitsRef = useRef([]); + const historyHasMoreRef = useRef(false); const expandedCommitShasRef = useRef>(new Set()); const reviewModeRef = useRef("changes"); const diffRequestIdRef = useRef(0); @@ -2038,6 +2828,11 @@ export function GitReviewPanel(props: { setBusy(""); }, []); + const setHistoryHasMoreValue = useCallback((value: boolean) => { + historyHasMoreRef.current = value; + setHistoryHasMore(value); + }, []); + useEffect(() => { selectedPathRef.current = selectedPath; }, [selectedPath]); @@ -2054,6 +2849,14 @@ export function GitReviewPanel(props: { selectedCommitFilePathRef.current = selectedCommitFilePath; }, [selectedCommitFilePath]); + useEffect(() => { + historyCommitsRef.current = historyCommits; + }, [historyCommits]); + + useEffect(() => { + historyHasMoreRef.current = historyHasMore; + }, [historyHasMore]); + useEffect(() => { expandedCommitShasRef.current = expandedCommitShas; }, [expandedCommitShas]); @@ -2296,14 +3099,20 @@ export function GitReviewPanel(props: { ); const loadHistory = useCallback(async (options: GitRefreshOptions = {}) => { + const append = options.append === true && historyCommitsRef.current.length > 0; + if (append && !historyHasMoreRef.current) { + return; + } if (historyInFlightRef.current) { return; } historyInFlightRef.current = true; const silent = options.silent === true; const force = options.force !== false; + const skip = append ? historyCommitsRef.current.length : 0; if (!gitClient || !cwd.trim()) { historySignatureRef.current = ""; + historyCommitsRef.current = []; setHistoryCommits([]); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; @@ -2311,24 +3120,64 @@ export function GitReviewPanel(props: { setSelectedCommitSha(""); setSelectedCommitFilePath(""); setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + setHistoryLoadMoreError(""); + setHistoryLoading(false); + setHistoryLoadingMore(false); clearCommitDiff(); setHistoryError(""); historyInFlightRef.current = false; return; } - if (!silent) { + if (append) { + setHistoryLoadingMore(true); + setHistoryLoadMoreError(""); + } else if (!silent) { setHistoryLoading(true); setHistoryError(""); + setHistoryLoadMoreError(""); } try { - const response = await gitClient.log(cwd, GIT_HISTORY_LIMIT); - const nextSignature = gitHistorySignature(response.state, response.commits); - const historyChanged = historySignatureRef.current !== nextSignature; + const response = await gitClient.log(cwd, { + limit: GIT_HISTORY_PAGE_SIZE, + skip, + }); const previousStatusSignature = statusSignatureRef.current; const nextStatusSignature = gitRepositoryStateSignature(response.state); const statusChanged = previousStatusSignature !== nextStatusSignature; - historySignatureRef.current = nextSignature; statusSignatureRef.current = nextStatusSignature; + const pageHasMore = response.commits.length >= GIT_HISTORY_PAGE_SIZE; + if (append) { + setState(response.state); + if (response.state.status !== "ready") { + historySignatureRef.current = ""; + historyCommitsRef.current = []; + setHistoryCommits([]); + selectedCommitShaRef.current = ""; + selectedCommitFilePathRef.current = ""; + expandedCommitShasRef.current = new Set(); + setSelectedCommitSha(""); + setSelectedCommitFilePath(""); + setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + clearCommitDiff(); + return; + } + const existingCommits = historyCommitsRef.current; + const existingShas = new Set(existingCommits.map((commit) => commit.sha)); + const nextCommits = [ + ...existingCommits, + ...response.commits.filter((commit) => !existingShas.has(commit.sha)), + ]; + historyCommitsRef.current = nextCommits; + setHistoryCommits(nextCommits); + setHistoryHasMoreValue(pageHasMore); + setHistoryLoadMoreError(""); + return; + } + const nextSignature = gitHistorySignature(response.state, response.commits); + const historyChanged = historySignatureRef.current !== nextSignature; + historySignatureRef.current = nextSignature; if (historyChanged) { commitDetailsCacheRef.current.clear(); } @@ -2336,10 +3185,20 @@ export function GitReviewPanel(props: { suppressNextGitChangedRef.current = true; dispatchGitChanged(cwd); } + setHistoryHasMoreValue( + !force && + !historyChanged && + historyCommitsRef.current.length > response.commits.length && + !historyHasMoreRef.current + ? false + : pageHasMore, + ); + setHistoryLoadMoreError(""); if (!force && !historyChanged) { return; } setState(response.state); + historyCommitsRef.current = response.commits; setHistoryCommits(response.commits); if (response.state.status !== "ready" || response.commits.length === 0) { selectedCommitShaRef.current = ""; @@ -2348,6 +3207,7 @@ export function GitReviewPanel(props: { setSelectedCommitSha(""); setSelectedCommitFilePath(""); setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); clearCommitDiff(); return; } @@ -2377,7 +3237,12 @@ export function GitReviewPanel(props: { clearCommitDiff(); } } catch (err) { - if (!silent || force) { + const message = err instanceof Error ? err.message : String(err); + if (append) { + setHistoryLoadMoreError(message); + setHistoryHasMoreValue(true); + } else if (!silent || force) { + historyCommitsRef.current = []; setHistoryCommits([]); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; @@ -2385,16 +3250,47 @@ export function GitReviewPanel(props: { setSelectedCommitSha(""); setSelectedCommitFilePath(""); setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + setHistoryLoadMoreError(""); clearCommitDiff(); - setHistoryError(err instanceof Error ? err.message : String(err)); + setHistoryError(message); } } finally { historyInFlightRef.current = false; - if (!silent) { + if (append) { + setHistoryLoadingMore(false); + } else if (!silent) { setHistoryLoading(false); } } - }, [clearCommitDiff, cwd, gitClient, loadCommitDiff]); + }, [clearCommitDiff, cwd, gitClient, loadCommitDiff, setHistoryHasMoreValue]); + + const maybeLoadMoreHistory = useCallback( + (element: HTMLElement | null) => { + if ( + !element || + !(useSplitReviewLayout || historyStackedPane === "list") || + !historyHasMoreRef.current || + historyInFlightRef.current || + historyLoadMoreError + ) { + return; + } + const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + if (distanceToBottom <= GIT_HISTORY_LOAD_MORE_SCROLL_THRESHOLD_PX) { + void loadHistory({ append: true, silent: true }); + } + }, + [historyLoadMoreError, historyStackedPane, loadHistory, useSplitReviewLayout], + ); + + const handleHistoryListScroll = useCallback( + (event: ReactUIEvent) => { + handleGitReviewTransientScroll(event); + maybeLoadMoreHistory(event.currentTarget); + }, + [maybeLoadMoreHistory], + ); useEffect(() => { void refresh(); @@ -2711,7 +3607,15 @@ export function GitReviewPanel(props: { const historyContextCommitGithubUrl = historyContextCommit ? gitHubCommitUrl(state.remoteUrl, historyContextCommit.sha) : ""; - const gitGraph = useMemo(() => computeGitGraph(historyCommits), [historyCommits]); + const gitGraph = useMemo( + () => + computeGitGraph(historyCommits, { + currentRef: state.head, + remoteRef: state.upstream, + remoteName: state.remoteName, + }), + [historyCommits, state.head, state.remoteName, state.upstream], + ); const historyRows = useMemo(() => { const rows: GitHistoryRow[] = []; historyCommits.forEach((commit, commitIndex) => { @@ -2722,21 +3626,32 @@ export function GitReviewPanel(props: { }); } }); + if (historyHasMore || historyLoadingMore || historyLoadMoreError) { + rows.push({ type: "loadMore" }); + } return rows; - }, [expandedCommitShas, historyCommits]); + }, [expandedCommitShas, historyCommits, historyHasMore, historyLoadMoreError, historyLoadingMore]); const historyVirtualizer = useVirtualizer({ count: historyRows.length, getScrollElement: () => historyListRef.current, - estimateSize: (index) => (historyRows[index]?.type === "file" ? 26 : 28), + estimateSize: () => 22, overscan: 8, getItemKey: (index) => { const row = historyRows[index]; if (!row) return index; if (row.type === "commit") return `commit:${row.commit.sha}`; + if (row.type === "loadMore") return "load-more"; return `file:${row.commit.sha}:${row.file.status}:${row.file.oldPath ?? ""}:${row.file.path}`; }, }); + useEffect(() => { + if (reviewMode !== "history") { + return; + } + maybeLoadMoreHistory(historyListRef.current); + }, [historyRows.length, maybeLoadMoreHistory, reviewMode]); + useEffect(() => { if (!changeContextMenu) return; const closeMenu = () => setChangeContextMenu(null); @@ -3401,6 +4316,7 @@ export function GitReviewPanel(props: { size="sm" variant="ghost" disabled={loading || historyLoading || operationBusy} + className="h-7 w-7 px-0" title={t("projectTools.gitReview.refresh")} aria-label={t("projectTools.gitReview.refresh")} onClick={() => { @@ -3419,22 +4335,30 @@ export function GitReviewPanel(props: { variant="ghost" disabled={writeDisabled || operationBusy} title={t("projectTools.gitReview.fetch")} - className="gap-1.5" + aria-label={t("projectTools.gitReview.fetch")} + className="h-7 w-7 px-0" onClick={() => void runOperation("fetch", () => gitClient!.fetch(cwd), "fetch")} > - {busy === "fetch" ? : null} - {t("projectTools.gitReview.fetch")} + {busy === "fetch" ? ( + + ) : ( + + )} +
+ ); + } if (row.type === "file") { const TypeIcon = getFileTypeIcon(row.file.path, "file"); const fileSelected = @@ -3785,42 +4740,35 @@ export function GitReviewPanel(props: { className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${virtualRow.start}px)` }} > -
${row.file.path}` + : row.file.path + } onContextMenu={(event) => openHistoryFileContextMenu(event, row.commit, row.file) } + onClick={() => selectCommitFile(row.commit, row.file)} > {graphRow ? ( - + ) : null} - -
+ + + {commitFileStatusLabel(row.file)} + +
); } @@ -3839,38 +4787,24 @@ export function GitReviewPanel(props: { className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${virtualRow.start}px)` }} > -
openHistoryCommitContextMenu(event, commit)} + onClick={() => selectCommit(commit)} > {graphRow ? ( - 1} - localOnly={commit.localOnly} - /> + ) : null} - -
+ + {commit.subject || commit.shortSha} + + +
); })} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 038d22a5..70290623 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -329,6 +329,9 @@ export const translations: Record> = { "projectTools.gitReview.detailPane": "详情视图", "projectTools.gitReview.commitHistoryTitle": "提交历史", "projectTools.gitReview.noCommitHistory": "没有提交历史。", + "projectTools.gitReview.loadMoreCommits": "加载更多", + "projectTools.gitReview.loadingMoreCommits": "正在加载更多...", + "projectTools.gitReview.loadMoreCommitsFailed": "加载失败,点击重试", "projectTools.gitReview.openChange": "打开更改", "projectTools.gitReview.openOnGithub": "在 GitHub 上打开", "projectTools.gitReview.createBranch": "创建分支", @@ -1420,6 +1423,9 @@ export const translations: Record> = { "projectTools.gitReview.detailPane": "Detail view", "projectTools.gitReview.commitHistoryTitle": "Commit History", "projectTools.gitReview.noCommitHistory": "No commit history.", + "projectTools.gitReview.loadMoreCommits": "Load more", + "projectTools.gitReview.loadingMoreCommits": "Loading more...", + "projectTools.gitReview.loadMoreCommitsFailed": "Load failed, click to retry", "projectTools.gitReview.openChange": "Open Change", "projectTools.gitReview.openOnGithub": "Open on GitHub", "projectTools.gitReview.createBranch": "Create Branch", diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index bd143b9d..ed8862bf 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -103,6 +103,11 @@ --checkpoint-border: 0 0% 0% / 0.06; --checkpoint-icon-bg: 220 10% 95%; --checkpoint-icon-fg: 220 9% 46%; + + /* VS Code SCM graph reference colors */ + --git-review-graph-ref-local: #0063d3; + --git-review-graph-ref-remote: #652d90; + --git-review-graph-ref-base: #ea5c00; } .dark { @@ -170,6 +175,11 @@ --checkpoint-border: 0 0% 100% / 0.14; --checkpoint-icon-bg: 220 12% 22%; --checkpoint-icon-fg: 220 14% 76%; + + /* VS Code SCM graph reference colors (dark) */ + --git-review-graph-ref-local: #59a4f9; + --git-review-graph-ref-remote: #b180d7; + --git-review-graph-ref-base: #ea5c00; } } @@ -305,12 +315,36 @@ 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 { + color: inherit; + background: transparent; + --git-review-graph-background: hsl(var(--background)); + } + + .git-review-history-row:hover:not([data-selected="true"]):not([data-context-open="true"]) { + background: hsl(var(--muted) / 0.38); + --git-review-graph-background: hsl(var(--muted) / 0.38); + } + + .git-review-history-row[data-selected="true"] { + background: hsl(var(--accent) / 0.8); + color: hsl(var(--accent-foreground)); + --git-review-graph-background: hsl(var(--accent) / 0.8); + } + + .git-review-history-row[data-context-open="true"] { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.35); + --git-review-graph-background: hsl(var(--primary) / 0.1); } /* Git review tab & pane transition animations */ @@ -1668,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-gateway/web/src/lib/git/gatewayGitClient.ts b/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts index 3327232b..ef457663 100644 --- a/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts +++ b/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts @@ -42,8 +42,11 @@ export function createGatewayGitClient(api: GatewayWebSocketClientLike): GitClie async diff(workdir, mode, path) { return normalizeGitDiffResponse(await api.gitRequest("diff", workdir, { mode, path })); }, - async log(workdir, limit) { - return normalizeGitLogResponse(await api.gitRequest("log", workdir, { limit }), workdir); + async log(workdir, options = {}) { + return normalizeGitLogResponse( + await api.gitRequest("log", workdir, { limit: options.limit, skip: options.skip }), + workdir, + ); }, async commitDetails(workdir, commit) { return normalizeGitCommitDetailsResponse( diff --git a/crates/agent-gateway/web/src/lib/git/gitGraph.ts b/crates/agent-gateway/web/src/lib/git/gitGraph.ts index 053d45e5..6b82fe3a 100644 --- a/crates/agent-gateway/web/src/lib/git/gitGraph.ts +++ b/crates/agent-gateway/web/src/lib/git/gitGraph.ts @@ -1,123 +1,182 @@ export const GRAPH_COLORS = [ - "#3b82f6", - "#10b981", - "#ef4444", - "#8b5cf6", - "#f59e0b", - "#06b6d4", - "#f97316", - "#ec4899", + "#ffb000", + "#dc267f", + "#994f00", + "#40b0a6", + "#b66dff", ]; -export type GraphPipe = { - fromCol: number; - toCol: number; - color: number; +export const GRAPH_REF_COLORS = { + local: "var(--git-review-graph-ref-local)", + remote: "var(--git-review-graph-ref-remote)", + base: "var(--git-review-graph-ref-base)", +} as const; + +export type GraphColor = number | string; + +export type GraphLane = { + id: string; + color: GraphColor; }; export type GraphRow = { + sha: string; + parents: string[]; commitCol: number; - commitColor: number; - topPipes: GraphPipe[]; - bottomPipes: GraphPipe[]; + commitColor: GraphColor; + inputLanes: GraphLane[]; + outputLanes: GraphLane[]; + isHead: boolean; + isMerge: boolean; }; -type Wire = { sha: string; color: number } | null; +type GitGraphCommit = { + sha: string; + parents: readonly string[]; + refs?: readonly string[]; +}; -export function computeGitGraph(commits: readonly { sha: string; parents: string[] }[]): { - rows: GraphRow[]; - maxCols: number; -} { - if (commits.length === 0) return { rows: [], maxCols: 0 }; +export type GitGraphOptions = { + currentRef?: string; + remoteRef?: string; + baseRef?: string; + remoteName?: string; +}; - const shaSet = new Set(commits.map((c) => c.sha)); - const rows: GraphRow[] = []; - const wires: Wire[] = []; - let nextColor = 0; - let globalMaxCols = 1; +function cloneLane(lane: GraphLane): GraphLane { + return { ...lane }; +} - function allocColor(): number { - return nextColor++ % GRAPH_COLORS.length; +function normalizeRef(value: string) { + let ref = value.trim(); + if (!ref) return ""; + if (ref.startsWith("refs/heads/")) { + ref = ref.slice("refs/heads/".length); + } else if (ref.startsWith("refs/remotes/")) { + ref = ref.slice("refs/remotes/".length); + } else if (ref.startsWith("refs/tags/")) { + ref = ref.slice("refs/tags/".length); } + return ref; +} - function findFreeSlot(): number { - const idx = wires.indexOf(null); - if (idx >= 0) return idx; - wires.push(null); - return wires.length - 1; - } +function createRefColorMap(options: GitGraphOptions) { + const map = new Map(); + const currentRef = normalizeRef(options.currentRef ?? ""); + const remoteRef = normalizeRef(options.remoteRef ?? ""); + const baseRef = normalizeRef(options.baseRef ?? ""); + const remoteName = normalizeRef(options.remoteName ?? ""); - function trimWires() { - while (wires.length > 0 && wires[wires.length - 1] === null) wires.pop(); + if (currentRef) { + map.set(currentRef, GRAPH_REF_COLORS.local); + } + if (remoteRef) { + map.set(remoteRef, GRAPH_REF_COLORS.remote); + } + if (remoteName && currentRef) { + map.set(`${remoteName}/${currentRef}`, GRAPH_REF_COLORS.remote); + } + if (baseRef) { + map.set(baseRef, GRAPH_REF_COLORS.base); } - for (let r = 0; r < commits.length; r++) { - const commit = commits[r]; - const parents = commit.parents.filter((p) => shaSet.has(p)); - - const prevWires: Wire[] = wires.map((w) => (w ? { ...w } : null)); - - const matchCols: number[] = []; - for (let i = 0; i < wires.length; i++) { - if (wires[i]?.sha === commit.sha) matchCols.push(i); - } + return map; +} - let commitCol: number; - let commitColor: number; +function labelColorForCommit( + commit: GitGraphCommit | undefined, + refColorMap: Map, +): GraphColor | undefined { + for (const rawRef of commit?.refs ?? []) { + const color = refColorMap.get(normalizeRef(rawRef)); + if (color !== undefined) return color; + } + return undefined; +} - if (matchCols.length > 0) { - commitCol = matchCols[0]; - commitColor = wires[commitCol]?.color ?? 0; - for (const mc of matchCols) wires[mc] = null; - } else { - commitCol = findFreeSlot(); - commitColor = allocColor(); - } +function uniqueParents(parents: readonly string[]) { + const seen = new Set(); + const result: string[] = []; + for (const rawParent of parents) { + const parent = rawParent.trim(); + if (!parent || seen.has(parent)) continue; + seen.add(parent); + result.push(parent); + } + return result; +} - const newParentSlots = new Set(); +export function computeGitGraph( + commits: readonly GitGraphCommit[], + options: GitGraphOptions = {}, +): { + rows: GraphRow[]; + maxCols: number; +} { + if (commits.length === 0) return { rows: [], maxCols: 0 }; - if (parents.length >= 1) { - wires[commitCol] = { sha: parents[0], color: commitColor }; - } + const rows: GraphRow[] = []; + const commitBySha = new Map(commits.map((commit) => [commit.sha, commit])); + const refColorMap = createRefColorMap(options); + let nextColor = -1; + let previousOutputLanes: GraphLane[] = []; + let maxCols = 1; - for (let p = 1; p < parents.length; p++) { - const pSha = parents[p]; - if (wires.some((w) => w?.sha === pSha)) continue; - const slot = findFreeSlot(); - const color = allocColor(); - wires[slot] = { sha: pSha, color }; - newParentSlots.add(slot); - } + function allocColor(): number { + nextColor = (nextColor + 1) % GRAPH_COLORS.length; + return nextColor; + } - trimWires(); + for (let index = 0; index < commits.length; index++) { + const commit = commits[index]; + const parents = uniqueParents(commit.parents); + const inputLanes = previousOutputLanes.map(cloneLane); + const inputIndex = inputLanes.findIndex((lane) => lane.id === commit.sha); + const commitCol = inputIndex >= 0 ? inputIndex : inputLanes.length; + const labelColor = labelColorForCommit(commit, refColorMap); + const commitColor = inputIndex >= 0 ? inputLanes[inputIndex].color : (labelColor ?? allocColor()); + const outputLanes: GraphLane[] = []; + + if (parents.length > 0) { + let firstParentAdded = false; + for (const lane of inputLanes) { + if (lane.id === commit.sha) { + if (!firstParentAdded) { + outputLanes.push({ id: parents[0], color: labelColor ?? commitColor }); + firstParentAdded = true; + } + continue; + } + + outputLanes.push(cloneLane(lane)); + } - const topPipes: GraphPipe[] = []; - for (let i = 0; i < prevWires.length; i++) { - const pw = prevWires[i]; - if (pw === null) continue; - if (pw.sha === commit.sha) { - topPipes.push({ fromCol: i, toCol: commitCol, color: pw.color }); - } else { - topPipes.push({ fromCol: i, toCol: i, color: pw.color }); + if (!firstParentAdded) { + outputLanes.push({ id: parents[0], color: labelColor ?? commitColor }); } - } - const bottomPipes: GraphPipe[] = []; - for (let i = 0; i < wires.length; i++) { - const w = wires[i]; - if (w === null) continue; - if (newParentSlots.has(i)) { - bottomPipes.push({ fromCol: commitCol, toCol: i, color: w.color }); - } else { - bottomPipes.push({ fromCol: i, toCol: i, color: w.color }); + for (let parentIndex = 1; parentIndex < parents.length; parentIndex++) { + const parent = parents[parentIndex]; + outputLanes.push({ + id: parent, + color: labelColorForCommit(commitBySha.get(parent), refColorMap) ?? allocColor(), + }); } } - const maxCols = Math.max(prevWires.length, wires.length, commitCol + 1); - if (maxCols > globalMaxCols) globalMaxCols = maxCols; - - rows.push({ commitCol, commitColor, topPipes, bottomPipes }); + maxCols = Math.max(maxCols, inputLanes.length, outputLanes.length, commitCol + 1); + rows.push({ + sha: commit.sha, + parents, + commitCol, + commitColor, + inputLanes, + outputLanes, + isHead: index === 0, + isMerge: parents.length > 1, + }); + previousOutputLanes = outputLanes; } - return { rows, maxCols: globalMaxCols }; + return { rows, maxCols }; } diff --git a/crates/agent-gateway/web/src/lib/git/types.ts b/crates/agent-gateway/web/src/lib/git/types.ts index ca5684b8..1cb9da8c 100644 --- a/crates/agent-gateway/web/src/lib/git/types.ts +++ b/crates/agent-gateway/web/src/lib/git/types.ts @@ -120,6 +120,11 @@ export type GitInitOptions = { userEmail?: string; }; +export type GitLogOptions = { + limit?: number; + skip?: number; +}; + export type GitClient = { status(workdir: string): Promise; branches(workdir: string): Promise; @@ -127,7 +132,7 @@ export type GitClient = { switchBranch(workdir: string, branch: string, kind?: string): Promise; createBranch(workdir: string, branch: string, startPoint?: string): Promise; diff(workdir: string, mode: "branch" | "working_tree", path?: string): Promise; - log(workdir: string, limit?: number): Promise; + log(workdir: string, options?: GitLogOptions): Promise; commitDetails(workdir: string, commit: string): Promise; compareCommitWithRemote(workdir: string, commit: string): Promise; commitDiff(workdir: string, commit: string, path?: string): Promise; diff --git a/crates/agent-gui/src-tauri/src/commands/git.rs b/crates/agent-gui/src-tauri/src/commands/git.rs index 5409f387..0b7ddbfc 100644 --- a/crates/agent-gui/src-tauri/src/commands/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/git.rs @@ -17,8 +17,8 @@ const GIT_UNTRACKED_FILE_MAX_BYTES: u64 = 128 * 1024; const GIT_COMMAND_TIMEOUT_SECS: u64 = 60; const GIT_TRANSIENT_RETRY_ATTEMPTS: usize = 3; const GIT_TRANSIENT_RETRY_DELAY_MS: u64 = 160; -const GIT_LOG_DEFAULT_LIMIT: usize = 80; -const GIT_LOG_MAX_LIMIT: usize = 200; +const GIT_LOG_DEFAULT_LIMIT: usize = 50; +const GIT_LOG_MAX_LIMIT: usize = 1000; const GIT_MISSING_REMOTE_MESSAGE: &str = "当前仓库还没有设置远端仓库。"; const GIT_MISSING_ORIGIN_REMOTE_MESSAGE: &str = "当前分支没有 upstream,且找不到 origin remote。"; @@ -175,6 +175,7 @@ struct GitGatewayArgs { commit: Option, start_point: Option, limit: Option, + skip: Option, user_name: Option, user_email: Option, } @@ -1294,7 +1295,7 @@ fn parse_git_refs(raw: &str) -> Vec { fn parse_git_log(raw: &str) -> Vec { raw.split('\x1e') .filter_map(|record| { - let record = record.trim_start_matches('\n'); + let record = record.trim_start_matches('\n').trim_end_matches('\0'); if record .trim_matches(|ch: char| ch == '\0' || ch.is_whitespace()) .is_empty() @@ -1340,6 +1341,25 @@ fn parse_git_log(raw: &str) -> Vec { .collect() } +fn commit_files_between( + repo_root: &str, + base_ref: &str, + head_ref: &str, +) -> Result, String> { + let output = git_success( + repo_root, + &[ + "diff", + "--name-status", + "-z", + "--find-renames", + base_ref, + head_ref, + ], + )?; + Ok(parse_name_status_records(&output.stdout)) +} + fn local_only_commit_shas(repo_root: &str, cloud_ref: &str) -> HashSet { let cloud_ref = cloud_ref.trim(); if cloud_ref.is_empty() { @@ -1401,6 +1421,7 @@ fn validate_commit_sha(repo_root: &str, value: &str) -> Result { pub(crate) fn git_log_sync( workdir: String, limit: Option, + skip: Option, ) -> Result { let state = git_status_sync(workdir)?; if state.status != "ready" { @@ -1417,8 +1438,8 @@ pub(crate) fn git_log_sync( } let limit = limit .unwrap_or(GIT_LOG_DEFAULT_LIMIT) - .clamp(1, GIT_LOG_MAX_LIMIT) - .to_string(); + .clamp(1, GIT_LOG_MAX_LIMIT); + let skip = skip.unwrap_or(0); let mut args = vec![ "log".to_string(), "--date=iso-strict".to_string(), @@ -1429,10 +1450,15 @@ pub(crate) fn git_log_sync( "-z".to_string(), "--find-renames".to_string(), "--max-count".to_string(), - limit, + limit.to_string(), + ]; + if skip > 0 { + args.push(format!("--skip={skip}")); + } + args.extend([ "--pretty=format:%x1e%H%x1f%h%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%aI%x1f%s".to_string(), "HEAD".to_string(), - ]; + ]); let review_ref = if !state.upstream.trim().is_empty() { state.upstream.clone() } else { @@ -1444,6 +1470,19 @@ pub(crate) fn git_log_sync( let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); let output = git_success(&state.repo_root, &arg_refs)?; let mut commits = parse_git_log(&output.stdout); + // git log/show omit name-status entries for merge commits; VSCode expands + // those commits by diffing them against the first parent. + for commit in &mut commits { + if commit.parents.len() > 1 && commit.files.is_empty() { + if let Some(first_parent) = commit.parents.first() { + let files = commit_files_between(&state.repo_root, first_parent, &commit.sha); + if let Ok(files) = files { + commit.file_count = files.len(); + commit.files = files; + } + } + } + } let cloud_ref = resolve_cloud_tracking_ref(&state); let local_only_shas = local_only_commit_shas(&state.repo_root, &cloud_ref); if cloud_ref.trim().is_empty() { @@ -1478,6 +1517,13 @@ pub(crate) fn git_commit_details_sync( if fields.len() < 7 { return Err("无法解析 Git commit 详情。".to_string()); } + let parent_output = git_success(&state.repo_root, &["show", "-s", "--format=%P", &commit])?; + let first_parent = parent_output + .stdout + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); let files_output = git_success( &state.repo_root, &[ @@ -1489,7 +1535,13 @@ pub(crate) fn git_commit_details_sync( &commit, ], )?; - let files = parse_name_status_records(&files_output.stdout); + let mut files = parse_name_status_records(&files_output.stdout); + if files.is_empty() && !first_parent.is_empty() { + let parent_files = commit_files_between(&state.repo_root, &first_parent, &commit); + if let Ok(parent_files) = parent_files { + files = parent_files; + } + } let stat_output = git_success( &state.repo_root, &["show", "--format=", "--stat", "--find-renames", &commit], @@ -2041,7 +2093,7 @@ pub(crate) fn git_gateway_action_sync( args.branch.unwrap_or_default(), args.start_point, )?), - "log" => serde_json::to_value(git_log_sync(workdir, args.limit)?), + "log" => serde_json::to_value(git_log_sync(workdir, args.limit, args.skip)?), "commit_details" => serde_json::to_value(git_commit_details_sync( workdir, args.commit.unwrap_or_default(), @@ -2162,8 +2214,12 @@ pub async fn git_diff( } #[tauri::command(rename_all = "snake_case")] -pub async fn git_log(workdir: String, limit: Option) -> Result { - tauri::async_runtime::spawn_blocking(move || git_log_sync(workdir, limit)) +pub async fn git_log( + workdir: String, + limit: Option, + skip: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || git_log_sync(workdir, limit, skip)) .await .map_err(|error| format!("git_log join 失败:{error}"))? } @@ -2383,6 +2439,10 @@ mod tests { .expect("parse init args"); assert_eq!(init_args.user_name.as_deref(), Some("LiveAgent Test")); assert_eq!(init_args.user_email.as_deref(), Some("test@example.com")); + let log_args = + parse_gateway_args(json!({"limit":50,"skip":100}).to_string()).expect("parse log args"); + assert_eq!(log_args.limit, Some(50)); + assert_eq!(log_args.skip, Some(100)); } #[test] @@ -2639,7 +2699,7 @@ mod tests { git_commit_sync(workdir.clone(), "add feature file".to_string()).expect("commit"); assert!(committed.ok, "commit failed: {}", committed.message); - let history = git_log_sync(workdir.clone(), Some(10)).expect("git log"); + let history = git_log_sync(workdir.clone(), Some(10), None).expect("git log"); let feature_commit = history .commits .iter() @@ -2839,6 +2899,120 @@ mod tests { ); } + #[test] + fn git_history_and_details_expand_merge_commit_files() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let initial = git_status_sync(workdir.clone()).expect("initial status"); + + run_temp_git(repo.path(), &["checkout", "-b", "feature-merge-files"]); + fs::write(repo.path().join("feature.txt"), "feature\n").expect("write feature file"); + run_temp_git(repo.path(), &["add", "feature.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "feature branch file"]); + + run_temp_git(repo.path(), &["checkout", initial.head.as_str()]); + fs::write(repo.path().join("main.txt"), "main\n").expect("write main file"); + run_temp_git(repo.path(), &["add", "main.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "main branch file"]); + + run_temp_git( + repo.path(), + &[ + "merge", + "--no-ff", + "-m", + "merge feature files", + "feature-merge-files", + ], + ); + let merge_sha = git_success(&workdir, &["rev-parse", "HEAD"]) + .expect("read merge head") + .stdout; + + let history = git_log_sync(workdir.clone(), Some(10), None).expect("git log"); + let merge_commit = history + .commits + .iter() + .find(|commit| commit.subject == "merge feature files") + .expect("merge commit should be in log"); + assert_eq!(merge_commit.parents.len(), 2); + assert_eq!(merge_commit.file_count, merge_commit.files.len()); + assert!( + merge_commit + .files + .iter() + .any(|file| file.path == "feature.txt" && file.status == "A"), + "merge commit files: {:?}", + merge_commit.files + ); + assert!( + !merge_commit + .files + .iter() + .any(|file| file.path == "main.txt"), + "merge commit should use first-parent files: {:?}", + merge_commit.files + ); + + let details = git_commit_details_sync(workdir, merge_sha).expect("merge details"); + assert_eq!(details.commit.file_count, details.commit.files.len()); + assert!( + details + .commit + .files + .iter() + .any(|file| file.path == "feature.txt" && file.status == "A"), + "merge details files: {:?}", + details.commit.files + ); + } + + #[test] + fn git_log_supports_skip_pagination() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + + for index in 1..=4 { + let file_name = format!("page-{index}.txt"); + fs::write(repo.path().join(&file_name), format!("page {index}\n")) + .expect("write page file"); + run_temp_git(repo.path(), &["add", &file_name]); + run_temp_git(repo.path(), &["commit", "-m", &format!("page {index}")]); + } + + let first_page = git_log_sync(workdir.clone(), Some(2), Some(0)).expect("first page"); + let second_page = git_log_sync(workdir, Some(2), Some(2)).expect("second page"); + let first_subjects = first_page + .commits + .iter() + .map(|commit| commit.subject.as_str()) + .collect::>(); + let second_subjects = second_page + .commits + .iter() + .map(|commit| commit.subject.as_str()) + .collect::>(); + + assert_eq!(first_subjects, vec!["page 4", "page 3"]); + assert_eq!(second_subjects, vec!["page 2", "page 1"]); + let first_shas = first_page + .commits + .iter() + .map(|commit| commit.sha.as_str()) + .collect::>(); + assert!( + second_page + .commits + .iter() + .all(|commit| !first_shas.contains(commit.sha.as_str())), + "pages should not overlap" + ); + } + #[test] fn git_create_branch_can_start_from_commit() { let Some(repo) = init_temp_repo() else { @@ -3061,7 +3235,7 @@ mod tests { run_temp_git(repo.path(), &["add", "local.txt"]); run_temp_git(repo.path(), &["commit", "-m", "local only"]); - let history = git_log_sync(workdir, Some(10)).expect("git log"); + let history = git_log_sync(workdir, Some(10), None).expect("git log"); let local_commit = history .commits .iter() @@ -3110,7 +3284,7 @@ mod tests { run_temp_git(repo.path(), &["add", "feature.txt"]); run_temp_git(repo.path(), &["commit", "-m", "feature local only"]); - let history = git_log_sync(workdir, Some(10)).expect("git log"); + let history = git_log_sync(workdir, Some(10), None).expect("git log"); let local_commit = history .commits .iter() 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}
(); type GitReviewScrollbarAxis = "vertical" | "horizontal"; type GitReviewScrollbarOverlay = { @@ -331,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; @@ -375,6 +527,9 @@ type GitHistoryRow = commit: GitCommitSummary; commitIndex: number; file: GitCommitFile; + } + | { + type: "loadMore"; }; type ChangeListSection = "staged" | "changes"; @@ -401,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; @@ -430,12 +599,17 @@ 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 GIT_HISTORY_LIMIT = 120; +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 = "flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"; const GIT_REVIEW_POLL_INTERVAL_MS = 1500; type GitRefreshOptions = { + append?: boolean; force?: boolean; notifyChanged?: boolean; silent?: boolean; @@ -1011,7 +1185,7 @@ const DiffChunkView = memo(function DiffChunkView(props: { item: PatchChunk; isD return (
-
+
{item.label} {item.large ? ( @@ -1032,7 +1206,7 @@ const DiffChunkView = memo(function DiffChunkView(props: { item: PatchChunk; isD
@@ -1158,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 ? ( @@ -1179,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) => ( @@ -1189,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}
         >
@@ -1208,6 +1812,60 @@ function DiffContent(props: {
           {t("projectTools.gitReview.diffOutputTruncated")}
         
) : null} + {diffHorizontalScrollbar.visible ? ( +
+
+
+
+
+ ) : null} + {selectionContextMenu && selectionContextMenuPosition + ? createPortal( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + +
, + document.body, + ) + : null}
); } @@ -1436,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); @@ -1460,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 = [ @@ -1542,77 +2257,79 @@ function assertGitOperationResult(value: unknown, fallbackMessage: string) { } } -const GRAPH_COL_WIDTH = 16; -const GRAPH_ROW_HEIGHT = 28; -const GRAPH_DOT_Y = 14; -const GRAPH_DOT_R = 3.5; -const GRAPH_LOCAL_ONLY_DOT_R = GRAPH_DOT_R + 0.9; -const GRAPH_ANCHOR_R = 5.2; -const GRAPH_ANCHOR_CENTER_R = 1.9; -const GRAPH_LINE_W = 2; -const GRAPH_CURVE_HORIZONTAL_TENSION = 0.7; -const GRAPH_CURVE_VERTICAL_TENSION = 0.82; -const GRAPH_MERGE_BRANCH_HORIZONTAL_TENSION = 0.92; -const GRAPH_MERGE_BRANCH_VERTICAL_TENSION = 0.55; +const GRAPH_SWIMLANE_WIDTH = 11; +const GRAPH_SVG_HEIGHT = 22; +const GRAPH_DOT_Y = GRAPH_SWIMLANE_WIDTH; +const GRAPH_DOT_R = 4; +const GRAPH_STROKE_W = 2; +const GRAPH_LINE_W = 1; +const GRAPH_CURVE_R = 5; const COMMIT_REF_TAG_LIMIT = 1; const COMMIT_DETAIL_REF_TAG_LIMIT = 3; function graphLayoutWidth(row: GraphRow) { - return graphDrawWidth(row); + return graphLaneWidth(graphColumnCount(row)); } -function graphDrawWidth(row: GraphRow) { - const maxPipeCol = Math.max( - row.commitCol, - ...row.topPipes.flatMap((pipe) => [pipe.fromCol, pipe.toCol]), - ...row.bottomPipes.flatMap((pipe) => [pipe.fromCol, pipe.toCol]), - ); - return (maxPipeCol + 1) * GRAPH_COL_WIDTH; +function graphColumnCount(row: GraphRow) { + return Math.max(row.inputLanes.length, row.outputLanes.length, row.commitCol + 1, 1); } -function graphContinuationWidth(row: GraphRow) { - const maxPipeCol = Math.max( - row.commitCol, - ...row.bottomPipes.flatMap((pipe) => [pipe.fromCol, pipe.toCol]), - ); - return (maxPipeCol + 1) * GRAPH_COL_WIDTH; +function graphLaneWidth(columnCount: number) { + return GRAPH_SWIMLANE_WIDTH * (columnCount + 1); } function graphLaneX(col: number) { - return col * GRAPH_COL_WIDTH + GRAPH_COL_WIDTH / 2; + return GRAPH_SWIMLANE_WIDTH * (col + 1); } -function graphAnchorSideX(cx: number, targetX: number) { - if (targetX === cx) return cx; - return cx + (targetX > cx ? GRAPH_ANCHOR_R : -GRAPH_ANCHOR_R); +function graphColor(color: GraphColor) { + if (typeof color === "string") return color; + return GRAPH_COLORS[((color % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length]; } -function graphBezierPath( - x1: number, - y1: number, - x2: number, - y2: number, - commitAnchor: "from" | "to", -) { - const xDirection = x2 > x1 ? 1 : -1; - const yDirection = y2 > y1 ? 1 : -1; - const xHandle = Math.abs(x2 - x1) * GRAPH_CURVE_HORIZONTAL_TENSION; - const yHandle = Math.abs(y2 - y1) * GRAPH_CURVE_VERTICAL_TENSION; - - if (commitAnchor === "from") { - return `M${x1} ${y1}C${x1 + xDirection * xHandle} ${y1},${x2} ${y2 - yDirection * yHandle},${x2} ${y2}`; +function findLastGraphLaneIndex(lanes: GraphRow["outputLanes"], id: string) { + for (let index = lanes.length - 1; index >= 0; index--) { + if (lanes[index].id === id) return index; } + return -1; +} - return `M${x1} ${y1}C${x1} ${y1 + yDirection * yHandle},${x2 - xDirection * xHandle} ${y2},${x2} ${y2}`; +function graphVerticalPath(col: number, y1 = 0, y2 = GRAPH_SVG_HEIGHT) { + const x = graphLaneX(col); + return `M ${x} ${y1} V ${y2}`; } -function graphMergeBranchPath(x1: number, y1: number, x2: number, y2: number) { - const xDirection = x2 > x1 ? 1 : -1; - const yDirection = y2 > y1 ? 1 : -1; - const xHandle = Math.abs(x2 - x1) * GRAPH_MERGE_BRANCH_HORIZONTAL_TENSION; - const yHandle = Math.abs(y2 - y1) * GRAPH_MERGE_BRANCH_VERTICAL_TENSION; +function graphCommitJoinPath(fromCol: number, toCol: number) { + if (fromCol === toCol) return graphVerticalPath(fromCol, 0, GRAPH_DOT_Y); + const x1 = graphLaneX(fromCol); + const x2 = graphLaneX(toCol); + const direction = toCol > fromCol ? 1 : -1; + return [ + `M ${x1} 0`, + `A ${GRAPH_SWIMLANE_WIDTH} ${GRAPH_SWIMLANE_WIDTH} 0 0 ${direction > 0 ? 0 : 1} ${ + x1 + direction * GRAPH_SWIMLANE_WIDTH + } ${GRAPH_DOT_Y}`, + `H ${x2}`, + ].join(" "); +} - return `M${x1} ${y1}C${x1 + xDirection * xHandle} ${y1},${x2} ${y2 - yDirection * yHandle},${x2} ${y2}`; +function graphParentBranchPath(fromCol: number, toCol: number) { + if (fromCol === toCol) return ""; + const circleX = graphLaneX(fromCol); + const branchX = GRAPH_SWIMLANE_WIDTH * toCol; + const parentX = graphLaneX(toCol); + return [ + `M ${branchX} ${GRAPH_DOT_Y}`, + `A ${GRAPH_SWIMLANE_WIDTH} ${GRAPH_SWIMLANE_WIDTH} 0 0 1 ${parentX} ${GRAPH_SVG_HEIGHT}`, + `M ${branchX} ${GRAPH_DOT_Y}`, + `H ${circleX}`, + ].join(" "); +} + +function graphCircleColor(row: GraphRow) { + const lane = row.outputLanes[row.commitCol] ?? row.inputLanes[row.commitCol]; + return graphColor(lane?.color ?? row.commitColor); } function orderedCommitRefs(refs: readonly string[]) { @@ -1689,23 +2406,46 @@ function CommitRefTags({ function GitGraphCommitMarker({ cx, color, + isHead, isMerge, - localOnly, }: { cx: number; color: string; + isHead: boolean; isMerge: boolean; - localOnly: boolean; }) { + if (isHead) { + return ( + + + + + ); + } + if (!isMerge) { return ( ); } @@ -1715,140 +2455,190 @@ function GitGraphCommitMarker({ - - {localOnly ? null : ( - - )} ); } -function GitGraphSvgCell({ - row, - isFirst, - isLast, - isMerge, - localOnly, -}: { - row: GraphRow; - isFirst: boolean; - isLast: boolean; - isMerge: boolean; - localOnly: boolean; -}) { - const drawW = graphDrawWidth(row); +function GitGraphSvgCell({ row }: { row: GraphRow }) { const layoutW = graphLayoutWidth(row); const cx = graphLaneX(row.commitCol); - const commitColor = GRAPH_COLORS[row.commitColor % GRAPH_COLORS.length]; + const commitColor = graphCircleColor(row); + const commitInputColor = graphColor(row.commitColor); + let outputIndex = 0; return ( -
+
); } -function GitGraphContinuationCell({ row, isLast }: { row: GraphRow; isLast: boolean }) { - const layoutW = graphContinuationWidth(row); - const pipes = isLast ? [] : row.bottomPipes; +function GitGraphContinuationCell({ row }: { row: GraphRow }) { + const layoutW = graphLayoutWidth(row); return ( ); } @@ -1953,6 +2743,9 @@ export function GitReviewPanel(props: { const [reviewMode, setReviewMode] = useState("changes"); const [historyCommits, setHistoryCommits] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); + const [historyLoadingMore, setHistoryLoadingMore] = useState(false); + const [historyHasMore, setHistoryHasMore] = useState(false); + const [historyLoadMoreError, setHistoryLoadMoreError] = useState(""); const [historyError, setHistoryError] = useState(""); const [selectedCommitSha, setSelectedCommitSha] = useState(""); const [selectedCommitFilePath, setSelectedCommitFilePath] = useState(""); @@ -1983,6 +2776,8 @@ export function GitReviewPanel(props: { const selectedPathRef = useRef(""); const selectedCommitShaRef = useRef(""); const selectedCommitFilePathRef = useRef(""); + const historyCommitsRef = useRef([]); + const historyHasMoreRef = useRef(false); const expandedCommitShasRef = useRef>(new Set()); const reviewModeRef = useRef("changes"); const diffRequestIdRef = useRef(0); @@ -2046,6 +2841,11 @@ export function GitReviewPanel(props: { setBusy(""); }, []); + const setHistoryHasMoreValue = useCallback((value: boolean) => { + historyHasMoreRef.current = value; + setHistoryHasMore(value); + }, []); + useEffect(() => { selectedPathRef.current = selectedPath; }, [selectedPath]); @@ -2062,6 +2862,14 @@ export function GitReviewPanel(props: { selectedCommitFilePathRef.current = selectedCommitFilePath; }, [selectedCommitFilePath]); + useEffect(() => { + historyCommitsRef.current = historyCommits; + }, [historyCommits]); + + useEffect(() => { + historyHasMoreRef.current = historyHasMore; + }, [historyHasMore]); + useEffect(() => { expandedCommitShasRef.current = expandedCommitShas; }, [expandedCommitShas]); @@ -2316,14 +3124,20 @@ export function GitReviewPanel(props: { const loadHistory = useCallback( async (options: GitRefreshOptions = {}) => { + const append = options.append === true && historyCommitsRef.current.length > 0; + if (append && !historyHasMoreRef.current) { + return; + } if (historyInFlightRef.current) { return; } historyInFlightRef.current = true; const silent = options.silent === true; const force = options.force !== false; + const skip = append ? historyCommitsRef.current.length : 0; if (!gitClient || !cwd.trim()) { historySignatureRef.current = ""; + historyCommitsRef.current = []; setHistoryCommits([]); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; @@ -2331,24 +3145,66 @@ export function GitReviewPanel(props: { setSelectedCommitSha(""); setSelectedCommitFilePath(""); setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + setHistoryLoadMoreError(""); + setHistoryLoading(false); + setHistoryLoadingMore(false); clearCommitDiff(); setHistoryError(""); historyInFlightRef.current = false; return; } - if (!silent) { + if (append) { + setHistoryLoadingMore(true); + setHistoryLoadMoreError(""); + } else if (!silent) { setHistoryLoading(true); setHistoryError(""); + setHistoryLoadMoreError(""); } try { - const response = await gitClient.log(cwd, GIT_HISTORY_LIMIT); - const nextSignature = gitHistorySignature(response.state, response.commits); - const historyChanged = historySignatureRef.current !== nextSignature; + const response = await gitClient.log(cwd, { + limit: GIT_HISTORY_PAGE_SIZE, + skip, + }); + const pageHasMore = response.commits.length >= GIT_HISTORY_PAGE_SIZE; const previousStatusSignature = statusSignatureRef.current; const nextStatusSignature = gitRepositoryStateSignature(response.state); const statusChanged = previousStatusSignature !== nextStatusSignature; - historySignatureRef.current = nextSignature; statusSignatureRef.current = nextStatusSignature; + + if (append) { + setState(response.state); + if (response.state.status !== "ready") { + historySignatureRef.current = ""; + historyCommitsRef.current = []; + setHistoryCommits([]); + selectedCommitShaRef.current = ""; + selectedCommitFilePathRef.current = ""; + expandedCommitShasRef.current = new Set(); + setSelectedCommitSha(""); + setSelectedCommitFilePath(""); + setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + clearCommitDiff(); + return; + } + const existingCommits = historyCommitsRef.current; + const existingShas = new Set(existingCommits.map((commit) => commit.sha)); + const nextCommits = [ + ...existingCommits, + ...response.commits.filter((commit) => !existingShas.has(commit.sha)), + ]; + historyCommitsRef.current = nextCommits; + setHistoryCommits(nextCommits); + setHistoryHasMoreValue(pageHasMore); + setHistoryLoadMoreError(""); + return; + } + + const nextSignature = gitHistorySignature(response.state, response.commits); + const historyChanged = historySignatureRef.current !== nextSignature; + historySignatureRef.current = nextSignature; if (historyChanged) { commitDetailsCacheRef.current.clear(); } @@ -2356,10 +3212,20 @@ export function GitReviewPanel(props: { suppressNextGitChangedRef.current = true; dispatchGitChanged(cwd); } + setHistoryHasMoreValue( + !force && + !historyChanged && + historyCommitsRef.current.length > response.commits.length && + !historyHasMoreRef.current + ? false + : pageHasMore, + ); + setHistoryLoadMoreError(""); if (!force && !historyChanged) { return; } setState(response.state); + historyCommitsRef.current = response.commits; setHistoryCommits(response.commits); if (response.state.status !== "ready" || response.commits.length === 0) { selectedCommitShaRef.current = ""; @@ -2368,6 +3234,7 @@ export function GitReviewPanel(props: { setSelectedCommitSha(""); setSelectedCommitFilePath(""); setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); clearCommitDiff(); return; } @@ -2397,7 +3264,12 @@ export function GitReviewPanel(props: { clearCommitDiff(); } } catch (err) { - if (!silent || force) { + const message = err instanceof Error ? err.message : String(err); + if (append) { + setHistoryLoadMoreError(message); + setHistoryHasMoreValue(true); + } else if (!silent || force) { + historyCommitsRef.current = []; setHistoryCommits([]); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; @@ -2405,17 +3277,48 @@ export function GitReviewPanel(props: { setSelectedCommitSha(""); setSelectedCommitFilePath(""); setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + setHistoryLoadMoreError(""); clearCommitDiff(); - setHistoryError(err instanceof Error ? err.message : String(err)); + setHistoryError(message); } } finally { historyInFlightRef.current = false; - if (!silent) { + if (append) { + setHistoryLoadingMore(false); + } else if (!silent) { setHistoryLoading(false); } } }, - [clearCommitDiff, cwd, gitClient, loadCommitDiff], + [clearCommitDiff, cwd, gitClient, loadCommitDiff, setHistoryHasMoreValue], + ); + + const maybeLoadMoreHistory = useCallback( + (element: HTMLElement | null) => { + if ( + !element || + !(useSplitReviewLayout || historyStackedPane === "list") || + !historyHasMoreRef.current || + historyInFlightRef.current || + historyLoadMoreError + ) { + return; + } + const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + if (distanceToBottom <= GIT_HISTORY_LOAD_MORE_SCROLL_THRESHOLD_PX) { + void loadHistory({ append: true, silent: true }); + } + }, + [historyLoadMoreError, historyStackedPane, loadHistory, useSplitReviewLayout], + ); + + const handleHistoryListScroll = useCallback( + (event: ReactUIEvent) => { + handleGitReviewTransientScroll(event); + maybeLoadMoreHistory(event.currentTarget); + }, + [maybeLoadMoreHistory], ); useEffect(() => { @@ -2735,7 +3638,15 @@ export function GitReviewPanel(props: { ? gitHubCommitUrl(state.remoteUrl, historyContextCommit.sha) : ""; - const gitGraph = useMemo(() => computeGitGraph(historyCommits), [historyCommits]); + const gitGraph = useMemo( + () => + computeGitGraph(historyCommits, { + currentRef: state.head, + remoteRef: state.upstream, + remoteName: state.remoteName, + }), + [historyCommits, state.head, state.remoteName, state.upstream], + ); const historyRows = useMemo(() => { const rows: GitHistoryRow[] = []; historyCommits.forEach((commit, commitIndex) => { @@ -2746,21 +3657,32 @@ export function GitReviewPanel(props: { }); } }); + if (historyHasMore || historyLoadingMore || historyLoadMoreError) { + rows.push({ type: "loadMore" }); + } return rows; - }, [expandedCommitShas, historyCommits]); + }, [expandedCommitShas, historyCommits, historyHasMore, historyLoadMoreError, historyLoadingMore]); const historyVirtualizer = useVirtualizer({ count: historyRows.length, getScrollElement: () => historyListRef.current, - estimateSize: (index) => (historyRows[index]?.type === "file" ? 26 : 28), + estimateSize: () => 22, overscan: 8, getItemKey: (index) => { const row = historyRows[index]; if (!row) return index; if (row.type === "commit") return `commit:${row.commit.sha}`; + if (row.type === "loadMore") return "load-more"; return `file:${row.commit.sha}:${row.file.status}:${row.file.oldPath ?? ""}:${row.file.path}`; }, }); + useEffect(() => { + if (reviewMode !== "history") { + return; + } + maybeLoadMoreHistory(historyListRef.current); + }, [historyRows.length, maybeLoadMoreHistory, reviewMode]); + useEffect(() => { if (!changeContextMenu) return; const closeMenu = () => setChangeContextMenu(null); @@ -3440,6 +4362,7 @@ export function GitReviewPanel(props: { size="sm" variant="ghost" disabled={loading || historyLoading || operationBusy} + className="h-7 w-7 px-0" title={t("projectTools.gitReview.refresh")} aria-label={t("projectTools.gitReview.refresh")} onClick={() => { @@ -3460,22 +4383,30 @@ export function GitReviewPanel(props: { variant="ghost" disabled={writeDisabled || operationBusy} title={t("projectTools.gitReview.fetch")} - className="gap-1.5" + aria-label={t("projectTools.gitReview.fetch")} + className="h-7 w-7 px-0" onClick={() => void runOperation("fetch", () => gitClient!.fetch(cwd), "fetch")} > - {busy === "fetch" ? : null} - {t("projectTools.gitReview.fetch")} + {busy === "fetch" ? ( + + ) : ( + + )} +
+ ); + } if (row.type === "file") { const TypeIcon = getFileTypeIcon(row.file.path, "file"); const fileSelected = @@ -3856,53 +4818,40 @@ export function GitReviewPanel(props: { className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${virtualRow.start}px)` }} > -
${row.file.path}` + : row.file.path + } onContextMenu={(event) => openHistoryFileContextMenu(event, row.commit, row.file) } + onClick={() => selectCommitFile(row.commit, row.file)} > {graphRow ? ( - + ) : null} - -
+ {commitFileStatusLabel(row.file)} + +
); } @@ -3921,38 +4870,24 @@ export function GitReviewPanel(props: { className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${virtualRow.start}px)` }} > -
openHistoryCommitContextMenu(event, commit)} + onClick={() => selectCommit(commit)} > {graphRow ? ( - 1} - localOnly={commit.localOnly} - /> + ) : null} - -
+ + {commit.subject || commit.shortSha} + + +
); })} diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 8b1a4819..54efb748 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -346,6 +346,9 @@ export const translations: Record> = { "projectTools.gitReview.detailPane": "详情视图", "projectTools.gitReview.commitHistoryTitle": "提交历史", "projectTools.gitReview.noCommitHistory": "没有提交历史。", + "projectTools.gitReview.loadMoreCommits": "加载更多", + "projectTools.gitReview.loadingMoreCommits": "正在加载更多...", + "projectTools.gitReview.loadMoreCommitsFailed": "加载失败,点击重试", "projectTools.gitReview.openChange": "打开更改", "projectTools.gitReview.openOnGithub": "在 GitHub 上打开", "projectTools.gitReview.createBranch": "创建分支", @@ -1508,6 +1511,9 @@ export const translations: Record> = { "projectTools.gitReview.detailPane": "Detail view", "projectTools.gitReview.commitHistoryTitle": "Commit History", "projectTools.gitReview.noCommitHistory": "No commit history.", + "projectTools.gitReview.loadMoreCommits": "Load more", + "projectTools.gitReview.loadingMoreCommits": "Loading more...", + "projectTools.gitReview.loadMoreCommitsFailed": "Load failed, click to retry", "projectTools.gitReview.openChange": "Open Change", "projectTools.gitReview.openOnGithub": "Open on GitHub", "projectTools.gitReview.createBranch": "Create Branch", diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index 3f4752c2..307d7366 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -99,6 +99,11 @@ --checkpoint-border: 0 0% 0% / 0.06; --checkpoint-icon-bg: 220 10% 95%; --checkpoint-icon-fg: 220 9% 46%; + + /* VS Code SCM graph reference colors */ + --git-review-graph-ref-local: #0063d3; + --git-review-graph-ref-remote: #652d90; + --git-review-graph-ref-base: #ea5c00; } .dark { @@ -166,6 +171,11 @@ --checkpoint-border: 0 0% 100% / 0.14; --checkpoint-icon-bg: 220 12% 22%; --checkpoint-icon-fg: 220 14% 76%; + + /* VS Code SCM graph reference colors (dark) */ + --git-review-graph-ref-local: #59a4f9; + --git-review-graph-ref-remote: #b180d7; + --git-review-graph-ref-base: #ea5c00; } } @@ -301,12 +311,36 @@ 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 { + color: inherit; + background: transparent; + --git-review-graph-background: hsl(var(--background)); + } + + .git-review-history-row:hover:not([data-selected="true"]):not([data-context-open="true"]) { + background: hsl(var(--muted) / 0.38); + --git-review-graph-background: hsl(var(--muted) / 0.38); + } + + .git-review-history-row[data-selected="true"] { + background: hsl(var(--accent) / 0.8); + color: hsl(var(--accent-foreground)); + --git-review-graph-background: hsl(var(--accent) / 0.8); + } + + .git-review-history-row[data-context-open="true"] { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--foreground)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.35); + --git-review-graph-background: hsl(var(--primary) / 0.1); } /* Git review tab & pane transition animations */ @@ -1939,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; +} diff --git a/crates/agent-gui/src/lib/git/gitGraph.ts b/crates/agent-gui/src/lib/git/gitGraph.ts index 053d45e5..6b82fe3a 100644 --- a/crates/agent-gui/src/lib/git/gitGraph.ts +++ b/crates/agent-gui/src/lib/git/gitGraph.ts @@ -1,123 +1,182 @@ export const GRAPH_COLORS = [ - "#3b82f6", - "#10b981", - "#ef4444", - "#8b5cf6", - "#f59e0b", - "#06b6d4", - "#f97316", - "#ec4899", + "#ffb000", + "#dc267f", + "#994f00", + "#40b0a6", + "#b66dff", ]; -export type GraphPipe = { - fromCol: number; - toCol: number; - color: number; +export const GRAPH_REF_COLORS = { + local: "var(--git-review-graph-ref-local)", + remote: "var(--git-review-graph-ref-remote)", + base: "var(--git-review-graph-ref-base)", +} as const; + +export type GraphColor = number | string; + +export type GraphLane = { + id: string; + color: GraphColor; }; export type GraphRow = { + sha: string; + parents: string[]; commitCol: number; - commitColor: number; - topPipes: GraphPipe[]; - bottomPipes: GraphPipe[]; + commitColor: GraphColor; + inputLanes: GraphLane[]; + outputLanes: GraphLane[]; + isHead: boolean; + isMerge: boolean; }; -type Wire = { sha: string; color: number } | null; +type GitGraphCommit = { + sha: string; + parents: readonly string[]; + refs?: readonly string[]; +}; -export function computeGitGraph(commits: readonly { sha: string; parents: string[] }[]): { - rows: GraphRow[]; - maxCols: number; -} { - if (commits.length === 0) return { rows: [], maxCols: 0 }; +export type GitGraphOptions = { + currentRef?: string; + remoteRef?: string; + baseRef?: string; + remoteName?: string; +}; - const shaSet = new Set(commits.map((c) => c.sha)); - const rows: GraphRow[] = []; - const wires: Wire[] = []; - let nextColor = 0; - let globalMaxCols = 1; +function cloneLane(lane: GraphLane): GraphLane { + return { ...lane }; +} - function allocColor(): number { - return nextColor++ % GRAPH_COLORS.length; +function normalizeRef(value: string) { + let ref = value.trim(); + if (!ref) return ""; + if (ref.startsWith("refs/heads/")) { + ref = ref.slice("refs/heads/".length); + } else if (ref.startsWith("refs/remotes/")) { + ref = ref.slice("refs/remotes/".length); + } else if (ref.startsWith("refs/tags/")) { + ref = ref.slice("refs/tags/".length); } + return ref; +} - function findFreeSlot(): number { - const idx = wires.indexOf(null); - if (idx >= 0) return idx; - wires.push(null); - return wires.length - 1; - } +function createRefColorMap(options: GitGraphOptions) { + const map = new Map(); + const currentRef = normalizeRef(options.currentRef ?? ""); + const remoteRef = normalizeRef(options.remoteRef ?? ""); + const baseRef = normalizeRef(options.baseRef ?? ""); + const remoteName = normalizeRef(options.remoteName ?? ""); - function trimWires() { - while (wires.length > 0 && wires[wires.length - 1] === null) wires.pop(); + if (currentRef) { + map.set(currentRef, GRAPH_REF_COLORS.local); + } + if (remoteRef) { + map.set(remoteRef, GRAPH_REF_COLORS.remote); + } + if (remoteName && currentRef) { + map.set(`${remoteName}/${currentRef}`, GRAPH_REF_COLORS.remote); + } + if (baseRef) { + map.set(baseRef, GRAPH_REF_COLORS.base); } - for (let r = 0; r < commits.length; r++) { - const commit = commits[r]; - const parents = commit.parents.filter((p) => shaSet.has(p)); - - const prevWires: Wire[] = wires.map((w) => (w ? { ...w } : null)); - - const matchCols: number[] = []; - for (let i = 0; i < wires.length; i++) { - if (wires[i]?.sha === commit.sha) matchCols.push(i); - } + return map; +} - let commitCol: number; - let commitColor: number; +function labelColorForCommit( + commit: GitGraphCommit | undefined, + refColorMap: Map, +): GraphColor | undefined { + for (const rawRef of commit?.refs ?? []) { + const color = refColorMap.get(normalizeRef(rawRef)); + if (color !== undefined) return color; + } + return undefined; +} - if (matchCols.length > 0) { - commitCol = matchCols[0]; - commitColor = wires[commitCol]?.color ?? 0; - for (const mc of matchCols) wires[mc] = null; - } else { - commitCol = findFreeSlot(); - commitColor = allocColor(); - } +function uniqueParents(parents: readonly string[]) { + const seen = new Set(); + const result: string[] = []; + for (const rawParent of parents) { + const parent = rawParent.trim(); + if (!parent || seen.has(parent)) continue; + seen.add(parent); + result.push(parent); + } + return result; +} - const newParentSlots = new Set(); +export function computeGitGraph( + commits: readonly GitGraphCommit[], + options: GitGraphOptions = {}, +): { + rows: GraphRow[]; + maxCols: number; +} { + if (commits.length === 0) return { rows: [], maxCols: 0 }; - if (parents.length >= 1) { - wires[commitCol] = { sha: parents[0], color: commitColor }; - } + const rows: GraphRow[] = []; + const commitBySha = new Map(commits.map((commit) => [commit.sha, commit])); + const refColorMap = createRefColorMap(options); + let nextColor = -1; + let previousOutputLanes: GraphLane[] = []; + let maxCols = 1; - for (let p = 1; p < parents.length; p++) { - const pSha = parents[p]; - if (wires.some((w) => w?.sha === pSha)) continue; - const slot = findFreeSlot(); - const color = allocColor(); - wires[slot] = { sha: pSha, color }; - newParentSlots.add(slot); - } + function allocColor(): number { + nextColor = (nextColor + 1) % GRAPH_COLORS.length; + return nextColor; + } - trimWires(); + for (let index = 0; index < commits.length; index++) { + const commit = commits[index]; + const parents = uniqueParents(commit.parents); + const inputLanes = previousOutputLanes.map(cloneLane); + const inputIndex = inputLanes.findIndex((lane) => lane.id === commit.sha); + const commitCol = inputIndex >= 0 ? inputIndex : inputLanes.length; + const labelColor = labelColorForCommit(commit, refColorMap); + const commitColor = inputIndex >= 0 ? inputLanes[inputIndex].color : (labelColor ?? allocColor()); + const outputLanes: GraphLane[] = []; + + if (parents.length > 0) { + let firstParentAdded = false; + for (const lane of inputLanes) { + if (lane.id === commit.sha) { + if (!firstParentAdded) { + outputLanes.push({ id: parents[0], color: labelColor ?? commitColor }); + firstParentAdded = true; + } + continue; + } + + outputLanes.push(cloneLane(lane)); + } - const topPipes: GraphPipe[] = []; - for (let i = 0; i < prevWires.length; i++) { - const pw = prevWires[i]; - if (pw === null) continue; - if (pw.sha === commit.sha) { - topPipes.push({ fromCol: i, toCol: commitCol, color: pw.color }); - } else { - topPipes.push({ fromCol: i, toCol: i, color: pw.color }); + if (!firstParentAdded) { + outputLanes.push({ id: parents[0], color: labelColor ?? commitColor }); } - } - const bottomPipes: GraphPipe[] = []; - for (let i = 0; i < wires.length; i++) { - const w = wires[i]; - if (w === null) continue; - if (newParentSlots.has(i)) { - bottomPipes.push({ fromCol: commitCol, toCol: i, color: w.color }); - } else { - bottomPipes.push({ fromCol: i, toCol: i, color: w.color }); + for (let parentIndex = 1; parentIndex < parents.length; parentIndex++) { + const parent = parents[parentIndex]; + outputLanes.push({ + id: parent, + color: labelColorForCommit(commitBySha.get(parent), refColorMap) ?? allocColor(), + }); } } - const maxCols = Math.max(prevWires.length, wires.length, commitCol + 1); - if (maxCols > globalMaxCols) globalMaxCols = maxCols; - - rows.push({ commitCol, commitColor, topPipes, bottomPipes }); + maxCols = Math.max(maxCols, inputLanes.length, outputLanes.length, commitCol + 1); + rows.push({ + sha: commit.sha, + parents, + commitCol, + commitColor, + inputLanes, + outputLanes, + isHead: index === 0, + isMerge: parents.length > 1, + }); + previousOutputLanes = outputLanes; } - return { rows, maxCols: globalMaxCols }; + return { rows, maxCols }; } diff --git a/crates/agent-gui/src/lib/git/tauriGitClient.ts b/crates/agent-gui/src/lib/git/tauriGitClient.ts index 8e887a19..9b7b85b4 100644 --- a/crates/agent-gui/src/lib/git/tauriGitClient.ts +++ b/crates/agent-gui/src/lib/git/tauriGitClient.ts @@ -42,8 +42,11 @@ export const tauriGitClient: GitClient = { async diff(workdir, mode, path) { return normalizeGitDiffResponse(await invoke("git_diff", { workdir, mode, path })); }, - async log(workdir, limit) { - return normalizeGitLogResponse(await invoke("git_log", { workdir, limit }), workdir); + async log(workdir, options = {}) { + return normalizeGitLogResponse( + await invoke("git_log", { workdir, limit: options.limit, skip: options.skip }), + workdir, + ); }, async commitDetails(workdir, commit) { return normalizeGitCommitDetailsResponse( diff --git a/crates/agent-gui/src/lib/git/types.ts b/crates/agent-gui/src/lib/git/types.ts index 86b8d328..ffab07e7 100644 --- a/crates/agent-gui/src/lib/git/types.ts +++ b/crates/agent-gui/src/lib/git/types.ts @@ -120,6 +120,11 @@ export type GitInitOptions = { userEmail?: string; }; +export type GitLogOptions = { + limit?: number; + skip?: number; +}; + export type GitClient = { status(workdir: string): Promise; branches(workdir: string): Promise; @@ -127,7 +132,7 @@ export type GitClient = { switchBranch(workdir: string, branch: string, kind?: string): Promise; createBranch(workdir: string, branch: string, startPoint?: string): Promise; diff(workdir: string, mode: "branch" | "working_tree", path?: string): Promise; - log(workdir: string, limit?: number): Promise; + log(workdir: string, options?: GitLogOptions): Promise; commitDetails(workdir: string, commit: string): Promise; compareCommitWithRemote(workdir: string, commit: string): Promise; commitDiff(workdir: string, commit: string, path?: string): Promise; diff --git a/crates/agent-gui/test/tools/git-graph.test.mjs b/crates/agent-gui/test/tools/git-graph.test.mjs new file mode 100644 index 00000000..bacd8d37 --- /dev/null +++ b/crates/agent-gui/test/tools/git-graph.test.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const guiRoot = fileURLToPath(new URL("../..", import.meta.url)); +const graphModules = { + gui: createTsModuleLoader().loadModule("src/lib/git/gitGraph.ts"), + web: createTsModuleLoader({ + rootDir: path.resolve(guiRoot, "..", "agent-gateway", "web"), + }).loadModule("src/lib/git/gitGraph.ts"), +}; + +function simplifyRows(rows) { + return rows.map((row) => ({ + sha: row.sha, + parents: row.parents, + commitCol: row.commitCol, + commitColor: row.commitColor, + inputLanes: row.inputLanes, + outputLanes: row.outputLanes, + isHead: row.isHead, + isMerge: row.isMerge, + })); +} + +for (const [surface, graph] of Object.entries(graphModules)) { + test(`${surface} git graph uses VS Code source control graph colors`, () => { + assert.deepEqual(graph.GRAPH_COLORS, [ + "#ffb000", + "#dc267f", + "#994f00", + "#40b0a6", + "#b66dff", + ]); + }); + + test(`${surface} git graph exposes VS Code ref semantic colors`, () => { + assert.deepEqual(graph.GRAPH_REF_COLORS, { + local: "var(--git-review-graph-ref-local)", + remote: "var(--git-review-graph-ref-remote)", + base: "var(--git-review-graph-ref-base)", + }); + }); + + test(`${surface} git graph builds linear swimlanes`, () => { + const result = graph.computeGitGraph([ + { sha: "c", parents: ["b"] }, + { sha: "b", parents: ["a"] }, + { sha: "a", parents: [] }, + ]); + + assert.equal(result.maxCols, 1); + assert.deepEqual(simplifyRows(result.rows), [ + { + sha: "c", + parents: ["b"], + commitCol: 0, + commitColor: 0, + inputLanes: [], + outputLanes: [{ id: "b", color: 0 }], + isHead: true, + isMerge: false, + }, + { + sha: "b", + parents: ["a"], + commitCol: 0, + commitColor: 0, + inputLanes: [{ id: "b", color: 0 }], + outputLanes: [{ id: "a", color: 0 }], + isHead: false, + isMerge: false, + }, + { + sha: "a", + parents: [], + commitCol: 0, + commitColor: 0, + inputLanes: [{ id: "a", color: 0 }], + outputLanes: [], + isHead: false, + isMerge: false, + }, + ]); + }); + + test(`${surface} git graph preserves merge branch lanes and base joins`, () => { + const result = graph.computeGitGraph([ + { sha: "m", parents: ["a", "b"] }, + { sha: "a", parents: ["r"] }, + { sha: "b", parents: ["r"] }, + { sha: "r", parents: [] }, + ]); + + assert.equal(result.maxCols, 2); + assert.deepEqual(simplifyRows(result.rows), [ + { + sha: "m", + parents: ["a", "b"], + commitCol: 0, + commitColor: 0, + inputLanes: [], + outputLanes: [ + { id: "a", color: 0 }, + { id: "b", color: 1 }, + ], + isHead: true, + isMerge: true, + }, + { + sha: "a", + parents: ["r"], + commitCol: 0, + commitColor: 0, + inputLanes: [ + { id: "a", color: 0 }, + { id: "b", color: 1 }, + ], + outputLanes: [ + { id: "r", color: 0 }, + { id: "b", color: 1 }, + ], + isHead: false, + isMerge: false, + }, + { + sha: "b", + parents: ["r"], + commitCol: 1, + commitColor: 1, + inputLanes: [ + { id: "r", color: 0 }, + { id: "b", color: 1 }, + ], + outputLanes: [ + { id: "r", color: 0 }, + { id: "r", color: 1 }, + ], + isHead: false, + isMerge: false, + }, + { + sha: "r", + parents: [], + commitCol: 0, + commitColor: 0, + inputLanes: [ + { id: "r", color: 0 }, + { id: "r", color: 1 }, + ], + outputLanes: [], + isHead: false, + isMerge: false, + }, + ]); + }); + + test(`${surface} git graph colors local and remote refs like VS Code`, () => { + const result = graph.computeGitGraph( + [ + { sha: "tip", parents: ["merge", "side"] }, + { sha: "merge", parents: ["base"], refs: ["main"] }, + { sha: "side", parents: ["base"], refs: ["origin/side"] }, + { sha: "base", parents: [] }, + ], + { + currentRef: "main", + remoteRef: "origin/side", + remoteName: "origin", + }, + ); + + assert.deepEqual(simplifyRows(result.rows), [ + { + sha: "tip", + parents: ["merge", "side"], + commitCol: 0, + commitColor: 0, + inputLanes: [], + outputLanes: [ + { id: "merge", color: 0 }, + { id: "side", color: graph.GRAPH_REF_COLORS.remote }, + ], + isHead: true, + isMerge: true, + }, + { + sha: "merge", + parents: ["base"], + commitCol: 0, + commitColor: 0, + inputLanes: [ + { id: "merge", color: 0 }, + { id: "side", color: graph.GRAPH_REF_COLORS.remote }, + ], + outputLanes: [ + { id: "base", color: graph.GRAPH_REF_COLORS.local }, + { id: "side", color: graph.GRAPH_REF_COLORS.remote }, + ], + isHead: false, + isMerge: false, + }, + { + sha: "side", + parents: ["base"], + commitCol: 1, + commitColor: graph.GRAPH_REF_COLORS.remote, + inputLanes: [ + { id: "base", color: graph.GRAPH_REF_COLORS.local }, + { id: "side", color: graph.GRAPH_REF_COLORS.remote }, + ], + outputLanes: [ + { id: "base", color: graph.GRAPH_REF_COLORS.local }, + { id: "base", color: graph.GRAPH_REF_COLORS.remote }, + ], + isHead: false, + isMerge: false, + }, + { + sha: "base", + parents: [], + commitCol: 0, + commitColor: graph.GRAPH_REF_COLORS.local, + inputLanes: [ + { id: "base", color: graph.GRAPH_REF_COLORS.local }, + { id: "base", color: graph.GRAPH_REF_COLORS.remote }, + ], + outputLanes: [], + isHead: false, + isMerge: false, + }, + ]); + }); + + test(`${surface} git graph keeps an already-active merge parent as a new VS Code lane`, () => { + const result = graph.computeGitGraph([ + { sha: "tip", parents: ["merge", "side"] }, + { sha: "merge", parents: ["base", "side"] }, + ]); + + assert.equal(result.maxCols, 3); + assert.deepEqual(simplifyRows(result.rows), [ + { + sha: "tip", + parents: ["merge", "side"], + commitCol: 0, + commitColor: 0, + inputLanes: [], + outputLanes: [ + { id: "merge", color: 0 }, + { id: "side", color: 1 }, + ], + isHead: true, + isMerge: true, + }, + { + sha: "merge", + parents: ["base", "side"], + commitCol: 0, + commitColor: 0, + inputLanes: [ + { id: "merge", color: 0 }, + { id: "side", color: 1 }, + ], + outputLanes: [ + { id: "base", color: 0 }, + { id: "side", color: 1 }, + { id: "side", color: 2 }, + ], + isHead: false, + isMerge: true, + }, + ]); + }); + + test(`${surface} git graph normalizes duplicate parent ids`, () => { + const result = graph.computeGitGraph([{ sha: "m", parents: ["a", "a", "b", ""] }]); + + assert.deepEqual(result.rows[0].parents, ["a", "b"]); + assert.deepEqual(result.rows[0].outputLanes, [ + { id: "a", color: 0 }, + { id: "b", color: 1 }, + ]); + }); +} + +test("GUI and WebUI git graph modules stay in parity", () => { + const commits = [ + { sha: "m", parents: ["a", "b"] }, + { sha: "a", parents: ["r"] }, + { sha: "b", parents: ["r"] }, + { sha: "r", parents: [] }, + ]; + + assert.deepEqual( + graphModules.gui.computeGitGraph(commits), + graphModules.web.computeGitGraph(commits), + ); +});