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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/features/git/components/actions-menu-position.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
59 changes: 59 additions & 0 deletions src/features/git/components/actions-menu-position.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
65 changes: 60 additions & 5 deletions src/features/git/components/actions-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,7 +35,7 @@ interface GitActionsMenuProps {

const GitActionsMenu = ({
isOpen,
position,
anchorRect,
onClose,
hasGitRepo,
repoPath,
Expand All @@ -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<HTMLDivElement>(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<boolean>, actionName: string) => {
if (!repoPath) return;

Expand Down Expand Up @@ -109,7 +151,7 @@ const GitActionsMenu = ({
onClose();
};

if (!isOpen || !position) {
if (!isOpen || !anchorRect) {
return null;
}

Expand Down Expand Up @@ -194,7 +236,20 @@ const GitActionsMenu = ({
},
];

return <ContextMenu isOpen={isOpen} position={position} items={items} onClose={onClose} />;
return (
<ContextMenu
isOpen={isOpen}
position={{
x: menuPosition?.left ?? anchorRect.left,
y: menuPosition?.top ?? anchorRect.bottom + 6,
}}
items={items}
onClose={onClose}
style={{
visibility: menuPosition ? "visible" : "hidden",
}}
/>
);
};

export default GitActionsMenu;
24 changes: 14 additions & 10 deletions src/features/git/components/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,10 +63,9 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
const [isSelectingRepo, setIsSelectingRepo] = useState(false);
const [repoSelectionError, setRepoSelectionError] = useState<string | null>(null);
const [repoMenuPosition, setRepoMenuPosition] = useState<DropdownPosition | null>(null);
const [gitActionsMenuPosition, setGitActionsMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [gitActionsMenuAnchor, setGitActionsMenuAnchor] = useState<GitActionsMenuAnchorRect | null>(
null,
);

const [showRemoteManager, setShowRemoteManager] = useState(false);
const [showTagManager, setShowTagManager] = useState(false);
Expand Down Expand Up @@ -345,7 +345,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {

const handleClickOutside = () => {
setShowGitActionsMenu(false);
setGitActionsMenuPosition(null);
setGitActionsMenuAnchor(null);
};

document.addEventListener("mousedown", handleClickOutside);
Expand Down Expand Up @@ -559,9 +559,13 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
<button
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setGitActionsMenuPosition({
x: rect.left,
y: rect.bottom + 5,
setGitActionsMenuAnchor({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
});
setShowGitActionsMenu(!showGitActionsMenu);
setIsRepoMenuOpen(false);
Expand Down Expand Up @@ -897,10 +901,10 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {

<GitActionsMenu
isOpen={showGitActionsMenu}
position={gitActionsMenuPosition}
anchorRect={gitActionsMenuAnchor}
onClose={() => {
setShowGitActionsMenu(false);
setGitActionsMenuPosition(null);
setGitActionsMenuAnchor(null);
}}
hasGitRepo={!!gitStatus}
repoPath={activeRepoPath}
Expand Down
Loading