mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-11 17:46:41 -05:00
Move a bunch of git ops to use the git binary (#302)
This commit is contained in:
399
src-web/components/git/GitCommitDialog.tsx
Normal file
399
src-web/components/git/GitCommitDialog.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import type { GitStatusEntry } from '@yaakapp-internal/git';
|
||||
import { useGit } from '@yaakapp-internal/git';
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { resolvedModelName } from '../../lib/resolvedModelName';
|
||||
import { showErrorToast } from '../../lib/toast';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import type { CheckboxProps } from '../core/Checkbox';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { Input } from '../core/Input';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { SplitLayout } from '../core/SplitLayout';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { EmptyStateText } from '../EmptyStateText';
|
||||
import { handlePushResult } from './git-util';
|
||||
import { gitCallbacks } from './callbacks';
|
||||
|
||||
interface Props {
|
||||
syncDir: string;
|
||||
onDone: () => void;
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
interface CommitTreeNode {
|
||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;
|
||||
status: GitStatusEntry;
|
||||
children: CommitTreeNode[];
|
||||
ancestors: CommitTreeNode[];
|
||||
}
|
||||
|
||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(
|
||||
syncDir,
|
||||
gitCallbacks(syncDir),
|
||||
);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
const handleCreateCommit = async () => {
|
||||
setCommitError(null);
|
||||
try {
|
||||
await commit.mutateAsync({ message });
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setCommitError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCommitAndPush = async () => {
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const r = await commitAndPush.mutateAsync({ message });
|
||||
handlePushResult(r);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
showErrorToast('git-commit-and-push-error', String(err));
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { internalEntries, externalEntries, allEntries } = useMemo(() => {
|
||||
const allEntries = [];
|
||||
const yaakEntries = [];
|
||||
const externalEntries = [];
|
||||
|
||||
for (const entry of status.data?.entries ?? []) {
|
||||
allEntries.push(entry);
|
||||
if (entry.next == null && entry.prev == null) {
|
||||
externalEntries.push(entry);
|
||||
} else {
|
||||
yaakEntries.push(entry);
|
||||
}
|
||||
}
|
||||
return { internalEntries: yaakEntries, externalEntries, allEntries };
|
||||
}, [status.data?.entries]);
|
||||
|
||||
const hasAddedAnything = allEntries.find((e) => e.staged) != null;
|
||||
const hasAnythingToAdd = allEntries.find((e) => e.status !== 'current') != null;
|
||||
|
||||
const tree: CommitTreeNode | null = useMemo(() => {
|
||||
const next = (
|
||||
model: CommitTreeNode['model'],
|
||||
ancestors: CommitTreeNode[],
|
||||
): CommitTreeNode | null => {
|
||||
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
|
||||
if (statusEntry == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node: CommitTreeNode = {
|
||||
model,
|
||||
status: statusEntry,
|
||||
children: [],
|
||||
ancestors,
|
||||
};
|
||||
|
||||
for (const entry of internalEntries) {
|
||||
const childModel = entry.next ?? entry.prev;
|
||||
|
||||
// Should never happen because we're iterating internalEntries
|
||||
if (childModel == null) continue;
|
||||
|
||||
// TODO: Figure out why not all of these show up
|
||||
if ('folderId' in childModel && childModel.folderId != null) {
|
||||
if (childModel.folderId === model.id) {
|
||||
const c = next(childModel, [...ancestors, node]);
|
||||
if (c != null) node.children.push(c);
|
||||
}
|
||||
} else if ('workspaceId' in childModel && childModel.workspaceId === model.id) {
|
||||
const c = next(childModel, [...ancestors, node]);
|
||||
if (c != null) node.children.push(c);
|
||||
} else {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return next(workspace, []);
|
||||
}, [workspace, internalEntries]);
|
||||
|
||||
if (tree == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasAnythingToAdd) {
|
||||
return <EmptyStateText>No changes since last commit</EmptyStateText>;
|
||||
}
|
||||
|
||||
const checkNode = (treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
const newChecked = checked === 'indeterminate' ? true : !checked;
|
||||
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
|
||||
// TODO: Also ensure parents are added properly
|
||||
};
|
||||
|
||||
const checkEntry = (entry: GitStatusEntry) => {
|
||||
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
|
||||
else add.mutate({ relaPaths: [entry.relaPath] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-1 h-full">
|
||||
<SplitLayout
|
||||
name="commit"
|
||||
layout="vertical"
|
||||
defaultRatio={0.3}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="h-full overflow-y-auto pb-3">
|
||||
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
|
||||
{externalEntries.find((e) => e.status !== 'current') && (
|
||||
<>
|
||||
<Separator className="mt-3 mb-1">External file changes</Separator>
|
||||
{externalEntries.map((entry) => (
|
||||
<ExternalTreeNode
|
||||
key={entry.relaPath + entry.status}
|
||||
entry={entry}
|
||||
onCheck={checkEntry}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style }) => (
|
||||
<div style={style} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="!text-base font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
onChange={setMessage}
|
||||
stateKey={null}
|
||||
label="Commit message"
|
||||
fullHeight
|
||||
multiLine
|
||||
hideLabel
|
||||
/>
|
||||
{commitError && <Banner color="danger">{commitError}</Banner>}
|
||||
<HStack alignItems="center">
|
||||
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
|
||||
<HStack space={2} className="ml-auto">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCreateCommit}
|
||||
disabled={!hasAddedAnything}
|
||||
isLoading={isPushing}
|
||||
>
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!hasAddedAnything}
|
||||
onClick={handleCreateCommitAndPush}
|
||||
isLoading={isPushing}
|
||||
>
|
||||
Commit and Push
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodeChildren({
|
||||
node,
|
||||
depth,
|
||||
onCheck,
|
||||
}: {
|
||||
node: CommitTreeNode | null;
|
||||
depth: number;
|
||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||
}) {
|
||||
if (node === null) return null;
|
||||
if (!isNodeRelevant(node)) return null;
|
||||
|
||||
const checked = nodeCheckedStatus(node);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && 'pl-1 ml-[10px] border-l border-dashed border-border-subtle',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3 w-full h-xs">
|
||||
<Checkbox
|
||||
fullWidth
|
||||
className="w-full hover:bg-surface-highlight rounded px-1 group"
|
||||
checked={checked}
|
||||
onChange={(checked) => onCheck(node, checked)}
|
||||
title={
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||
{node.model.model !== 'http_request' &&
|
||||
node.model.model !== 'grpc_request' &&
|
||||
node.model.model !== 'websocket_request' ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === 'folder'
|
||||
? 'folder'
|
||||
: node.model.model === 'environment'
|
||||
? 'variable'
|
||||
: 'house'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
<div className="truncate">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== 'current' && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
'py-0 ml-auto bg-transparent w-[6rem] text-center',
|
||||
node.status.status === 'modified' && 'text-info',
|
||||
node.status.status === 'untracked' && 'text-success',
|
||||
node.status.status === 'removed' && 'text-danger',
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{node.children.map((childNode, i) => {
|
||||
return (
|
||||
<TreeNodeChildren
|
||||
key={childNode.status.relaPath + i}
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
onCheck={onCheck}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalTreeNode({
|
||||
entry,
|
||||
onCheck,
|
||||
}: {
|
||||
entry: GitStatusEntry;
|
||||
onCheck: (entry: GitStatusEntry) => void;
|
||||
}) {
|
||||
if (entry.status === 'current') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
fullWidth
|
||||
className="h-xs w-full hover:bg-surface-highlight rounded px-1 group"
|
||||
checked={entry.staged}
|
||||
onChange={() => onCheck(entry)}
|
||||
title={
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||
<Icon color="secondary" icon="file_code" />
|
||||
<div className="truncate">{entry.relaPath}</div>
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
'py-0 ml-auto bg-transparent w-[6rem] text-center',
|
||||
entry.status === 'modified' && 'text-info',
|
||||
entry.status === 'untracked' && 'text-success',
|
||||
entry.status === 'removed' && 'text-danger',
|
||||
)}
|
||||
>
|
||||
{entry.status}
|
||||
</InlineCode>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(root: CommitTreeNode): CheckboxProps['checked'] {
|
||||
let numVisited = 0;
|
||||
let numChecked = 0;
|
||||
let numCurrent = 0;
|
||||
|
||||
const visitChildren = (n: CommitTreeNode) => {
|
||||
numVisited += 1;
|
||||
if (n.status.status === 'current') {
|
||||
numCurrent += 1;
|
||||
} else if (n.status.staged) {
|
||||
numChecked += 1;
|
||||
}
|
||||
for (const child of n.children) {
|
||||
visitChildren(child);
|
||||
}
|
||||
};
|
||||
|
||||
visitChildren(root);
|
||||
|
||||
if (numVisited === numChecked + numCurrent) {
|
||||
return true;
|
||||
} else if (numChecked === 0) {
|
||||
return false;
|
||||
} else {
|
||||
return 'indeterminate';
|
||||
}
|
||||
}
|
||||
|
||||
function setCheckedAndChildren(
|
||||
node: CommitTreeNode,
|
||||
checked: boolean,
|
||||
unstage: (args: { relaPaths: string[] }) => void,
|
||||
add: (args: { relaPaths: string[] }) => void,
|
||||
) {
|
||||
const toAdd: string[] = [];
|
||||
const toUnstage: string[] = [];
|
||||
|
||||
const next = (node: CommitTreeNode) => {
|
||||
for (const child of node.children) {
|
||||
next(child);
|
||||
}
|
||||
|
||||
if (node.status.status === 'current') {
|
||||
// Nothing required
|
||||
} else if (checked && !node.status.staged) {
|
||||
toAdd.push(node.status.relaPath);
|
||||
} else if (!checked && node.status.staged) {
|
||||
toUnstage.push(node.status.relaPath);
|
||||
}
|
||||
};
|
||||
|
||||
next(node);
|
||||
|
||||
if (toAdd.length > 0) add({ relaPaths: toAdd });
|
||||
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
||||
}
|
||||
|
||||
function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||
if (node.status.status !== 'current') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursively check children
|
||||
return node.children.some((c) => isNodeRelevant(c));
|
||||
}
|
||||
419
src-web/components/git/GitDropdown.tsx
Normal file
419
src-web/components/git/GitDropdown.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
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 { sync } from '../../init/sync';
|
||||
import { showConfirm, showConfirmDelete } from '../../lib/confirm';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
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 { BranchSelectionDialog } from './BranchSelectionDialog';
|
||||
import { gitCallbacks } from './callbacks';
|
||||
import { handlePullResult } from './git-util';
|
||||
import { GitCommitDialog } from './GitCommitDialog';
|
||||
import { GitRemotesDialog } from './GitRemotesDialog';
|
||||
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 [
|
||||
{ status, log },
|
||||
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
|
||||
] = useGit(syncDir, gitCallbacks(syncDir));
|
||||
|
||||
const localBranches = status.data?.localBranches ?? [];
|
||||
const remoteBranches = status.data?.remoteBranches ?? [];
|
||||
const remoteOnlyBranches = remoteBranches.filter(
|
||||
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
|
||||
);
|
||||
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
|
||||
|
||||
if (workspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noRepo = status.error?.includes('not found');
|
||||
if (noRepo) {
|
||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
||||
}
|
||||
|
||||
const tryCheckout = (branch: string, force: boolean) => {
|
||||
checkout.mutate(
|
||||
{ branch, force },
|
||||
{
|
||||
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('git-checkout-error', 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',
|
||||
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 branch.mutateAsync(
|
||||
{ branch: name },
|
||||
{
|
||||
onError: (err) => {
|
||||
showErrorToast('git-branch-error', String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
tryCheckout(name, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Merge Branch',
|
||||
leftSlot: <Icon icon="merge" />,
|
||||
hidden: localBranches.length <= 1,
|
||||
async onSelect() {
|
||||
showDialog({
|
||||
id: 'git-merge',
|
||||
title: 'Merge Branch',
|
||||
size: 'sm',
|
||||
description: (
|
||||
<>
|
||||
Select a branch to merge into <InlineCode>{currentBranch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
render: ({ hide }) => (
|
||||
<BranchSelectionDialog
|
||||
selectText="Merge"
|
||||
branches={localBranches.filter((b) => b !== currentBranch)}
|
||||
onCancel={hide}
|
||||
onSelect={async (branch) => {
|
||||
await mergeBranch.mutateAsync(
|
||||
{ branch, force: false },
|
||||
{
|
||||
onSettled: hide,
|
||||
onSuccess() {
|
||||
showToast({
|
||||
id: 'git-merged-branch',
|
||||
message: (
|
||||
<>
|
||||
Merged <InlineCode>{branch}</InlineCode> into{' '}
|
||||
<InlineCode>{currentBranch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
sync({ force: true });
|
||||
},
|
||||
onError(err) {
|
||||
showErrorToast('git-merged-branch-error', String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Branch',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
hidden: localBranches.length <= 1,
|
||||
color: 'danger',
|
||||
async onSelect() {
|
||||
if (currentBranch == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'git-delete-branch',
|
||||
title: 'Delete Branch',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await deleteBranch.mutateAsync(
|
||||
{ branch: currentBranch },
|
||||
{
|
||||
onError(err) {
|
||||
showErrorToast('git-delete-branch-error', String(err));
|
||||
},
|
||||
async onSuccess() {
|
||||
await sync({ force: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Push',
|
||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
||||
waitForOnSelect: true,
|
||||
async onSelect() {
|
||||
await push.mutateAsync(undefined, {
|
||||
onSuccess: handlePullResult,
|
||||
onError(err) {
|
||||
showErrorToast('git-pull-error', String(err));
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Pull',
|
||||
hidden: (status.data?.origins ?? []).length === 0,
|
||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
||||
waitForOnSelect: true,
|
||||
async onSelect() {
|
||||
await pull.mutateAsync(undefined, {
|
||||
onSuccess: handlePullResult,
|
||||
onError(err) {
|
||||
showErrorToast('git-pull-error', String(err));
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||
onSelect() {
|
||||
showDialog({
|
||||
id: 'commit',
|
||||
title: 'Commit Changes',
|
||||
size: 'full',
|
||||
className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]',
|
||||
render: ({ hide }) => (
|
||||
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
|
||||
...localBranches.map((branch) => {
|
||||
const isCurrent = currentBranch === branch;
|
||||
return {
|
||||
label: branch,
|
||||
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
||||
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
|
||||
};
|
||||
}),
|
||||
...remoteOnlyBranches.map((branch) => {
|
||||
const isCurrent = currentBranch === branch;
|
||||
return {
|
||||
label: branch,
|
||||
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
||||
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
|
||||
<GitMenuButton>
|
||||
<InlineCode>{currentBranch}</InlineCode>
|
||||
<Icon icon="git_branch" size="sm" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
67
src-web/components/git/GitRemotesDialog.tsx
Normal file
67
src-web/components/git/GitRemotesDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useGit } from '@yaakapp-internal/git';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
import { Button } from '../core/Button';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||
import { gitCallbacks } from './callbacks';
|
||||
import { addGitRemote } from './showAddRemoteDialog';
|
||||
|
||||
interface Props {
|
||||
dir: string;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GitRemotesDialog({ dir }: Props) {
|
||||
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>URL</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<Button
|
||||
className="text-text-subtle ml-auto"
|
||||
size="2xs"
|
||||
color="primary"
|
||||
title="Add remote"
|
||||
variant="border"
|
||||
onClick={() => addGitRemote(dir)}
|
||||
>
|
||||
Add Remote
|
||||
</Button>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{remotes.data?.map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<TableCell>{r.url}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
size="sm"
|
||||
className="text-text-subtle ml-auto"
|
||||
icon="trash"
|
||||
title="Remove remote"
|
||||
onClick={() => rmRemote.mutate({ name: r.name })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GitRemotesDialog.show = function (dir: string) {
|
||||
showDialog({
|
||||
id: 'git-remotes',
|
||||
title: 'Manage Remotes',
|
||||
size: 'md',
|
||||
render: ({ hide }) => <GitRemotesDialog onDone={hide} dir={dir} />,
|
||||
});
|
||||
};
|
||||
48
src-web/components/git/callbacks.tsx
Normal file
48
src-web/components/git/callbacks.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { GitCallbacks } from '@yaakapp-internal/git';
|
||||
import { showPromptForm } from '../../lib/prompt-form';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { addGitRemote } from './showAddRemoteDialog';
|
||||
|
||||
export function gitCallbacks(dir: string): GitCallbacks {
|
||||
return {
|
||||
addRemote: async () => {
|
||||
return addGitRemote(dir);
|
||||
},
|
||||
promptCredentials: async ({ url: remoteUrl, error }) => {
|
||||
const isGitHub = /github\.com/i.test(remoteUrl);
|
||||
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
|
||||
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
|
||||
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
|
||||
const passDescription = isGitHub
|
||||
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
|
||||
: 'Enter your password or access token for this Git server.';
|
||||
const r = await showPromptForm({
|
||||
id: 'git-credentials',
|
||||
title: 'Credentials Required',
|
||||
description: error ? (
|
||||
<Banner color="danger">{error}</Banner>
|
||||
) : (
|
||||
<>
|
||||
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
|
||||
</>
|
||||
),
|
||||
inputs: [
|
||||
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
label: passLabel,
|
||||
description: passDescription,
|
||||
password: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (r == null) throw new Error('Cancelled credentials prompt');
|
||||
|
||||
const username = String(r.username || '');
|
||||
const password = String(r.password || '');
|
||||
return { username, password };
|
||||
},
|
||||
};
|
||||
}
|
||||
30
src-web/components/git/git-util.ts
Normal file
30
src-web/components/git/git-util.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PullResult, PushResult } from '@yaakapp-internal/git';
|
||||
import { showToast } from '../../lib/toast';
|
||||
|
||||
export function handlePushResult(r: PushResult) {
|
||||
switch (r.type) {
|
||||
case 'needs_credentials':
|
||||
showToast({ id: 'push-error', message: 'Credentials not found', color: 'danger' });
|
||||
break;
|
||||
case 'success':
|
||||
showToast({ id: 'push-success', message: r.message, color: 'success' });
|
||||
break;
|
||||
case 'up_to_date':
|
||||
showToast({ id: 'push-nothing', message: 'Already up-to-date', color: 'info' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePullResult(r: PullResult) {
|
||||
switch (r.type) {
|
||||
case 'needs_credentials':
|
||||
showToast({ id: 'pull-error', message: 'Credentials not found', color: 'danger' });
|
||||
break;
|
||||
case 'success':
|
||||
showToast({ id: 'pull-success', message: r.message, color: 'success' });
|
||||
break;
|
||||
case 'up_to_date':
|
||||
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
20
src-web/components/git/showAddRemoteDialog.tsx
Normal file
20
src-web/components/git/showAddRemoteDialog.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { GitRemote } from '@yaakapp-internal/git';
|
||||
import { gitMutations } from '@yaakapp-internal/git';
|
||||
import { showPromptForm } from '../../lib/prompt-form';
|
||||
import { gitCallbacks } from './callbacks';
|
||||
|
||||
export async function addGitRemote(dir: string): Promise<GitRemote> {
|
||||
const r = await showPromptForm({
|
||||
id: 'add-remote',
|
||||
title: 'Add Remote',
|
||||
inputs: [
|
||||
{ type: 'text', label: 'Name', name: 'name' },
|
||||
{ type: 'text', label: 'URL', name: 'url' },
|
||||
],
|
||||
});
|
||||
if (r == null) throw new Error('Cancelled remote prompt');
|
||||
|
||||
const name = String(r.name ?? '');
|
||||
const url = String(r.url ?? '');
|
||||
return gitMutations(dir, gitCallbacks(dir)).addRemote.mutateAsync({ name, url });
|
||||
}
|
||||
Reference in New Issue
Block a user