mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 19:01:38 +01:00
Add .oxfmtignore to skip generated bindings and wasm-pack output. Add npm format script, update DEVELOPMENT.md for Vite+ toolchain, and format all non-generated files with oxfmt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
657 lines
20 KiB
TypeScript
657 lines
20 KiB
TypeScript
import { useGit } 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 { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
|
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
import { useRandomKey } from "../../hooks/useRandomKey";
|
|
import { sync } from "../../init/sync";
|
|
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
|
import { showDialog } from "../../lib/dialog";
|
|
import { fireAndForget } from "../../lib/fireAndForget";
|
|
import { showPrompt } from "../../lib/prompt";
|
|
import { showErrorToast, showToast } from "../../lib/toast";
|
|
import { Banner } from "../core/Banner";
|
|
import type { DropdownItem } from "../core/Dropdown";
|
|
import { Dropdown } from "../core/Dropdown";
|
|
import { Icon } from "../core/Icon";
|
|
import { InlineCode } from "../core/InlineCode";
|
|
import { gitCallbacks } from "./callbacks";
|
|
import { GitCommitDialog } from "./GitCommitDialog";
|
|
import { GitRemotesDialog } from "./GitRemotesDialog";
|
|
import { handlePullResult, handlePushResult } from "./git-util";
|
|
import { HistoryDialog } from "./HistoryDialog";
|
|
|
|
export function GitDropdown() {
|
|
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
|
if (workspaceMeta == null) return null;
|
|
|
|
if (workspaceMeta.settingSyncDir == null) {
|
|
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
|
|
}
|
|
|
|
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
|
|
}
|
|
|
|
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
const [refreshKey, regenerateKey] = useRandomKey();
|
|
const [
|
|
{ status, log },
|
|
{
|
|
createBranch,
|
|
deleteBranch,
|
|
deleteRemoteBranch,
|
|
renameBranch,
|
|
mergeBranch,
|
|
push,
|
|
pull,
|
|
checkout,
|
|
resetChanges,
|
|
init,
|
|
},
|
|
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
|
|
|
|
const localBranches = status.data?.localBranches ?? [];
|
|
const remoteBranches = status.data?.remoteBranches ?? [];
|
|
const remoteOnlyBranches = remoteBranches.filter(
|
|
(b) => !localBranches.includes(b.replace(/^origin\//, "")),
|
|
);
|
|
if (workspace == null) {
|
|
return null;
|
|
}
|
|
|
|
const noRepo = status.error?.includes("not found");
|
|
if (noRepo) {
|
|
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
|
}
|
|
|
|
// Still loading
|
|
if (status.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>
|
|
<InlineCode className="flex items-center gap-1">
|
|
<Icon icon="git_branch" size="xs" className="opacity-50" />
|
|
{currentBranch}
|
|
</InlineCode>
|
|
<div className="flex items-center gap-1.5">
|
|
{ahead > 0 && (
|
|
<span className="text-xs flex items-center gap-0.5">
|
|
<span className="text-primary">↗</span>
|
|
{ahead}
|
|
</span>
|
|
)}
|
|
{behind > 0 && (
|
|
<span className="text-xs flex items-center gap-0.5">
|
|
<span className="text-info">↙</span>
|
|
{behind}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</GitMenuButton>
|
|
</Dropdown>
|
|
);
|
|
}
|
|
|
|
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
|
|
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
className={classNames(
|
|
className,
|
|
"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight",
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
|
|
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
|
|
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
|
key: "setup_sync",
|
|
fallback: {},
|
|
});
|
|
|
|
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
|
|
return null;
|
|
}
|
|
|
|
const banner = (
|
|
<Banner color="info">
|
|
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
|
|
Git collaboration.
|
|
</Banner>
|
|
);
|
|
|
|
return (
|
|
<Dropdown
|
|
fullWidth
|
|
items={[
|
|
{
|
|
type: "content",
|
|
label: banner,
|
|
},
|
|
{
|
|
color: "success",
|
|
label: "Open Workspace Settings",
|
|
leftSlot: <Icon icon="settings" />,
|
|
onSelect: () => openWorkspaceSettings("data"),
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: "Hide This Message",
|
|
leftSlot: <Icon icon="eye_closed" />,
|
|
async onSelect() {
|
|
const confirmed = await showConfirm({
|
|
id: "hide-sync-menu-prompt",
|
|
title: "Hide Setup Message",
|
|
description: "You can configure filesystem sync or Git it in the workspace settings",
|
|
});
|
|
if (confirmed) {
|
|
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
|
|
}
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<GitMenuButton>
|
|
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
|
<Icon icon="wrench" />
|
|
<div className="truncate">Setup FS Sync or Git</div>
|
|
</div>
|
|
</GitMenuButton>
|
|
</Dropdown>
|
|
);
|
|
}
|
|
|
|
function SetupGitDropdown({
|
|
workspaceId,
|
|
initRepo,
|
|
}: {
|
|
workspaceId: string;
|
|
initRepo: () => void;
|
|
}) {
|
|
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
|
key: "setup_git_repo",
|
|
fallback: {},
|
|
});
|
|
|
|
if (hidden == null || hidden[workspaceId]) {
|
|
return null;
|
|
}
|
|
|
|
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
|
|
|
|
return (
|
|
<Dropdown
|
|
fullWidth
|
|
items={[
|
|
{ type: "content", label: banner },
|
|
{
|
|
label: "Initialize Git Repo",
|
|
leftSlot: <Icon icon="magic_wand" />,
|
|
onSelect: initRepo,
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: "Hide This Message",
|
|
leftSlot: <Icon icon="eye_closed" />,
|
|
async onSelect() {
|
|
const confirmed = await showConfirm({
|
|
id: "hide-git-init-prompt",
|
|
title: "Hide Git Setup",
|
|
description: "You can initialize a git repo outside of Yaak to bring this back",
|
|
});
|
|
if (confirmed) {
|
|
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
|
|
}
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<GitMenuButton>
|
|
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
|
<Icon icon="folder_git" />
|
|
<div className="truncate">Setup Git</div>
|
|
</div>
|
|
</GitMenuButton>
|
|
</Dropdown>
|
|
);
|
|
}
|