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 38647676..eff25464 100644 --- a/src/features/git/components/actions-menu.tsx +++ b/src/features/git/components/actions-menu.tsx @@ -9,16 +9,20 @@ import { Tag, Upload, } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useSettingsStore } from "@/features/settings/store"; import { ContextMenu, type ContextMenuItem } from "@/ui/context-menu"; 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; @@ -31,7 +35,7 @@ interface GitActionsMenuProps { const GitActionsMenu = ({ isOpen, - position, + anchorRect, onClose, hasGitRepo, repoPath, @@ -42,9 +46,47 @@ 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 confirmBeforeDiscard = useSettingsStore((state) => state.settings.confirmBeforeDiscard); + 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; @@ -109,7 +151,7 @@ const GitActionsMenu = ({ onClose(); }; - if (!isOpen || !position) { + if (!isOpen || !anchorRect) { return null; } @@ -194,7 +236,20 @@ const GitActionsMenu = ({ }, ]; - return ; + return ( + + ); }; export default GitActionsMenu; diff --git a/src/features/git/components/view.tsx b/src/features/git/components/view.tsx index c582ceab..21fdf180 100644 --- a/src/features/git/components/view.tsx +++ b/src/features/git/components/view.tsx @@ -18,6 +18,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"; @@ -62,10 +63,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); @@ -345,7 +345,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const handleClickOutside = () => { setShowGitActionsMenu(false); - setGitActionsMenuPosition(null); + setGitActionsMenuAnchor(null); }; document.addEventListener("mousedown", handleClickOutside); @@ -559,9 +559,13 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {