Add live git status indicators (#458)

This commit is contained in:
Gregory Schier
2026-05-08 11:25:39 -07:00
committed by GitHub
parent 1b154ba550
commit d7e67cf13c
35 changed files with 1702 additions and 578 deletions

View File

@@ -0,0 +1,131 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } from "@yaakapp-internal/git";
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { sync } from "../../init/sync";
import { showConfirm } from "../../lib/confirm";
import { EmptyStateText } from "../EmptyStateText";
import { Button } from "../core/Button";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { useGitCallbacks } from "./callbacks";
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
const callbacks = useGitCallbacks(dir);
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
const log = useGitLog(dir, undefined, relaPath);
const commits = log.data ?? [];
const [selectedOid, setSelectedOid] = useState<string | null>(null);
const selectedCommit = useMemo(
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
[commits, selectedOid],
);
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
useEffect(() => {
if (commits.length === 0) {
setSelectedOid(null);
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
setSelectedOid(commits[0]?.oid ?? null);
}
}, [commits, selectedOid]);
const handleRestoreCommit = useCallback(
async (commit: GitCommit) => {
const confirmed = await showConfirm({
id: "git-restore-file-history-entry",
title: "Restore File",
description: "This will restore the file to the selected commit.",
confirmText: "Restore",
color: "warning",
});
if (!confirmed) return;
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
await sync({ force: true });
},
[relaPath, restoreFileFromCommit],
);
if (commits.length === 0 && !log.isLoading) {
return <EmptyStateText>No history for this file</EmptyStateText>;
}
return (
<div className="h-full px-2 pb-4">
<SplitLayout
storageKey="git-file-history-horizontal"
layout="horizontal"
defaultRatio={0.6}
firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
<div className="flex flex-col pt-1.5">
{commits.map((commit) => (
<CommitListItem
key={commit.oid}
commit={commit}
selected={commit.oid === selectedCommit?.oid}
onSelect={() => setSelectedOid(commit.oid)}
/>
))}
</div>
</div>
)}
secondSlot={({ style }) => (
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
{selectedCommit == null ? (
<EmptyStateText>Select a commit to view diff</EmptyStateText>
) : (
<div className="h-full flex flex-col">
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
<Button
className="ml-auto"
color="warning"
size="2xs"
variant="border"
onClick={() => handleRestoreCommit(selectedCommit)}
>
Restore File
</Button>
</div>
<DiffViewer
original={diff.data?.original ?? ""}
modified={diff.data?.modified ?? ""}
className="flex-1 min-h-0"
/>
</div>
)}
</div>
)}
/>
</div>
);
}
function CommitListItem({
commit,
selected,
onSelect,
}: {
commit: GitCommit;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={classNames(
"w-full min-w-0 text-left rounded px-2 py-1.5",
selected && "bg-surface-active",
)}
onClick={onSelect}
>
<div className="truncate flex-1">{commit.message || "No message"}</div>
<div className="text-text-subtle text-sm truncate">
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
</div>
</button>
);
}

View File

@@ -8,12 +8,14 @@ import type {
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
@@ -21,7 +23,7 @@ import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input";
import { Separator } from "../core/Separator";
import { EmptyStateText } from "../EmptyStateText";
import { gitCallbacks } from "./callbacks";
import { useGitCallbacks } from "./callbacks";
import { handlePushResult } from "./git-util";
interface Props {
@@ -38,9 +40,10 @@ interface CommitTreeNode {
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(
const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
syncDir,
gitCallbacks(syncDir),
callbacks,
);
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
@@ -165,6 +168,24 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[selectedEntry],
);
const handleDiscardChanges = useCallback(
async (entry: GitStatusEntry) => {
const confirmed = await showConfirm({
id: "git-restore-commit-entry",
title: "Discard Changes",
description: "Do you really want to discard uncommitted changes for the selected item?",
confirmText: "Discard",
color: "danger",
});
if (!confirmed) return;
await restore.mutateAsync({ relaPaths: [entry.relaPath] });
await sync({ force: true });
setSelectedEntry(null);
},
[restore],
);
if (tree == null) {
return null;
}
@@ -259,7 +280,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
secondSlot={({ style }) => (
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
{selectedEntry ? (
<DiffPanel entry={selectedEntry} />
<DiffPanel entry={selectedEntry} onDiscardChanges={handleDiscardChanges} />
) : (
<EmptyStateText>Select a change to view diff</EmptyStateText>
)}
@@ -466,16 +487,35 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({ entry }: { entry: GitStatusEntry }) {
function DiffPanel({
entry,
onDiscardChanges,
}: {
entry: GitStatusEntry;
onDiscardChanges: (entry: GitStatusEntry) => void | Promise<void>;
}) {
const prevYaml = modelToYaml(entry.prev);
const nextYaml = modelToYaml(entry.next);
return (
<div className="h-full flex flex-col">
<div className="text-sm text-text-subtle mb-2 px-1">
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
<div className="text-text-subtle mb-2 px-1 grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 truncate">
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
</div>
<Button
className="ml-auto"
color="warning"
size="2xs"
variant="border"
onClick={() => onDiscardChanges(entry)}
>Discard Changes</Button>
</div>
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
<DiffViewer
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { useGit } from "@yaakapp-internal/git";
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { HTMLAttributes } from "react";
import { forwardRef } from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
import { useKeyValue } from "../../hooks/useKeyValue";
@@ -12,17 +12,20 @@ import { sync } from "../../init/sync";
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
import { fireAndForget } from "../../lib/fireAndForget";
import { showDialog } from "../../lib/dialog";
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
import { showPrompt } from "../../lib/prompt";
import { showErrorToast, showToast } from "../../lib/toast";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
import { gitCallbacks } from "./callbacks";
import { useGitCallbacks } from "./callbacks";
import { GitCommitDialog } from "./GitCommitDialog";
import { GitRemotesDialog } from "./GitRemotesDialog";
import { handlePullResult, handlePushResult } from "./git-util";
import { HistoryDialog } from "./HistoryDialog";
const EMPTY_BRANCHES: string[] = [];
export function GitDropdown() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
if (workspaceMeta == null) return null;
@@ -36,469 +39,493 @@ export function GitDropdown() {
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
const [refreshKey, regenerateKey] = useRandomKey();
const [
{ status, log },
{
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
const callbacks = useGitCallbacks(syncDir);
const {
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
} = useGitMutations(syncDir, callbacks);
const localBranches = status.data?.localBranches ?? [];
const remoteBranches = status.data?.remoteBranches ?? [];
const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, "")),
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
const remoteOnlyBranches = useMemo(
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
[localBranches, remoteBranches],
);
const currentBranch = branchInfo.data?.headRefShorthand;
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
const ahead = branchInfo.data?.ahead ?? 0;
const behind = branchInfo.data?.behind ?? 0;
const initRepo = useCallback(() => {
init.mutate();
}, [init]);
const items: DropdownItem[] = useMemo(() => {
if (workspace == null || branchInfo.data == null) return [];
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
{
disableToastError: true,
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
const forceCheckout = await showConfirm({
id: "git-force-checkout",
title: "Conflicts Detected",
description:
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
confirmText: "Force Checkout",
color: "warning",
});
if (forceCheckout) {
tryCheckout(branch, true);
}
} else {
// Checkout failed
showErrorToast({
id: "git-checkout-error",
title: "Error checking out branch",
message: String(err),
});
}
},
async onSuccess(branchName) {
showToast({
id: "git-checkout-success",
message: (
<>
Switched branch <InlineCode>{branchName}</InlineCode>
</>
),
color: "success",
});
await sync({ force: true });
},
},
);
};
return [
{
label: "View History...",
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: "git-history",
size: "md",
title: "Commit History",
noPadding: true,
render: () => <HistoryDialog dir={syncDir} />,
});
},
},
{
label: "Manage Remotes...",
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir),
},
{ type: "separator" },
{
label: "New Branch...",
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
id: "git-branch-name",
title: "Create Branch",
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{ type: "separator" },
{
label: "Push",
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePushResult,
onError(err) {
showErrorToast({
id: "git-push-error",
title: "Error pushing changes",
message: String(err),
});
},
});
},
},
{
label: "Pull",
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
await pull.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onError(err) {
showErrorToast({
id: "git-pull-error",
title: "Error pulling changes",
message: String(err),
});
},
});
},
},
{
label: "Commit...",
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
id: "commit",
title: "Commit Changes",
size: "full",
noPadding: true,
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),
});
},
},
{
label: "Reset Changes",
hidden: !hasChanges,
leftSlot: <Icon icon="rotate_ccw" />,
color: "danger",
async onSelect() {
const confirmed = await showConfirm({
id: "git-reset-changes",
title: "Reset Changes",
description: "This will discard all uncommitted changes. This cannot be undone.",
confirmText: "Reset",
color: "danger",
});
if (!confirmed) return;
await resetChanges.mutateAsync(undefined, {
disableToastError: true,
onSuccess() {
showToast({
id: "git-reset-success",
message: "Changes have been reset",
color: "success",
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-reset-error",
title: "Error resetting changes",
message: String(err),
});
},
});
},
},
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: (
<>
Merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
hidden: isCurrent,
async onSelect() {
await mergeBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-merged-branch",
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{" "}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-merged-branch-error",
title: "Error merging branch",
message: String(err),
});
},
},
);
},
},
{
label: "New Branch...",
async onSelect() {
const name = await showPrompt({
id: "git-new-branch-from",
title: "New Branch",
description: (
<>
Create a new branch from <InlineCode>{branch}</InlineCode>
</>
),
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name, base: branch },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{
label: "Rename...",
async onSelect() {
const newName = await showPrompt({
id: "git-rename-branch",
title: "Rename Branch",
label: "New Branch Name",
defaultValue: branch,
});
if (!newName || newName === branch) return;
await renameBranch.mutateAsync(
{ oldName: branch, newName },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-rename-branch-success",
message: (
<>
Renamed <InlineCode>{branch}</InlineCode> to{" "}
<InlineCode>{newName}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-rename-branch-error",
title: "Error renaming branch",
message: String(err),
});
},
},
);
},
},
{ type: "separator", hidden: isCurrent },
{
label: "Delete",
color: "danger",
hidden: isCurrent,
onSelect: async () => {
const confirmed = await showConfirmDelete({
id: "git-delete-branch",
title: "Delete Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode>?
</>
),
});
if (!confirmed) {
return;
}
const result = await deleteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-delete-branch-error",
title: "Error deleting branch",
message: String(err),
});
},
},
);
if (result.type === "not_fully_merged") {
const confirmed = await showConfirm({
id: "force-branch-delete",
title: "Branch not fully merged",
description: (
<>
<p>
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
</p>
<p>Do you want to delete it anyway?</p>
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch, force: true },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-force-delete-branch-error",
title: "Error force deleting branch",
message: String(err),
});
},
},
);
}
}
},
},
],
} satisfies DropdownItem;
}),
...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: "Delete",
color: "danger",
async onSelect() {
const confirmed = await showConfirmDelete({
id: "git-delete-remote-branch",
title: "Delete Remote Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
</>
),
});
if (!confirmed) return;
await deleteRemoteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-delete-remote-branch-success",
message: (
<>
Deleted remote branch <InlineCode>{branch}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-delete-remote-branch-error",
title: "Error deleting remote branch",
message: String(err),
});
},
},
);
},
},
],
} satisfies DropdownItem;
}),
];
}, [
branchInfo.data,
checkout,
createBranch,
currentBranch,
deleteBranch,
deleteRemoteBranch,
hasChanges,
localBranches,
mergeBranch,
pull,
push,
remoteOnlyBranches,
renameBranch,
resetChanges,
syncDir,
workspace,
]);
if (workspace == null) {
return null;
}
const noRepo = status.error?.includes("not found");
const noRepo = branchInfo.error?.includes("not found");
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
}
// Still loading
if (status.data == null) {
if (branchInfo.data == null) {
return null;
}
const currentBranch = status.data.headRefShorthand;
const hasChanges = status.data.entries.some((e) => e.status !== "current");
const _hasRemotes = (status.data.origins ?? []).length > 0;
const { ahead, behind } = status.data;
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
{
disableToastError: true,
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
const forceCheckout = await showConfirm({
id: "git-force-checkout",
title: "Conflicts Detected",
description:
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
confirmText: "Force Checkout",
color: "warning",
});
if (forceCheckout) {
tryCheckout(branch, true);
}
} else {
// Checkout failed
showErrorToast({
id: "git-checkout-error",
title: "Error checking out branch",
message: String(err),
});
}
},
async onSuccess(branchName) {
showToast({
id: "git-checkout-success",
message: (
<>
Switched branch <InlineCode>{branchName}</InlineCode>
</>
),
color: "success",
});
await sync({ force: true });
},
},
);
};
const items: DropdownItem[] = [
{
label: "View History...",
hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: "git-history",
size: "md",
title: "Commit History",
noPadding: true,
render: () => <HistoryDialog log={log.data ?? []} />,
});
},
},
{
label: "Manage Remotes...",
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir),
},
{ type: "separator" },
{
label: "New Branch...",
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
id: "git-branch-name",
title: "Create Branch",
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{ type: "separator" },
{
label: "Push",
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePushResult,
onError(err) {
showErrorToast({
id: "git-push-error",
title: "Error pushing changes",
message: String(err),
});
},
});
},
},
{
label: "Pull",
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
await pull.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onError(err) {
showErrorToast({
id: "git-pull-error",
title: "Error pulling changes",
message: String(err),
});
},
});
},
},
{
label: "Commit...",
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
id: "commit",
title: "Commit Changes",
size: "full",
noPadding: true,
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),
});
},
},
{
label: "Reset Changes",
hidden: !hasChanges,
leftSlot: <Icon icon="rotate_ccw" />,
color: "danger",
async onSelect() {
const confirmed = await showConfirm({
id: "git-reset-changes",
title: "Reset Changes",
description: "This will discard all uncommitted changes. This cannot be undone.",
confirmText: "Reset",
color: "danger",
});
if (!confirmed) return;
await resetChanges.mutateAsync(undefined, {
disableToastError: true,
onSuccess() {
showToast({
id: "git-reset-success",
message: "Changes have been reset",
color: "success",
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-reset-error",
title: "Error resetting changes",
message: String(err),
});
},
});
},
},
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: (
<>
Merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
hidden: isCurrent,
async onSelect() {
await mergeBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-merged-branch",
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{" "}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-merged-branch-error",
title: "Error merging branch",
message: String(err),
});
},
},
);
},
},
{
label: "New Branch...",
async onSelect() {
const name = await showPrompt({
id: "git-new-branch-from",
title: "New Branch",
description: (
<>
Create a new branch from <InlineCode>{branch}</InlineCode>
</>
),
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name, base: branch },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{
label: "Rename...",
async onSelect() {
const newName = await showPrompt({
id: "git-rename-branch",
title: "Rename Branch",
label: "New Branch Name",
defaultValue: branch,
});
if (!newName || newName === branch) return;
await renameBranch.mutateAsync(
{ oldName: branch, newName },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-rename-branch-success",
message: (
<>
Renamed <InlineCode>{branch}</InlineCode> to{" "}
<InlineCode>{newName}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-rename-branch-error",
title: "Error renaming branch",
message: String(err),
});
},
},
);
},
},
{ type: "separator", hidden: isCurrent },
{
label: "Delete",
color: "danger",
hidden: isCurrent,
onSelect: async () => {
const confirmed = await showConfirmDelete({
id: "git-delete-branch",
title: "Delete Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode>?
</>
),
});
if (!confirmed) {
return;
}
const result = await deleteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-delete-branch-error",
title: "Error deleting branch",
message: String(err),
});
},
},
);
if (result.type === "not_fully_merged") {
const confirmed = await showConfirm({
id: "force-branch-delete",
title: "Branch not fully merged",
description: (
<>
<p>
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
</p>
<p>Do you want to delete it anyway?</p>
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch, force: true },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-force-delete-branch-error",
title: "Error force deleting branch",
message: String(err),
});
},
},
);
}
}
},
},
],
} satisfies DropdownItem;
}),
...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: "Delete",
color: "danger",
async onSelect() {
const confirmed = await showConfirmDelete({
id: "git-delete-remote-branch",
title: "Delete Remote Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
</>
),
});
if (!confirmed) return;
await deleteRemoteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-delete-remote-branch-success",
message: (
<>
Deleted remote branch <InlineCode>{branch}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-delete-remote-branch-error",
title: "Error deleting remote branch",
message: String(err),
});
},
},
);
},
},
],
} satisfies DropdownItem;
}),
];
return (
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
<GitMenuButton>

View File

@@ -10,7 +10,7 @@ import {
TableHeaderCell,
TableRow,
} from "@yaakapp-internal/ui";
import { gitCallbacks } from "./callbacks";
import { useGitCallbacks } from "./callbacks";
import { addGitRemote } from "./showAddRemoteDialog";
interface Props {
@@ -19,7 +19,8 @@ interface Props {
}
export function GitRemotesDialog({ dir }: Props) {
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
const callbacks = useGitCallbacks(dir);
const [{ remotes }, { rmRemote }] = useGit(dir, callbacks);
return (
<Table scrollable>

View File

@@ -1,4 +1,4 @@
import type { GitCommit } from "@yaakapp-internal/git";
import { useGitLog } from "@yaakapp-internal/git";
import { formatDistanceToNowStrict } from "date-fns";
import {
Table,
@@ -10,11 +10,9 @@ import {
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
interface Props {
log: GitCommit[];
}
export function HistoryDialog({ dir }: { dir: string }) {
const log = useGitLog(dir);
export function HistoryDialog({ log }: Props) {
return (
<div className="pl-5 pr-1 pb-1">
<Table scrollable className="px-1">
@@ -26,8 +24,8 @@ export function HistoryDialog({ log }: Props) {
</TableRow>
</TableHead>
<TableBody>
{log.map((l) => (
<TableRow key={(l.author.name ?? "") + (l.message ?? "n/a") + l.when}>
{(log.data ?? []).map((l) => (
<TableRow key={l.oid}>
<TruncatedWideTableCell>
{l.message || <em className="text-text-subtle">No message</em>}
</TruncatedWideTableCell>

View File

@@ -1,4 +1,5 @@
import type { GitCallbacks } from "@yaakapp-internal/git";
import { useMemo } from "react";
import { sync } from "../../init/sync";
import { promptCredentials } from "./credentials";
import { promptDivergedStrategy } from "./diverged";
@@ -24,3 +25,7 @@ export function gitCallbacks(dir: string): GitCallbacks {
forceSync: () => sync({ force: true }),
};
}
export function useGitCallbacks(dir: string): GitCallbacks {
return useMemo(() => gitCallbacks(dir), [dir]);
}