From f7fed27c9d7a2f80e18dc37715b7eaf7e92e41d9 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:29:36 +0700 Subject: [PATCH] Fix: clamp git actions menu to viewport --- .../components/actions-menu-position.test.ts | 91 +++++++++++++++++++ .../git/components/actions-menu-position.ts | 59 ++++++++++++ src/features/git/components/actions-menu.tsx | 56 ++++++++++-- src/features/git/components/view.tsx | 24 +++-- 4 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 src/features/git/components/actions-menu-position.test.ts create mode 100644 src/features/git/components/actions-menu-position.ts diff --git a/src/features/git/components/actions-menu-position.test.ts b/src/features/git/components/actions-menu-position.test.ts new file mode 100644 index 00000000..5a1d4db3 --- /dev/null +++ b/src/features/git/components/actions-menu-position.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { resolveGitActionsMenuPosition } from "./actions-menu-position"; + +describe("resolveGitActionsMenuPosition", () => { + const viewport = { width: 320, height: 240 }; + const menuSize = { width: 200, height: 160 }; + + test("right-aligns the menu to the trigger when there is room", () => { + expect( + resolveGitActionsMenuPosition({ + anchorRect: { + left: 140, + right: 180, + top: 40, + bottom: 60, + width: 40, + height: 20, + }, + menuSize, + viewport, + }), + ).toEqual({ + left: 8, + top: 66, + direction: "down", + }); + }); + + test("clamps the menu inside the viewport near the right edge", () => { + expect( + resolveGitActionsMenuPosition({ + anchorRect: { + left: 292, + right: 312, + top: 40, + bottom: 60, + width: 20, + height: 20, + }, + menuSize, + viewport, + }), + ).toEqual({ + left: 112, + top: 66, + direction: "down", + }); + }); + + test("opens upward when there is not enough room below", () => { + expect( + resolveGitActionsMenuPosition({ + anchorRect: { + left: 292, + right: 312, + top: 210, + bottom: 230, + width: 20, + height: 20, + }, + menuSize, + viewport, + }), + ).toEqual({ + left: 112, + top: 44, + direction: "up", + }); + }); + + test("clamps upward-opening menus inside the top margin when needed", () => { + expect( + resolveGitActionsMenuPosition({ + anchorRect: { + left: 100, + right: 120, + top: 20, + bottom: 40, + width: 20, + height: 20, + }, + menuSize: { width: 200, height: 260 }, + viewport, + }), + ).toEqual({ + left: 8, + top: 8, + direction: "down", + }); + }); +}); diff --git a/src/features/git/components/actions-menu-position.ts b/src/features/git/components/actions-menu-position.ts new file mode 100644 index 00000000..70cf2bbc --- /dev/null +++ b/src/features/git/components/actions-menu-position.ts @@ -0,0 +1,59 @@ +export interface GitActionsMenuAnchorRect { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} + +interface ResolveGitActionsMenuPositionInput { + anchorRect: GitActionsMenuAnchorRect; + menuSize: { + width: number; + height: number; + }; + viewport: { + width: number; + height: number; + }; + margin?: number; + gap?: number; +} + +export interface ResolvedGitActionsMenuPosition { + left: number; + top: number; + direction: "up" | "down"; +} + +const DEFAULT_MARGIN = 8; +const DEFAULT_GAP = 6; + +export function resolveGitActionsMenuPosition({ + anchorRect, + menuSize, + viewport, + margin = DEFAULT_MARGIN, + gap = DEFAULT_GAP, +}: ResolveGitActionsMenuPositionInput): ResolvedGitActionsMenuPosition { + const leftCandidate = anchorRect.right - menuSize.width; + const maxLeft = Math.max(margin, viewport.width - menuSize.width - margin); + const left = Math.min(Math.max(leftCandidate, margin), maxLeft); + + const availableBelow = viewport.height - anchorRect.bottom - margin; + const availableAbove = anchorRect.top - margin; + const shouldOpenUp = availableBelow < menuSize.height + gap && availableAbove > availableBelow; + + const topCandidate = shouldOpenUp + ? anchorRect.top - menuSize.height - gap + : anchorRect.bottom + gap; + const maxTop = Math.max(margin, viewport.height - menuSize.height - margin); + const top = Math.min(Math.max(topCandidate, margin), maxTop); + + return { + left, + top, + direction: shouldOpenUp ? "up" : "down", + }; +} diff --git a/src/features/git/components/actions-menu.tsx b/src/features/git/components/actions-menu.tsx index a6034a7f..90c7751a 100644 --- a/src/features/git/components/actions-menu.tsx +++ b/src/features/git/components/actions-menu.tsx @@ -9,14 +9,18 @@ import { Tag, Upload, } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { fetchChanges, pullChanges, pushChanges } from "../api/remotes"; import { discardAllChanges, initRepository } from "../api/status"; import { useGitStore } from "../stores/git-store"; +import { + type GitActionsMenuAnchorRect, + resolveGitActionsMenuPosition, +} from "./actions-menu-position"; interface GitActionsMenuProps { isOpen: boolean; - position: { x: number; y: number } | null; + anchorRect: GitActionsMenuAnchorRect | null; onClose: () => void; hasGitRepo: boolean; repoPath?: string; @@ -29,7 +33,7 @@ interface GitActionsMenuProps { const GitActionsMenu = ({ isOpen, - position, + anchorRect, onClose, hasGitRepo, repoPath, @@ -40,8 +44,46 @@ const GitActionsMenu = ({ isSelectingRepository, }: GitActionsMenuProps) => { const [isLoading, setIsLoading] = useState(false); + const [menuPosition, setMenuPosition] = useState<{ + left: number; + top: number; + } | null>(null); + const menuRef = useRef(null); const { isRefreshing } = useGitStore(); + const updateMenuPosition = useCallback(() => { + if (!anchorRect || !menuRef.current) { + return; + } + + const rect = menuRef.current.getBoundingClientRect(); + const resolved = resolveGitActionsMenuPosition({ + anchorRect, + menuSize: { width: rect.width, height: rect.height }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + }); + + setMenuPosition({ + left: resolved.left, + top: resolved.top, + }); + }, [anchorRect]); + + useLayoutEffect(() => { + if (!isOpen || !anchorRect) { + setMenuPosition(null); + return; + } + + const frame = window.requestAnimationFrame(updateMenuPosition); + window.addEventListener("resize", updateMenuPosition); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", updateMenuPosition); + }; + }, [isOpen, anchorRect, updateMenuPosition, hasGitRepo, isLoading, isRefreshing]); + const handleAction = async (action: () => Promise, actionName: string) => { if (!repoPath) return; @@ -101,16 +143,18 @@ const GitActionsMenu = ({ onClose(); }; - if (!isOpen || !position) { + if (!isOpen || !anchorRect) { return null; } return (
{ e.stopPropagation(); diff --git a/src/features/git/components/view.tsx b/src/features/git/components/view.tsx index 75d0c5f2..1ee5a1a2 100644 --- a/src/features/git/components/view.tsx +++ b/src/features/git/components/view.tsx @@ -17,6 +17,7 @@ import type { MultiFileDiff } from "../types/diff"; import type { GitFile } from "../types/git"; import { countDiffStats } from "../utils/diff-helpers"; import GitActionsMenu from "./actions-menu"; +import type { GitActionsMenuAnchorRect } from "./actions-menu-position"; import GitBranchManager from "./branch-manager"; import GitCommitHistory from "./commit-history"; import GitCommitPanel from "./commit-panel"; @@ -56,10 +57,9 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const [isSelectingRepo, setIsSelectingRepo] = useState(false); const [repoSelectionError, setRepoSelectionError] = useState(null); const [repoMenuPosition, setRepoMenuPosition] = useState(null); - const [gitActionsMenuPosition, setGitActionsMenuPosition] = useState<{ - x: number; - y: number; - } | null>(null); + const [gitActionsMenuAnchor, setGitActionsMenuAnchor] = useState( + null, + ); const [showRemoteManager, setShowRemoteManager] = useState(false); const [showTagManager, setShowTagManager] = useState(false); @@ -263,7 +263,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const handleClickOutside = () => { setShowGitActionsMenu(false); - setGitActionsMenuPosition(null); + setGitActionsMenuAnchor(null); }; document.addEventListener("mousedown", handleClickOutside); @@ -459,9 +459,13 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {