mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-12 10:49:58 +02:00
Add live git status indicators (#458)
This commit is contained in:
@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { Compartment } from "@codemirror/state";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { gitMutations } from "@yaakapp-internal/git";
|
||||
import type { GitStatus } from "@yaakapp-internal/git";
|
||||
import type {
|
||||
AnyModel,
|
||||
Folder,
|
||||
@@ -23,13 +25,18 @@ import {
|
||||
} from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily, selectAtom } from "jotai/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { moveToWorkspace } from "../commands/moveToWorkspace";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import {
|
||||
activeWorkspaceAtom,
|
||||
activeWorkspaceIdAtom,
|
||||
activeWorkspaceMetaAtom,
|
||||
} from "../hooks/useActiveWorkspace";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
||||
import { getFolderActions } from "../hooks/useFolderActions";
|
||||
@@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import {
|
||||
gitWorktreeStatusByModelIdAtom,
|
||||
gitWorktreeStatusFamily,
|
||||
} from "../lib/gitWorktreeStatus";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { isSidebarFocused } from "../lib/scopes";
|
||||
@@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input";
|
||||
import { Input } from "./core/Input";
|
||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||
import { GitDropdown } from "./git/GitDropdown";
|
||||
import { gitCallbacks } from "./git/callbacks";
|
||||
import { FileHistoryDialog } from "./git/FileHistoryDialog";
|
||||
import { sync } from "../init/sync";
|
||||
|
||||
const collapsedFamily = atomFamily((treeId: string) => {
|
||||
const key = ["sidebar_collapsed", treeId ?? "n/a"];
|
||||
@@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir;
|
||||
const gitItems = getGitContextMenuItems({ items, syncDir });
|
||||
const onlyHttpRequests = items.every((i) => i.model === "http_request");
|
||||
const requestItems = items.filter(
|
||||
(i) =>
|
||||
@@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) {
|
||||
...initialItems,
|
||||
{
|
||||
type: "separator",
|
||||
hidden: initialItems.filter((v) => !v.hidden).length === 0,
|
||||
hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
|
||||
},
|
||||
...gitItems,
|
||||
{ type: "separator", hidden: gitItems.length === 0 },
|
||||
{
|
||||
label: "Rename",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
@@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) {
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
function getGitContextMenuItems({
|
||||
items,
|
||||
syncDir,
|
||||
}: {
|
||||
items: SidebarModel[];
|
||||
syncDir: string | null | undefined;
|
||||
}): DropdownItem[] {
|
||||
if (syncDir == null) return [];
|
||||
|
||||
const gitStatusEntries = items.flatMap((item) => {
|
||||
const status = jotaiStore.get(gitWorktreeStatusFamily(item.id));
|
||||
return status == null || status.status === "current" ? [] : [status];
|
||||
});
|
||||
const historyItem = items.length === 1 ? items[0] : null;
|
||||
const historyPath =
|
||||
historyItem == null
|
||||
? null
|
||||
: (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ??
|
||||
syncPathForModel(historyItem));
|
||||
|
||||
return [
|
||||
{
|
||||
label: "View History",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
hidden: historyPath == null,
|
||||
onSelect: () => {
|
||||
if (historyPath == null) return;
|
||||
showDialog({
|
||||
id: "git-history",
|
||||
size: "lg",
|
||||
title: "File History",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
render: () => <FileHistoryDialog dir={syncDir} relaPath={historyPath} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Restore Changes",
|
||||
leftSlot: <Icon icon="rotate_ccw" />,
|
||||
hidden: gitStatusEntries.length === 0,
|
||||
async onSelect() {
|
||||
const confirmed = await showConfirm({
|
||||
id: "git-restore-sidebar-items",
|
||||
title: "Restore Changes",
|
||||
description:
|
||||
gitStatusEntries.length === 1
|
||||
? "This will discard uncommitted changes for the selected item."
|
||||
: `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`,
|
||||
confirmText: "Restore",
|
||||
color: "danger",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({
|
||||
relaPaths: gitStatusEntries.map((entry) => entry.relaPath),
|
||||
});
|
||||
await sync({ force: true });
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function syncPathForModel(item: SidebarModel) {
|
||||
return `yaak.${item.id}.yaml`;
|
||||
}
|
||||
|
||||
const activeIdAtom = atom<string | null>((get) => {
|
||||
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||
});
|
||||
@@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
||||
return [root, fields] as const;
|
||||
});
|
||||
|
||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom);
|
||||
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
|
||||
const statusByModelId: Record<string, GitStatus> = {};
|
||||
|
||||
for (const item of allModels) {
|
||||
if ("folderId" in item && item.folderId == null) {
|
||||
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
||||
childrenMap[item.workspaceId]?.push(item);
|
||||
} else if ("folderId" in item && item.folderId != null) {
|
||||
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
||||
childrenMap[item.folderId]?.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (item: SidebarModel): GitStatus | null => {
|
||||
const statuses: GitStatus[] = [];
|
||||
const directStatus = gitStatusByModelId[item.id]?.status;
|
||||
if (directStatus != null && directStatus !== "current") {
|
||||
statuses.push(directStatus);
|
||||
}
|
||||
|
||||
for (const child of childrenMap[item.id] ?? []) {
|
||||
const childStatus = visit(child);
|
||||
if (childStatus != null) statuses.push(childStatus);
|
||||
}
|
||||
|
||||
const status = summarizeGitStatuses(statuses);
|
||||
if (status != null) {
|
||||
statusByModelId[item.id] = status;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
if (activeWorkspace != null) {
|
||||
visit(activeWorkspace);
|
||||
}
|
||||
|
||||
return statusByModelId;
|
||||
});
|
||||
|
||||
const sidebarGitStatusFamily = atomFamily(
|
||||
(modelId: string) =>
|
||||
selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null),
|
||||
Object.is,
|
||||
);
|
||||
|
||||
function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null {
|
||||
if (statuses.length === 0) return null;
|
||||
const firstStatus = statuses[0];
|
||||
if (firstStatus != null && statuses.every((status) => status === firstStatus)) {
|
||||
return firstStatus;
|
||||
}
|
||||
return "modified";
|
||||
}
|
||||
|
||||
function getItemKey(item: SidebarModel) {
|
||||
const responses = jotaiStore.get(httpResponsesAtom);
|
||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||
@@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
||||
treeId: string;
|
||||
item: SidebarModel;
|
||||
}) {
|
||||
const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
|
||||
const response = useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
@@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
||||
<div className="truncate">{resolvedModelName(item)}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"truncate",
|
||||
gitStatus === "modified" && "text-info",
|
||||
gitStatus === "untracked" && "text-success",
|
||||
gitStatus === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{resolvedModelName(item)}
|
||||
</div>
|
||||
{response != null && (
|
||||
<div className="ml-auto">
|
||||
{response.state !== "closed" ? (
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
|
||||
export function HttpResponseDurationTag({ response }: Props) {
|
||||
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
||||
const timeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const timeout = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
|
||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleOpenImmediate = () => {
|
||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||
|
||||
131
apps/yaak-client/components/git/FileHistoryDialog.tsx
Normal file
131
apps/yaak-client/components/git/FileHistoryDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
38
apps/yaak-client/init/git.ts
Normal file
38
apps/yaak-client/init/git.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
||||
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
||||
import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function initGit() {
|
||||
let watchedDir: string | null = null;
|
||||
let unwatch: null | ReturnType<typeof watchGitWorktreeStatus> = null;
|
||||
|
||||
const watchActiveWorkspace = () => {
|
||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
|
||||
if (syncDir === watchedDir) return;
|
||||
|
||||
void unwatch?.();
|
||||
unwatch = null;
|
||||
watchedDir = syncDir;
|
||||
jotaiStore.set(gitWorktreeStatusAtom, null);
|
||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
|
||||
|
||||
if (syncDir == null) return;
|
||||
|
||||
unwatch = watchGitWorktreeStatus(syncDir, (status) => {
|
||||
if (syncDir !== watchedDir) return;
|
||||
|
||||
jotaiStore.set(gitWorktreeStatusAtom, status);
|
||||
|
||||
const statusByModelId: Record<string, GitWorktreeStatusEntry> = {};
|
||||
for (const entry of status.entries) {
|
||||
if (entry.modelId == null) continue;
|
||||
statusByModelId[entry.modelId] = entry;
|
||||
}
|
||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
|
||||
});
|
||||
};
|
||||
|
||||
watchActiveWorkspace();
|
||||
jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
|
||||
}
|
||||
@@ -29,13 +29,45 @@ const debouncedSync = debounce(async () => {
|
||||
await sync();
|
||||
}, 1000);
|
||||
|
||||
let modelSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let modelSyncInFlight = false;
|
||||
|
||||
function scheduleModelSync() {
|
||||
if (modelSyncTimer == null) {
|
||||
// No timer means this is the first model change in a burst, so sync immediately.
|
||||
void syncModelChanges();
|
||||
} else {
|
||||
// Keep pushing the trailing sync out until model writes have been quiet for a bit.
|
||||
clearTimeout(modelSyncTimer);
|
||||
}
|
||||
|
||||
modelSyncTimer = setTimeout(async () => {
|
||||
modelSyncTimer = null;
|
||||
// Catch any final state that was written while the immediate sync was running.
|
||||
await syncModelChanges();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function syncModelChanges() {
|
||||
if (modelSyncInFlight) return;
|
||||
|
||||
modelSyncInFlight = true;
|
||||
try {
|
||||
await sync();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
modelSyncInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to model change events. Since we check the workspace ID on sync, we can
|
||||
* simply add long-lived subscribers for the lifetime of the app.
|
||||
*/
|
||||
function initModelListeners() {
|
||||
listenToTauriEvent<ModelPayload>("model_write", (p) => {
|
||||
if (isModelRelevant(p.payload.model)) debouncedSync();
|
||||
if (isModelRelevant(p.payload.model)) scheduleModelSync();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { SyncModel } from "@yaakapp-internal/git";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Convert a SyncModel to a clean YAML string for diffing.
|
||||
* Removes noisy fields like updatedAt that change on every edit.
|
||||
* Convert a SyncModel to a YAML string for diffing.
|
||||
*/
|
||||
export function modelToYaml(model: SyncModel | null): string {
|
||||
if (!model) return "";
|
||||
|
||||
22
apps/yaak-client/lib/gitWorktreeStatus.ts
Normal file
22
apps/yaak-client/lib/gitWorktreeStatus.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GitWorktreeStatus, GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
export const gitWorktreeStatusAtom = atom<GitWorktreeStatus | null>(null);
|
||||
|
||||
export const gitWorktreeStatusByModelIdAtom = atom<Record<string, GitWorktreeStatusEntry>>({});
|
||||
|
||||
export const gitWorktreeStatusFamily = atomFamily(
|
||||
(modelId: string) =>
|
||||
selectAtom(
|
||||
gitWorktreeStatusByModelIdAtom,
|
||||
(statusByModelId) => statusByModelId[modelId] ?? null,
|
||||
(a, b) =>
|
||||
a?.relaPath === b?.relaPath &&
|
||||
a?.status === b?.status &&
|
||||
a?.staged === b?.staged &&
|
||||
a?.modelId === b?.modelId,
|
||||
),
|
||||
Object.is,
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { changeModelStoreWorkspace, initModelStore } from "@yaakapp-internal/mod
|
||||
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { initGit } from "./init/git";
|
||||
import { initSync } from "./init/sync";
|
||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||
import { jotaiStore } from "./lib/jotai";
|
||||
@@ -31,6 +32,7 @@ window.addEventListener("keydown", (e) => {
|
||||
});
|
||||
|
||||
// Initialize a bunch of watchers
|
||||
initGit();
|
||||
initSync();
|
||||
initModelStore(jotaiStore);
|
||||
initGlobalListeners();
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"hexy": "^0.3.5",
|
||||
"history": "^5.3.0",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
|
||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { useCallback } from "react";
|
||||
import { httpExchangesAtom } from "../lib/store";
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^12.4.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
|
||||
Reference in New Issue
Block a user