Skip to content
Merged
281 changes: 175 additions & 106 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,16 @@ import {
} from "~/components/ui/dialog";
import { Group, GroupSeparator } from "~/components/ui/group";
import { Input } from "~/components/ui/input";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
import {
Menu,
MenuItem,
MenuItemLabel,
MenuPopup,
MenuSub,
MenuSubTrigger,
MenuSubPopup,
MenuTrigger,
} from "~/components/ui/menu";
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Textarea } from "~/components/ui/textarea";
Expand Down Expand Up @@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink";
import { useOpenPrLink } from "~/lib/openPullRequestLink";

interface GitActionsControlProps {
presentation?: "toolbar" | "menu";
gitCwd: string | null;
activeThreadRef: ScopedThreadRef | null;
draftId?: DraftId;
Expand Down Expand Up @@ -374,24 +384,25 @@ function GitActionItemIcon({
function GitQuickActionIcon({
quickAction,
SourceControlIcon,
className = "size-3.5",
}: {
quickAction: GitQuickAction;
className?: string;
SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
}) {
const iconClassName = "size-3.5";
if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />;
if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />;
if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={iconClassName} />;
if (quickAction.kind === "open_pr") return <SourceControlIcon className={className} />;
if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />;
if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
if (quickAction.kind === "run_action") {
if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;
if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
if (quickAction.action === "push" || quickAction.action === "commit_push") {
return <CloudUploadIcon className={iconClassName} />;
return <CloudUploadIcon className={className} />;
}
return <SourceControlIcon className={iconClassName} />;
return <SourceControlIcon className={className} />;
}
if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;
if (quickAction.label === "Push") return <CloudUploadIcon className={iconClassName} />;
return <InfoIcon className={iconClassName} />;
if (quickAction.label === "Commit") return <GitCommitIcon className={className} />;
if (quickAction.label === "Push") return <CloudUploadIcon className={className} />;
return <InfoIcon className={className} />;
}

interface PublishRepositoryDialogProps {
Expand Down Expand Up @@ -944,6 +955,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
}

export default function GitActionsControl({
presentation = "toolbar",
gitCwd,
activeThreadRef,
draftId,
Expand Down Expand Up @@ -1632,33 +1644,161 @@ export default function GitActionsControl({

const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote;

if (!gitCwd) return null;

return (
const initializeGit = () => {
void (async () => {
const result = await initAction.run();
if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
return;
}
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Git initialization failed",
description: error instanceof Error ? error.message : "An error occurred.",
...(threadToastData !== undefined ? { data: threadToastData } : {}),
}),
);
})();
};
const gitItems = (
<>
{!isRepo ? (
<Button
variant="outline"
size="xs"
disabled={initAction.isPending}
{gitActionMenuItems.map((item) => {
const disabledReason = getMenuActionDisabledReason({
item,
gitStatus: gitStatusForActions,
isBusy: isGitActionRunning,
hasPrimaryRemote,
});
if (item.disabled && disabledReason && presentation === "menu") {
return (
<div key={`${item.id}-${item.label}`}>
<MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
<GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
<MenuItemLabel>{item.label}</MenuItemLabel>
</MenuItem>
<p className="max-w-64 px-2 pb-2 text-xs text-muted-foreground">{disabledReason}</p>
</div>
);
}
if (item.disabled && disabledReason) {
return (
<Popover key={`${item.id}-${item.label}`}>
<PopoverTrigger
openOnHover
nativeButton={false}
render={<span className="block w-max cursor-not-allowed" />}
>
<MenuItem
density={presentation === "menu" ? "touch" : "default"}
className="w-full"
disabled
>
<GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
<MenuItemLabel>{item.label}</MenuItemLabel>
</MenuItem>
</PopoverTrigger>
<PopoverPopup tooltipStyle side="left" align="center">
{disabledReason}
</PopoverPopup>
</Popover>
);
}

return (
<MenuItem
density={presentation === "menu" ? "touch" : "default"}
key={`${item.id}-${item.label}`}
disabled={item.disabled}
onClick={() => {
openDialogForMenuItem(item);
}}
>
<GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
<MenuItemLabel>{item.label}</MenuItemLabel>
</MenuItem>
);
})}
{canPublishRepository ? (
<MenuItem
density={presentation === "menu" ? "touch" : "default"}
disabled={isGitActionRunning}
onClick={() => {
void (async () => {
const result = await initAction.run();
if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
return;
}
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Git initialization failed",
description: error instanceof Error ? error.message : "An error occurred.",
...(threadToastData !== undefined ? { data: threadToastData } : {}),
}),
);
})();
setIsPublishDialogOpen(true);
}}
>
<CloudUploadIcon />
<MenuItemLabel>Publish repository...</MenuItemLabel>
</MenuItem>
) : null}
{gitStatusForActions?.refName === null && (
<p className="px-2 py-1.5 text-xs text-warning">
Detached HEAD: create and check out a branch to enable push and pull request actions.
</p>
)}
{gitStatusForActions &&
gitStatusForActions.refName !== null &&
!gitStatusForActions.hasWorkingTreeChanges &&
gitStatusForActions.behindCount > 0 &&
gitStatusForActions.aheadCount === 0 && (
<p className="px-2 py-1.5 text-xs text-warning">Behind upstream. Pull/rebase first.</p>
)}
{gitStatusError && <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p>}
</>
);

if (!gitCwd) return null;

return (
<>
{presentation === "menu" ? (
!isRepo ? (
<MenuItem
density={presentation === "menu" ? "touch" : "default"}

disabled={initAction.isPending}
onClick={initializeGit}
>
<GitBranchPlusIcon className="size-4" />
<MenuItemLabel>
{initAction.isPending ? "Initializing..." : "Initialize Git"}
</MenuItemLabel>
</MenuItem>
) : (
<>
<MenuItem
density={presentation === "menu" ? "touch" : "default"}

disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}
onClick={runQuickAction}
>
<GitQuickActionIcon
className="size-4"
quickAction={quickAction}
SourceControlIcon={SourceControlIcon}
/>
<MenuItemLabel>{quickAction.label}</MenuItemLabel>
</MenuItem>
{quickActionDisabledReason && (
<p className="max-w-64 px-2 py-1.5 text-xs text-warning">
{quickActionDisabledReason}
</p>
)}
<MenuSub
onOpenChange={(open) => {
if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
}}
>
<MenuSubTrigger density="touch" disabled={isGitActionRunning}>
<SourceControlIcon className="size-4" />
<MenuItemLabel>Git actions</MenuItemLabel>
</MenuSubTrigger>
<MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]">{gitItems}</MenuSubPopup>
</MenuSub>
</>
)
) : !isRepo ? (
<Button variant="outline" size="xs" disabled={initAction.isPending} onClick={initializeGit}>
<GitBranchPlusIcon className="size-3.5" aria-hidden />
<span className="ml-0.5">
{initAction.isPending ? "Initializing..." : "Initialize Git"}
Expand Down Expand Up @@ -1720,78 +1860,7 @@ export default function GitActionsControl({
<ChevronDownIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end" className="w-full">
{gitActionMenuItems.map((item) => {
const disabledReason = getMenuActionDisabledReason({
item,
gitStatus: gitStatusForActions,
isBusy: isGitActionRunning,
hasPrimaryRemote,
});
if (item.disabled && disabledReason) {
return (
<Popover key={`${item.id}-${item.label}`}>
<PopoverTrigger
openOnHover
nativeButton={false}
render={<span className="block w-max cursor-not-allowed" />}
>
<MenuItem className="w-full" disabled>
<GitActionItemIcon
icon={item.icon}
SourceControlIcon={SourceControlIcon}
/>
{item.label}
</MenuItem>
</PopoverTrigger>
<PopoverPopup tooltipStyle side="left" align="center">
{disabledReason}
</PopoverPopup>
</Popover>
);
}

return (
<MenuItem
key={`${item.id}-${item.label}`}
disabled={item.disabled}
onClick={() => {
openDialogForMenuItem(item);
}}
>
<GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
{item.label}
</MenuItem>
);
})}
{canPublishRepository ? (
<MenuItem
disabled={isGitActionRunning}
onClick={() => {
setIsPublishDialogOpen(true);
}}
>
<CloudUploadIcon />
Publish repository...
</MenuItem>
) : null}
{gitStatusForActions?.refName === null && (
<p className="px-2 py-1.5 text-xs text-warning">
Detached HEAD: create and check out a branch to enable push and pull request
actions.
</p>
)}
{gitStatusForActions &&
gitStatusForActions.refName !== null &&
!gitStatusForActions.hasWorkingTreeChanges &&
gitStatusForActions.behindCount > 0 &&
gitStatusForActions.aheadCount === 0 && (
<p className="px-2 py-1.5 text-xs text-warning">
Behind upstream. Pull/rebase first.
</p>
)}
{gitStatusError && (
<p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p>
)}
{gitItems}
</MenuPopup>
</Menu>
</Group>
Expand Down
Loading
Loading