diff --git a/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx b/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx
new file mode 100644
index 000000000000..69f0d44f263c
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/AutocommitStatusbar.test.tsx
@@ -0,0 +1,158 @@
+import React from "react";
+import { render, screen, act } from "@testing-library/react";
+import AutocommitStatusbar from "./AutocommitStatusbar";
+import "@testing-library/jest-dom";
+
+// Mock timers using Jest
+jest.useFakeTimers();
+
+// Mock the Statusbar component from '@appsmith/ads-old'
+jest.mock("@appsmith/ads-old", () => ({
+ Statusbar: ({ percentage }: { percentage: number }) => (
+
{percentage}%
+ ),
+}));
+
+const TOTAL_DURATION_MS = 4000;
+const STEPS = 9;
+const INTERVAL_MS = TOTAL_DURATION_MS / STEPS;
+
+describe("AutocommitStatusbar Component", () => {
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it("should render with initial percentage 0 when completed is false", () => {
+ render();
+ const statusbar = screen.getByTestId("statusbar");
+
+ expect(statusbar).toBeInTheDocument();
+ expect(statusbar).toHaveTextContent("0%");
+ });
+
+ it("should increment percentage over time when completed is false", () => {
+ render();
+ const statusbar = screen.getByTestId("statusbar");
+
+ // Initial percentage
+ expect(statusbar).toHaveTextContent("0%");
+
+ // Advance timer by one interval
+ act(() => {
+ jest.advanceTimersByTime(INTERVAL_MS);
+ });
+ expect(statusbar).toHaveTextContent("10%");
+
+ // Advance timer by another interval
+ act(() => {
+ jest.advanceTimersByTime(INTERVAL_MS);
+ });
+ expect(statusbar).toHaveTextContent("20%");
+
+ // Continue until percentage reaches 90%
+ act(() => {
+ jest.advanceTimersByTime((4 * 1000 * 7) / 9);
+ });
+ expect(statusbar).toHaveTextContent("90%");
+ });
+
+ it("should not increment percentage beyond 90 when completed is false", () => {
+ render();
+ const statusbar = screen.getByTestId("statusbar");
+
+ // Advance time beyond the total interval duration
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(statusbar).toHaveTextContent("90%");
+
+ // Advance time further to ensure percentage doesn't exceed 90%
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(statusbar).toHaveTextContent("90%");
+ });
+
+ it("should set percentage to 100 when completed is true", () => {
+ render();
+ const statusbar = screen.getByTestId("statusbar");
+
+ expect(statusbar).toHaveTextContent("100%");
+ });
+
+ it("should call onHide after 1 second when completed is true", () => {
+ const onHide = jest.fn();
+
+ render();
+ expect(onHide).not.toHaveBeenCalled();
+
+ // Advance timer by 1 second
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(onHide).toHaveBeenCalledTimes(1);
+ });
+
+ it("should clean up intervals and timeouts on unmount", () => {
+ const onHide = jest.fn();
+
+ const { unmount } = render(
+ ,
+ );
+
+ // Start the interval
+ act(() => {
+ jest.advanceTimersByTime(INTERVAL_MS);
+ });
+
+ // Unmount the component
+ unmount();
+
+ // Advance time to see if any timers are still running
+ act(() => {
+ jest.advanceTimersByTime(10000);
+ });
+ expect(onHide).not.toHaveBeenCalled();
+ });
+
+ it("should handle transition from false to true for completed prop", () => {
+ const onHide = jest.fn();
+ const { rerender } = render(
+ ,
+ );
+ const statusbar = screen.getByTestId("statusbar");
+
+ // Advance timer to increase percentage
+ act(() => {
+ jest.advanceTimersByTime(INTERVAL_MS);
+ });
+ expect(statusbar).toHaveTextContent("10%");
+
+ // Update the completed prop to true
+ rerender();
+ expect(statusbar).toHaveTextContent("100%");
+
+ // Ensure onHide is called after 1 second
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(onHide).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not reset percentage when completed changes from true to false", () => {
+ const { rerender } = render();
+ const statusbar = screen.getByTestId("statusbar");
+
+ expect(statusbar).toHaveTextContent("100%");
+
+ // Change completed to false
+ rerender();
+ expect(statusbar).toHaveTextContent("100%");
+
+ // Advance timer to check if percentage increments beyond 100%
+ act(() => {
+ jest.advanceTimersByTime(INTERVAL_MS);
+ });
+ expect(statusbar).toHaveTextContent("100%");
+ });
+});
diff --git a/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx b/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx
new file mode 100644
index 000000000000..88a3eb460734
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/AutocommitStatusbar.tsx
@@ -0,0 +1,118 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Statusbar } from "@appsmith/ads-old";
+import styled from "styled-components";
+import {
+ AUTOCOMMIT_IN_PROGRESS_MESSAGE,
+ createMessage,
+} from "ee/constants/messages";
+
+interface AutocommitStatusbarProps {
+ completed: boolean;
+ onHide?: () => void;
+}
+
+const PROGRESSBAR_WIDTH = 150;
+const TOTAL_DURATION_MS = 4000; // in ms
+const MAX_PROGRESS_PERCENTAGE = 90;
+const PROGRESS_INCREMENT = 10;
+const STEPS = 9;
+const INTERVAL_MS = TOTAL_DURATION_MS / STEPS;
+
+const StatusbarWrapper = styled.div`
+ > div {
+ display: flex;
+ height: initial;
+ align-items: center;
+ }
+
+ > div > div {
+ margin-top: 0px;
+ width: ${PROGRESSBAR_WIDTH}px;
+ margin-right: var(--ads-v2-spaces-4);
+ }
+
+ > div > p {
+ margin-top: 0;
+ }
+`;
+
+export default function AutocommitStatusbar({
+ completed,
+ onHide,
+}: AutocommitStatusbarProps) {
+ const intervalRef = useRef(null);
+ const timeoutRef = useRef(null);
+ const [percentage, setPercentage] = useState(0);
+
+ // Effect for incrementing percentage when not completed
+ useEffect(
+ function incrementPercentage() {
+ if (!completed) {
+ intervalRef.current = setInterval(() => {
+ setPercentage((prevPercentage) => {
+ if (prevPercentage < MAX_PROGRESS_PERCENTAGE) {
+ return prevPercentage + PROGRESS_INCREMENT;
+ } else {
+ // Clear the interval when percentage reaches 90%
+ if (intervalRef.current !== null) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+
+ return prevPercentage;
+ }
+ });
+ }, INTERVAL_MS);
+ }
+
+ // Cleanup function to clear the interval
+ return () => {
+ if (intervalRef.current !== null) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+ },
+ [completed],
+ ); // Removed 'percentage' from dependencies
+
+ // Effect for setting percentage to 100% when completed
+ useEffect(
+ function finishPercentage() {
+ if (completed) {
+ setPercentage(100);
+ }
+ },
+ [completed],
+ );
+
+ // Effect for calling onHide after 1 second when completed
+ useEffect(
+ function onCompleteCallback() {
+ if (completed && onHide) {
+ timeoutRef.current = setTimeout(() => {
+ onHide();
+ }, 1000);
+ }
+
+ return () => {
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ };
+ },
+ [completed, onHide],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/app/client/src/git/components/QuickActions/ConnectButton.test.tsx b/app/client/src/git/components/QuickActions/ConnectButton.test.tsx
new file mode 100644
index 000000000000..3b017c0c018f
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/ConnectButton.test.tsx
@@ -0,0 +1,154 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import ConnectButton from "./ConnectButton";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import { GitSyncModalTab } from "entities/GitSync";
+import "@testing-library/jest-dom";
+import { theme } from "constants/DefaultTheme";
+import { ThemeProvider } from "styled-components";
+
+// Mock the AnalyticsUtil
+jest.mock("ee/utils/AnalyticsUtil", () => ({
+ logEvent: jest.fn(),
+}));
+
+// Mock the components from '@appsmith/ads'
+jest.mock("@appsmith/ads", () => ({
+ ...jest.requireActual("@appsmith/ads"),
+ Icon: ({ name }: Record) => (
+ {name}
+ ),
+ Tooltip: ({ children, content, isDisabled }: Record) => (
+
+ {children}
+ {!isDisabled &&
{content}
}
+
+ ),
+}));
+
+describe("ConnectButton Component", () => {
+ const openGitSyncModalMock = jest.fn();
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should render correctly when isConnectPermitted is true", () => {
+ render(
+
+
+ ,
+ );
+
+ // Check that the button is rendered and enabled
+ const button = screen.getByRole("button");
+
+ expect(button).toBeInTheDocument();
+ expect(button).toBeEnabled();
+
+ // Tooltip should be disabled
+ const tooltipContent = screen.queryByTestId("tooltip-content");
+
+ expect(tooltipContent).not.toBeInTheDocument();
+
+ // Icon should be rendered
+ const icon = screen.getByTestId("icon");
+
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveTextContent("git-commit");
+ });
+
+ it("should handle click when isConnectPermitted is true", () => {
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Connect Git (Beta)" });
+
+ fireEvent.click(button);
+
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith(
+ "GS_CONNECT_GIT_CLICK",
+ {
+ source: "BOTTOM_BAR_GIT_CONNECT_BUTTON",
+ },
+ );
+
+ expect(openGitSyncModalMock).toHaveBeenCalledWith({
+ tab: GitSyncModalTab.GIT_CONNECTION,
+ });
+ });
+
+ it("should render correctly when isConnectPermitted is false", () => {
+ render(
+
+
+ ,
+ );
+
+ // Check that the button is rendered and disabled
+ const button = screen.getByRole("button", { name: "Connect Git (Beta)" });
+
+ expect(button).toBeInTheDocument();
+ expect(button).toBeDisabled();
+
+ // Tooltip should be enabled and display correct content
+ const tooltipContent = screen.getByTestId("tooltip-content");
+
+ expect(tooltipContent).toBeInTheDocument();
+ expect(tooltipContent).toHaveTextContent(
+ "Please contact your workspace admin to connect your app to a git repo",
+ );
+
+ // Icon should be rendered
+ const icon = screen.getByTestId("icon");
+
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveTextContent("git-commit");
+ });
+
+ it("should not handle click when isConnectPermitted is false", () => {
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Connect Git (Beta)" });
+
+ fireEvent.click(button);
+
+ expect(AnalyticsUtil.logEvent).not.toHaveBeenCalled();
+ expect(openGitSyncModalMock).not.toHaveBeenCalled();
+ });
+
+ it("should display correct tooltip content when isConnectPermitted is true", () => {
+ render(
+
+
+ ,
+ );
+
+ // Tooltip should be disabled, so content should not be visible
+ const tooltipContent = screen.queryByTestId("tooltip-content");
+
+ expect(tooltipContent).not.toBeInTheDocument();
+ });
+});
diff --git a/app/client/src/git/components/QuickActions/ConnectButton.tsx b/app/client/src/git/components/QuickActions/ConnectButton.tsx
new file mode 100644
index 000000000000..426b2a05ec85
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/ConnectButton.tsx
@@ -0,0 +1,93 @@
+import React, { useCallback, useMemo } from "react";
+import { GitSyncModalTab } from "entities/GitSync";
+import styled from "styled-components";
+import {
+ COMING_SOON,
+ CONNECT_GIT_BETA,
+ CONTACT_ADMIN_FOR_GIT,
+ createMessage,
+ NOT_LIVE_FOR_YOU_YET,
+} from "ee/constants/messages";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import { Button, Icon, Tooltip } from "@appsmith/ads";
+
+interface ConnectButtonProps {
+ isConnectPermitted: boolean;
+ openGitSyncModal: (options: { tab: GitSyncModalTab }) => void;
+}
+
+const CenterDiv = styled.div`
+ text-align: center;
+`;
+
+const Container = styled.div`
+ height: 100%;
+ display: flex;
+ align-items: center;
+ margin-left: 0;
+ cursor: pointer;
+`;
+
+const StyledIcon = styled(Icon)`
+ cursor: default;
+ margin-right: ${(props) => props.theme.spaces[3]}px;
+`;
+
+const OuterContainer = styled.div`
+ padding: 4px 16px;
+ height: 100%;
+`;
+
+function ConnectButton({
+ isConnectPermitted,
+ openGitSyncModal,
+}: ConnectButtonProps) {
+ const isTooltipEnabled = !isConnectPermitted;
+ const tooltipContent = useMemo(() => {
+ if (!isConnectPermitted) {
+ return {createMessage(CONTACT_ADMIN_FOR_GIT)};
+ }
+
+ return (
+ <>
+ {createMessage(NOT_LIVE_FOR_YOU_YET)}
+ {createMessage(COMING_SOON)}
+ >
+ );
+ }, [isConnectPermitted]);
+
+ const handleClickOnGitConnect = useCallback(() => {
+ AnalyticsUtil.logEvent("GS_CONNECT_GIT_CLICK", {
+ source: "BOTTOM_BAR_GIT_CONNECT_BUTTON",
+ });
+
+ openGitSyncModal({
+ tab: GitSyncModalTab.GIT_CONNECTION,
+ });
+ }, [openGitSyncModal]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ConnectButton;
diff --git a/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx b/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx
new file mode 100644
index 000000000000..6bd94a586f56
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/QuickActionButton.test.tsx
@@ -0,0 +1,109 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import QuickActionButton from "./QuickActionButton";
+import "@testing-library/jest-dom";
+import { theme } from "constants/DefaultTheme";
+import { ThemeProvider } from "styled-components";
+
+jest.mock("pages/common/SpinnerLoader", () => {
+ return function SpinnerLoader() {
+ return Loading...
;
+ };
+});
+
+jest.mock("@appsmith/ads", () => ({
+ ...jest.requireActual("@appsmith/ads"),
+ Tooltip: ({ children, content }: Record) => (
+
+
{content}
+ {children}
+
+ ),
+}));
+
+describe("QuickActionButton", () => {
+ const defaultProps = {
+ icon: "plus",
+ onClick: jest.fn(),
+ tooltipText: "default action",
+ className: "t--test-btn",
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should render without crashing", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const btn = container.getElementsByClassName("t--test-btn")[0];
+
+ expect(btn).toBeInTheDocument();
+ });
+
+ it("should call onClick when button is clicked", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const btn = container.getElementsByClassName("t--test-btn")[0];
+
+ fireEvent.click(btn);
+ expect(defaultProps.onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not call onClick when button is disabled", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const btn = container.getElementsByClassName("t--test-btn")[0];
+
+ fireEvent.click(btn);
+ expect(defaultProps.onClick).not.toHaveBeenCalled();
+ });
+
+ it("should display the tooltip with capitalized text", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("tooltip-content")).toHaveTextContent(
+ "Default action",
+ );
+ });
+
+ it("should display the spinner when loading is true", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("spinner-loader")).toBeInTheDocument();
+ expect(screen.queryByTestId("t--test-btn")).not.toBeInTheDocument();
+ });
+
+ it("should display the count badge when count is greater than 0", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("5")).toBeInTheDocument();
+ });
+
+ it("should not display the count badge when count is 0", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText("0")).not.toBeInTheDocument();
+ });
+});
diff --git a/app/client/src/git/components/QuickActions/QuickActionButton.tsx b/app/client/src/git/components/QuickActions/QuickActionButton.tsx
new file mode 100644
index 000000000000..42d7721ab297
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/QuickActionButton.tsx
@@ -0,0 +1,91 @@
+import React from "react";
+import styled from "styled-components";
+import { capitalizeFirstLetter } from "./helpers";
+import SpinnerLoader from "pages/common/SpinnerLoader";
+import { Button, Tooltip, Text } from "@appsmith/ads";
+import { getTypographyByKey } from "@appsmith/ads-old";
+
+interface QuickActionButtonProps {
+ className?: string;
+ count?: number;
+ disabled?: boolean;
+ icon: string;
+ loading?: boolean;
+ onClick: () => void;
+ tooltipText: string;
+}
+
+const SpinnerContainer = styled.div`
+ padding: 0 10px;
+`;
+
+const QuickActionButtonContainer = styled.button<{ disabled?: boolean }>`
+ margin: 0 ${(props) => props.theme.spaces[1]}px;
+ display: block;
+ position: relative;
+ overflow: visible;
+ cursor: ${({ disabled = false }) => (disabled ? "not-allowed" : "pointer")};
+ opacity: ${({ disabled = false }) => (disabled ? 0.6 : 1)};
+`;
+
+const StyledCountText = styled(Text)`
+ align-items: center;
+ background-color: var(--ads-v2-color-bg-brand-secondary-emphasis-plus);
+ color: var(--ads-v2-color-white);
+ display: flex;
+ justify-content: center;
+ position: absolute;
+ height: var(--ads-v2-spaces-5);
+ top: ${(props) => -1 * props.theme.spaces[3]}px;
+ left: ${(props) => props.theme.spaces[10]}px;
+ border-radius: ${(props) => props.theme.spaces[3]}px;
+ ${getTypographyByKey("p3")};
+ z-index: 1;
+ padding: ${(props) => props.theme.spaces[1]}px
+ ${(props) => props.theme.spaces[2]}px;
+`;
+
+function QuickActionButton({
+ className = "",
+ count = 0,
+ disabled = false,
+ icon,
+ loading = false,
+ onClick,
+ tooltipText,
+}: QuickActionButtonProps) {
+ const content = capitalizeFirstLetter(tooltipText);
+
+ return (
+
+ {loading ? (
+
+
+
+ ) : (
+
+
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export default QuickActionButton;
diff --git a/app/client/src/git/components/QuickActions/helper.test.ts b/app/client/src/git/components/QuickActions/helper.test.ts
new file mode 100644
index 000000000000..a7b3a6d30204
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/helper.test.ts
@@ -0,0 +1,191 @@
+import { getPullBtnStatus, capitalizeFirstLetter } from "./helpers";
+
+describe("getPullBtnStatus", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return disabled with message "No commits to pull" when behindCount is 0', () => {
+ const gitStatus: Record = {
+ behindCount: 0,
+ isClean: true,
+ };
+ const pullFailed = false;
+ const isProtected = false;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: true,
+ message:
+ "No commits to pull. This branch is in sync with the remote repository",
+ });
+ });
+
+ it('should return disabled with message "Cannot pull with local uncommitted changes" when isClean is false and isProtected is false', () => {
+ const gitStatus: Record = {
+ behindCount: 5,
+ isClean: false,
+ };
+ const pullFailed = false;
+ const isProtected = false;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: true,
+ message:
+ "You have uncommitted changes. Please commit or discard before pulling the remote changes.",
+ });
+ });
+
+ it('should return enabled with message "Pull changes" when isClean is false, isProtected is true, and behindCount > 0', () => {
+ const gitStatus: Record = {
+ behindCount: 3,
+ isClean: false,
+ };
+ const pullFailed = false;
+ const isProtected = true;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: false,
+ message: "Pull changes",
+ });
+ });
+
+ it('should return message "Conflicts found" when pullFailed is true', () => {
+ const gitStatus: Record = {
+ behindCount: 2,
+ isClean: true,
+ };
+ const pullFailed = true;
+ const isProtected = false;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: false,
+ message: "Conflicts found. Please resolve them and pull again.",
+ });
+ });
+
+ it('should return enabled with message "Pull changes" when behindCount > 0 and no other conditions met', () => {
+ const gitStatus: Record = {
+ behindCount: 1,
+ isClean: true,
+ };
+ const pullFailed = false;
+ const isProtected = false;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: false,
+ message: "Pull changes",
+ });
+ });
+
+ it('should return disabled with message "No commits to pull" when behindCount is 0 regardless of isClean and isProtected', () => {
+ const gitStatus: Record = {
+ behindCount: 0,
+ isClean: false,
+ };
+ const pullFailed = false;
+ const isProtected = true;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: true,
+ message:
+ "No commits to pull. This branch is in sync with the remote repository",
+ });
+ });
+
+ it("should prioritize pullFailed over other conditions", () => {
+ const gitStatus: Record = {
+ behindCount: 0,
+ isClean: true,
+ };
+ const pullFailed = true;
+ const isProtected = false;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: true,
+ message: "Conflicts found. Please resolve them and pull again.",
+ });
+ });
+
+ it("should handle edge case when isClean is false, isProtected is true, behindCount is 0", () => {
+ const gitStatus: Record = {
+ behindCount: 0,
+ isClean: false,
+ };
+ const pullFailed = false;
+ const isProtected = true;
+
+ const result = getPullBtnStatus(gitStatus, pullFailed, isProtected);
+
+ expect(result).toEqual({
+ disabled: true,
+ message:
+ "No commits to pull. This branch is in sync with the remote repository",
+ });
+ });
+});
+
+describe("capitalizeFirstLetter", () => {
+ it("should capitalize the first letter of a lowercase word", () => {
+ const result = capitalizeFirstLetter("hello");
+
+ expect(result).toBe("Hello");
+ });
+
+ it("should capitalize the first letter of an uppercase word", () => {
+ const result = capitalizeFirstLetter("WORLD");
+
+ expect(result).toBe("World");
+ });
+
+ it("should handle empty string", () => {
+ const result = capitalizeFirstLetter("");
+
+ expect(result).toBe("");
+ });
+
+ it("should handle single character", () => {
+ const result = capitalizeFirstLetter("a");
+
+ expect(result).toBe("A");
+ });
+
+ it("should handle strings with spaces", () => {
+ const result = capitalizeFirstLetter("multiple words here");
+
+ expect(result).toBe("Multiple words here");
+ });
+
+ it("should handle undefined input", () => {
+ // The function provides a default value of " " when input is undefined
+ // So we expect the output to be a single space with capitalized first letter
+ const result = capitalizeFirstLetter();
+
+ expect(result).toBe(" ");
+ });
+
+ it("should handle strings with special characters", () => {
+ const result = capitalizeFirstLetter("123abc");
+
+ expect(result).toBe("123abc");
+ });
+
+ it("should not modify strings where the first character is not a letter", () => {
+ const result = capitalizeFirstLetter("!test");
+
+ expect(result).toBe("!test");
+ });
+});
diff --git a/app/client/src/git/components/QuickActions/helpers.ts b/app/client/src/git/components/QuickActions/helpers.ts
new file mode 100644
index 000000000000..109197264899
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/helpers.ts
@@ -0,0 +1,45 @@
+import {
+ CANNOT_PULL_WITH_LOCAL_UNCOMMITTED_CHANGES,
+ CONFLICTS_FOUND,
+ createMessage,
+ NO_COMMITS_TO_PULL,
+ PULL_CHANGES,
+} from "ee/constants/messages";
+import type { GitStatus } from "../../types";
+
+export const getPullBtnStatus = (
+ gitStatus: GitStatus,
+ pullFailed: boolean,
+ isProtected: boolean,
+) => {
+ const { behindCount, isClean } = gitStatus;
+ let message = createMessage(NO_COMMITS_TO_PULL);
+ let disabled = behindCount === 0;
+
+ if (!isClean && !isProtected) {
+ disabled = true;
+ message = createMessage(CANNOT_PULL_WITH_LOCAL_UNCOMMITTED_CHANGES);
+ // TODO: Remove this when gitStatus typings are finalized
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ } else if (!isClean && isProtected && behindCount > 0) {
+ disabled = false;
+ message = createMessage(PULL_CHANGES);
+ } else if (pullFailed) {
+ message = createMessage(CONFLICTS_FOUND);
+ // TODO: Remove this when gitStatus typings are finalized
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ } else if (behindCount > 0) {
+ message = createMessage(PULL_CHANGES);
+ }
+
+ return {
+ disabled,
+ message,
+ };
+};
+
+export const capitalizeFirstLetter = (string = " ") => {
+ return string.charAt(0).toUpperCase() + string.toLowerCase().slice(1);
+};
diff --git a/app/client/src/git/components/QuickActions/index.test.tsx b/app/client/src/git/components/QuickActions/index.test.tsx
new file mode 100644
index 000000000000..b920e6b51fde
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/index.test.tsx
@@ -0,0 +1,334 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import QuickActions from ".";
+import { GitSettingsTab } from "git/enums";
+import { GitSyncModalTab } from "entities/GitSync";
+import { theme } from "constants/DefaultTheme";
+import { ThemeProvider } from "styled-components";
+import "@testing-library/jest-dom/extend-expect";
+
+jest.mock("ee/utils/AnalyticsUtil", () => ({
+ logEvent: jest.fn(),
+}));
+
+jest.mock("./ConnectButton", () => () => (
+ ConnectButton
+));
+
+jest.mock("./AutocommitStatusbar", () => () => (
+ AutocommitStatusbar
+));
+
+describe("QuickActions Component", () => {
+ const defaultProps = {
+ isGitConnected: false,
+ gitStatus: {
+ behindCount: 0,
+ isClean: true,
+ },
+ pullFailed: false,
+ isProtectedMode: false,
+ isDiscardInProgress: false,
+ isPollingAutocommit: false,
+ isPullInProgress: false,
+ isFetchingGitStatus: false,
+ changesToCommit: 0,
+ gitMetadata: {},
+ isAutocommitEnabled: false,
+ isConnectPermitted: true,
+ openGitSyncModal: jest.fn(),
+ openGitSettingsModal: jest.fn(),
+ discardChanges: jest.fn(),
+ pull: jest.fn(),
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should render ConnectButton when isGitConnected is false", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("connect-button")).toBeInTheDocument();
+ });
+
+ it("should render QuickActionButtons when isGitConnected is true", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(
+ container.getElementsByClassName("t--bottom-bar-commit").length,
+ ).toBe(1);
+ expect(container.getElementsByClassName("t--bottom-bar-pull").length).toBe(
+ 1,
+ );
+ expect(container.getElementsByClassName("t--bottom-bar-merge").length).toBe(
+ 1,
+ );
+ expect(
+ container.getElementsByClassName("t--bottom-git-settings").length,
+ ).toBe(1);
+ });
+
+ it("should render AutocommitStatusbar when isAutocommitEnabled and isPollingAutocommit are true", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ gitMetadata: {
+ autoCommitConfig: {
+ enabled: true,
+ },
+ },
+ isPollingAutocommit: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("autocommit-statusbar")).toBeInTheDocument();
+ expect(
+ container.getElementsByClassName("t--bottom-bar-commit").length,
+ ).toBe(0);
+ });
+
+ it("should call onCommitClick when commit button is clicked", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+ const commitButton = container.getElementsByClassName(
+ "t--bottom-bar-commit",
+ )[0];
+
+ fireEvent.click(commitButton);
+ expect(props.openGitSyncModal).toHaveBeenCalledWith({
+ tab: GitSyncModalTab.DEPLOY,
+ });
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith(
+ "GS_DEPLOY_GIT_MODAL_TRIGGERED",
+ {
+ source: "BOTTOM_BAR_GIT_COMMIT_BUTTON",
+ },
+ );
+ });
+
+ it("should call onPullClick when pull button is clicked", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ isDiscardInProgress: false,
+ isPullInProgress: false,
+ isFetchingGitStatus: false,
+ pullDisabled: false,
+ gitStatus: {
+ behindCount: 1,
+ isClean: false,
+ },
+ isProtectedMode: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+ const pullButton =
+ container.getElementsByClassName("t--bottom-bar-pull")[0];
+
+ fireEvent.click(pullButton);
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("GS_PULL_GIT_CLICK", {
+ source: "BOTTOM_BAR_GIT_PULL_BUTTON",
+ });
+ });
+
+ it("should call onMerge when merge button is clicked", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+ const mergeButton = container.getElementsByClassName(
+ "t--bottom-bar-merge",
+ )[0];
+
+ fireEvent.click(mergeButton);
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith(
+ "GS_MERGE_GIT_MODAL_TRIGGERED",
+ {
+ source: "BOTTOM_BAR_GIT_MERGE_BUTTON",
+ },
+ );
+ expect(props.openGitSyncModal).toHaveBeenCalledWith({
+ tab: GitSyncModalTab.MERGE,
+ isDeploying: true,
+ });
+ });
+
+ it("should call onSettingsClick when settings button is clicked", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+ const settingsButton = container.getElementsByClassName(
+ "t--bottom-git-settings",
+ )[0];
+
+ fireEvent.click(settingsButton);
+ expect(AnalyticsUtil.logEvent).toHaveBeenCalledWith("GS_SETTING_CLICK", {
+ source: "BOTTOM_BAR_GIT_SETTING_BUTTON",
+ });
+ expect(props.openGitSettingsModal).toHaveBeenCalledWith({
+ tab: GitSettingsTab.General,
+ });
+ });
+
+ it("should disable commit button when isProtectedMode is true", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ isProtectedMode: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+ const commitButton = container.getElementsByClassName(
+ "t--bottom-bar-commit",
+ )[0];
+
+ expect(commitButton).toBeDisabled();
+ });
+
+ it("should show loading state on pull button when showPullLoadingState is true", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ isPullInProgress: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const pullButton =
+ container.getElementsByClassName("t--bottom-bar-pull")[0];
+ const pullLoading = pullButton.getElementsByClassName(
+ "t--loader-quick-git-action",
+ )[0];
+
+ expect(pullLoading).toBeInTheDocument();
+ });
+
+ it("should display changesToCommit count on commit button", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ changesToCommit: 5,
+ };
+
+ render(
+
+
+ ,
+ );
+ const countElement = screen.getByTestId("t--bottom-bar-count");
+
+ expect(countElement).toHaveTextContent("5");
+ });
+
+ it("should not display count on commit button when isProtectedMode is true", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ isProtectedMode: true,
+ changesToCommit: 5,
+ };
+
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByTestId("t--bottom-bar-count")).not.toBeInTheDocument();
+ });
+
+ it("should disable pull button when pullDisabled is true", () => {
+ const mockGetPullBtnStatus = jest.requireMock("./helpers").getPullBtnStatus;
+
+ mockGetPullBtnStatus.mockReturnValue({
+ disabled: true,
+ message: "Pull Disabled",
+ });
+
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+ const pullButton =
+ container.getElementsByClassName("t--bottom-bar-pull")[0];
+
+ expect(pullButton).toBeDisabled();
+ });
+
+ it("should show behindCount on pull button", () => {
+ const props = {
+ ...defaultProps,
+ isGitConnected: true,
+ gitStatus: {
+ behindCount: 3,
+ isClean: true,
+ },
+ };
+
+ render(
+
+
+ ,
+ );
+ const countElement = screen.getByTestId("t--bottom-bar-count");
+
+ expect(countElement).toHaveTextContent("3");
+ });
+});
diff --git a/app/client/src/git/components/QuickActions/index.tsx b/app/client/src/git/components/QuickActions/index.tsx
new file mode 100644
index 000000000000..cab336ca1e52
--- /dev/null
+++ b/app/client/src/git/components/QuickActions/index.tsx
@@ -0,0 +1,183 @@
+import React, { useCallback } from "react";
+import styled from "styled-components";
+
+import {
+ COMMIT_CHANGES,
+ createMessage,
+ GIT_SETTINGS,
+ MERGE,
+} from "ee/constants/messages";
+
+import { GitSyncModalTab } from "entities/GitSync";
+import AnalyticsUtil from "ee/utils/AnalyticsUtil";
+import type { GitMetadata, GitStatus } from "../../types";
+import { getPullBtnStatus } from "./helpers";
+import { GitSettingsTab } from "../../enums";
+import ConnectButton from "./ConnectButton";
+import QuickActionButton from "./QuickActionButton";
+import AutocommitStatusbar from "./AutocommitStatusbar";
+
+interface QuickActionsProps {
+ isGitConnected: boolean;
+ gitStatus: GitStatus;
+ pullFailed: boolean;
+ isProtectedMode: boolean;
+ isDiscardInProgress: boolean;
+ isPollingAutocommit: boolean;
+ isPullInProgress: boolean;
+ isFetchingGitStatus: boolean;
+ changesToCommit: number;
+ gitMetadata: GitMetadata;
+ isAutocommitEnabled: boolean;
+ isConnectPermitted: boolean;
+ openGitSyncModal: (options: {
+ tab: GitSyncModalTab;
+ isDeploying?: boolean;
+ }) => void;
+ openGitSettingsModal: (options: { tab: GitSettingsTab }) => void;
+ discardChanges: () => void;
+ pull: (options: { triggeredFromBottomBar: boolean }) => void;
+}
+
+const Container = styled.div`
+ height: 100%;
+ display: flex;
+ align-items: center;
+`;
+
+function QuickActions({
+ changesToCommit,
+ discardChanges,
+ gitMetadata,
+ gitStatus,
+ isConnectPermitted,
+ isDiscardInProgress,
+ isFetchingGitStatus,
+ isGitConnected,
+ isPollingAutocommit,
+ isProtectedMode,
+ isPullInProgress,
+ openGitSettingsModal,
+ openGitSyncModal,
+ pull,
+ pullFailed,
+}: QuickActionsProps) {
+ const { disabled: pullDisabled, message: pullTooltipMessage } =
+ getPullBtnStatus(gitStatus, !!pullFailed, isProtectedMode);
+
+ const showPullLoadingState =
+ isDiscardInProgress || isPullInProgress || isFetchingGitStatus;
+
+ // TODO - Update once the gitMetadata typing is added
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const isAutocommitEnabled: boolean = gitMetadata?.autoCommitConfig?.enabled;
+ const onCommitClick = useCallback(() => {
+ if (!isFetchingGitStatus && !isProtectedMode) {
+ openGitSyncModal({
+ tab: GitSyncModalTab.DEPLOY,
+ });
+
+ AnalyticsUtil.logEvent("GS_DEPLOY_GIT_MODAL_TRIGGERED", {
+ source: "BOTTOM_BAR_GIT_COMMIT_BUTTON",
+ });
+ }
+ }, [isFetchingGitStatus, isProtectedMode, openGitSyncModal]);
+
+ const onPullClick = useCallback(() => {
+ if (!showPullLoadingState && !pullDisabled) {
+ AnalyticsUtil.logEvent("GS_PULL_GIT_CLICK", {
+ source: "BOTTOM_BAR_GIT_PULL_BUTTON",
+ });
+
+ if (isProtectedMode) {
+ discardChanges();
+ } else {
+ pull({ triggeredFromBottomBar: true });
+ }
+ }
+ }, [
+ discardChanges,
+ isProtectedMode,
+ pull,
+ pullDisabled,
+ showPullLoadingState,
+ ]);
+
+ const onMerge = useCallback(() => {
+ AnalyticsUtil.logEvent("GS_MERGE_GIT_MODAL_TRIGGERED", {
+ source: "BOTTOM_BAR_GIT_MERGE_BUTTON",
+ });
+ openGitSyncModal({
+ tab: GitSyncModalTab.MERGE,
+ isDeploying: true,
+ });
+ }, [openGitSyncModal]);
+
+ const onSettingsClick = useCallback(() => {
+ openGitSettingsModal({
+ tab: GitSettingsTab.General,
+ });
+ AnalyticsUtil.logEvent("GS_SETTING_CLICK", {
+ source: "BOTTOM_BAR_GIT_SETTING_BUTTON",
+ });
+ }, [openGitSettingsModal]);
+
+ return isGitConnected ? (
+
+ {/* */}
+ {isAutocommitEnabled && isPollingAutocommit ? (
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+ ) : (
+
+ );
+}
+
+export default QuickActions;