import { useCachedNode } from '@dnd-kit/core/dist/hooks/utilities'; 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 { useCallback, useMemo, useState } from 'react'; import { modelToYaml } from '../../lib/diffYaml'; import { isSubEnvironment } from '../../lib/model_util'; 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 { DiffViewer } from '../core/Editor/DiffViewer'; 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 { gitCallbacks } from './callbacks'; import { handlePushResult } from './git-util'; 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(null); const [message, setMessage] = useState(''); const [selectedEntry, setSelectedEntry] = useState(null); 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({ id: 'git-commit-and-push-error', title: 'Error committing and pushing', message: 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]); const checkNode = useCallback( (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 }, [add.mutate, unstage.mutate], ); const checkEntry = useCallback( (entry: GitStatusEntry) => { if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] }); else add.mutate({ relaPaths: [entry.relaPath] }); }, [add.mutate, unstage.mutate], ); const handleSelectChild = useCallback( (entry: GitStatusEntry) => { if (entry === selectedEntry) { setSelectedEntry(null); } else { setSelectedEntry(entry); } }, [selectedEntry], ); if (tree == null) { return null; } if (!hasAnythingToAdd) { return No changes since last commit; } return (
(
(
{externalEntries.find((e) => e.status !== 'current') && ( <> External file changes {externalEntries.map((entry) => ( ))} )}
)} secondSlot={({ style: innerStyle }) => (
{commitError && {commitError}} {status.data?.headRefShorthand}
)} />
)} secondSlot={({ style }) => (
{selectedEntry ? ( ) : ( Select a change to view diff )}
)} />
); } function TreeNodeChildren({ node, depth, onCheck, onSelect, selectedPath, }: { node: CommitTreeNode | null; depth: number; onCheck: (node: CommitTreeNode, checked: boolean) => void; onSelect: (entry: GitStatusEntry) => void; selectedPath: string | null; }) { if (node === null) return null; if (!isNodeRelevant(node)) return null; const checked = nodeCheckedStatus(node); const isSelected = selectedPath === node.status.relaPath; return (
0 && 'pl-4 ml-2 border-l border-dashed border-border-subtle relative', )} >
{isSelected && (
)} onCheck(node, checked)} />
{node.children.map((childNode) => { return ( ); })}
); } function ExternalTreeNode({ entry, onCheck, }: { entry: GitStatusEntry; onCheck: (entry: GitStatusEntry) => void; }) { if (entry.status === 'current') { return null; } return ( onCheck(entry)} title={
{entry.relaPath}
{entry.status}
} /> ); } 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; } if (numChecked === 0) { return false; } 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)); } function DiffPanel({ entry }: { entry: GitStatusEntry }) { const prevYaml = modelToYaml(entry.prev); const nextYaml = modelToYaml(entry.next); return (
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
); }