mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-17 05:07:08 +02:00
Add live git status indicators (#458)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -10310,6 +10310,7 @@ dependencies = [
|
|||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
"md5 0.8.0",
|
"md5 0.8.0",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"notify",
|
||||||
"openssl-sys",
|
"openssl-sys",
|
||||||
"pretty_graphql",
|
"pretty_graphql",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
|||||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai/utils";
|
import { atomFamily } from "jotai-family";
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Extension } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { Compartment } from "@codemirror/state";
|
import { Compartment } from "@codemirror/state";
|
||||||
import { debounce } from "@yaakapp-internal/lib";
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
|
import { gitMutations } from "@yaakapp-internal/git";
|
||||||
|
import type { GitStatus } from "@yaakapp-internal/git";
|
||||||
import type {
|
import type {
|
||||||
AnyModel,
|
AnyModel,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -23,13 +25,18 @@ import {
|
|||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtomValue } from "jotai";
|
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 { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { moveToWorkspace } from "../commands/moveToWorkspace";
|
import { moveToWorkspace } from "../commands/moveToWorkspace";
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
|
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import {
|
||||||
|
activeWorkspaceAtom,
|
||||||
|
activeWorkspaceIdAtom,
|
||||||
|
activeWorkspaceMetaAtom,
|
||||||
|
} from "../hooks/useActiveWorkspace";
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||||
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
||||||
import { getFolderActions } from "../hooks/useFolderActions";
|
import { getFolderActions } from "../hooks/useFolderActions";
|
||||||
@@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||||
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
|
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
|
import { showConfirm } from "../lib/confirm";
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import {
|
||||||
|
gitWorktreeStatusByModelIdAtom,
|
||||||
|
gitWorktreeStatusFamily,
|
||||||
|
} from "../lib/gitWorktreeStatus";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { isSidebarFocused } from "../lib/scopes";
|
import { isSidebarFocused } from "../lib/scopes";
|
||||||
@@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input";
|
|||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||||
import { GitDropdown } from "./git/GitDropdown";
|
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 collapsedFamily = atomFamily((treeId: string) => {
|
||||||
const key = ["sidebar_collapsed", treeId ?? "n/a"];
|
const key = ["sidebar_collapsed", treeId ?? "n/a"];
|
||||||
@@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = jotaiStore.get(workspacesAtom);
|
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 onlyHttpRequests = items.every((i) => i.model === "http_request");
|
||||||
const requestItems = items.filter(
|
const requestItems = items.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
...initialItems,
|
...initialItems,
|
||||||
{
|
{
|
||||||
type: "separator",
|
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",
|
label: "Rename",
|
||||||
leftSlot: <Icon icon="pencil" />,
|
leftSlot: <Icon icon="pencil" />,
|
||||||
@@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export default Sidebar;
|
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) => {
|
const activeIdAtom = atom<string | null>((get) => {
|
||||||
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||||
});
|
});
|
||||||
@@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
return [root, fields] as const;
|
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) {
|
function getItemKey(item: SidebarModel) {
|
||||||
const responses = jotaiStore.get(httpResponsesAtom);
|
const responses = jotaiStore.get(httpResponsesAtom);
|
||||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||||
@@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
|||||||
treeId: string;
|
treeId: string;
|
||||||
item: SidebarModel;
|
item: SidebarModel;
|
||||||
}) {
|
}) {
|
||||||
|
const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
|
||||||
const response = useAtomValue(
|
const response = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
<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 && (
|
{response != null && (
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{response.state !== "closed" ? (
|
{response.state !== "closed" ? (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
|
|
||||||
export function HttpResponseDurationTag({ response }: Props) {
|
export function HttpResponseDurationTag({ response }: Props) {
|
||||||
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
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
|
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
|
|||||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
const handleOpenImmediate = () => {
|
const handleOpenImmediate = () => {
|
||||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
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,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} 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 classNames from "classnames";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
import { modelToYaml } from "../../lib/diffYaml";
|
||||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||||
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
|
import { sync } from "../../init/sync";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -21,7 +23,7 @@ import { DiffViewer } from "../core/Editor/DiffViewer";
|
|||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { gitCallbacks } from "./callbacks";
|
import { useGitCallbacks } from "./callbacks";
|
||||||
import { handlePushResult } from "./git-util";
|
import { handlePushResult } from "./git-util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -38,9 +40,10 @@ interface CommitTreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
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,
|
syncDir,
|
||||||
gitCallbacks(syncDir),
|
callbacks,
|
||||||
);
|
);
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
const [commitError, setCommitError] = useState<string | null>(null);
|
const [commitError, setCommitError] = useState<string | null>(null);
|
||||||
@@ -165,6 +168,24 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
[selectedEntry],
|
[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) {
|
if (tree == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -259,7 +280,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
secondSlot={({ style }) => (
|
secondSlot={({ style }) => (
|
||||||
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
||||||
{selectedEntry ? (
|
{selectedEntry ? (
|
||||||
<DiffPanel entry={selectedEntry} />
|
<DiffPanel entry={selectedEntry} onDiscardChanges={handleDiscardChanges} />
|
||||||
) : (
|
) : (
|
||||||
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
||||||
)}
|
)}
|
||||||
@@ -466,16 +487,35 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
|
|||||||
return node.children.some((c) => isNodeRelevant(c));
|
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 prevYaml = modelToYaml(entry.prev);
|
||||||
const nextYaml = modelToYaml(entry.next);
|
const nextYaml = modelToYaml(entry.next);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="text-sm text-text-subtle mb-2 px-1">
|
<div className="text-text-subtle mb-2 px-1 grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
||||||
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
<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>
|
</div>
|
||||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
<DiffViewer
|
||||||
|
original={prevYaml ?? ""}
|
||||||
|
modified={nextYaml ?? ""}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
/>
|
||||||
</div>
|
</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 type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef, useCallback, useMemo } from "react";
|
||||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
@@ -12,17 +12,20 @@ import { sync } from "../../init/sync";
|
|||||||
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
import { showDialog } from "../../lib/dialog";
|
import { showDialog } from "../../lib/dialog";
|
||||||
|
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
|
||||||
import { showPrompt } from "../../lib/prompt";
|
import { showPrompt } from "../../lib/prompt";
|
||||||
import { showErrorToast, showToast } from "../../lib/toast";
|
import { showErrorToast, showToast } from "../../lib/toast";
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
import type { DropdownItem } from "../core/Dropdown";
|
||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { gitCallbacks } from "./callbacks";
|
import { useGitCallbacks } from "./callbacks";
|
||||||
import { GitCommitDialog } from "./GitCommitDialog";
|
import { GitCommitDialog } from "./GitCommitDialog";
|
||||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
import { GitRemotesDialog } from "./GitRemotesDialog";
|
||||||
import { handlePullResult, handlePushResult } from "./git-util";
|
import { handlePullResult, handlePushResult } from "./git-util";
|
||||||
import { HistoryDialog } from "./HistoryDialog";
|
import { HistoryDialog } from "./HistoryDialog";
|
||||||
|
|
||||||
|
const EMPTY_BRANCHES: string[] = [];
|
||||||
|
|
||||||
export function GitDropdown() {
|
export function GitDropdown() {
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||||
if (workspaceMeta == null) return null;
|
if (workspaceMeta == null) return null;
|
||||||
@@ -36,469 +39,493 @@ export function GitDropdown() {
|
|||||||
|
|
||||||
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
|
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
|
||||||
const [refreshKey, regenerateKey] = useRandomKey();
|
const [refreshKey, regenerateKey] = useRandomKey();
|
||||||
const [
|
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
|
||||||
{ status, log },
|
const callbacks = useGitCallbacks(syncDir);
|
||||||
{
|
const {
|
||||||
createBranch,
|
createBranch,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
deleteRemoteBranch,
|
deleteRemoteBranch,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
push,
|
push,
|
||||||
pull,
|
pull,
|
||||||
checkout,
|
checkout,
|
||||||
resetChanges,
|
resetChanges,
|
||||||
init,
|
init,
|
||||||
},
|
} = useGitMutations(syncDir, callbacks);
|
||||||
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
|
|
||||||
|
|
||||||
const localBranches = status.data?.localBranches ?? [];
|
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
|
||||||
const remoteBranches = status.data?.remoteBranches ?? [];
|
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
|
||||||
const remoteOnlyBranches = remoteBranches.filter(
|
const remoteOnlyBranches = useMemo(
|
||||||
(b) => !localBranches.includes(b.replace(/^origin\//, "")),
|
() => 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) {
|
if (workspace == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noRepo = status.error?.includes("not found");
|
const noRepo = branchInfo.error?.includes("not found");
|
||||||
if (noRepo) {
|
if (noRepo) {
|
||||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still loading
|
// Still loading
|
||||||
if (status.data == null) {
|
if (branchInfo.data == null) {
|
||||||
return 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 (
|
return (
|
||||||
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
||||||
<GitMenuButton>
|
<GitMenuButton>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import { gitCallbacks } from "./callbacks";
|
import { useGitCallbacks } from "./callbacks";
|
||||||
import { addGitRemote } from "./showAddRemoteDialog";
|
import { addGitRemote } from "./showAddRemoteDialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,7 +19,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GitRemotesDialog({ dir }: Props) {
|
export function GitRemotesDialog({ dir }: Props) {
|
||||||
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
|
const callbacks = useGitCallbacks(dir);
|
||||||
|
const [{ remotes }, { rmRemote }] = useGit(dir, callbacks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table scrollable>
|
<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 { formatDistanceToNowStrict } from "date-fns";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
TruncatedWideTableCell,
|
TruncatedWideTableCell,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
interface Props {
|
export function HistoryDialog({ dir }: { dir: string }) {
|
||||||
log: GitCommit[];
|
const log = useGitLog(dir);
|
||||||
}
|
|
||||||
|
|
||||||
export function HistoryDialog({ log }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="pl-5 pr-1 pb-1">
|
<div className="pl-5 pr-1 pb-1">
|
||||||
<Table scrollable className="px-1">
|
<Table scrollable className="px-1">
|
||||||
@@ -26,8 +24,8 @@ export function HistoryDialog({ log }: Props) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{log.map((l) => (
|
{(log.data ?? []).map((l) => (
|
||||||
<TableRow key={(l.author.name ?? "") + (l.message ?? "n/a") + l.when}>
|
<TableRow key={l.oid}>
|
||||||
<TruncatedWideTableCell>
|
<TruncatedWideTableCell>
|
||||||
{l.message || <em className="text-text-subtle">No message</em>}
|
{l.message || <em className="text-text-subtle">No message</em>}
|
||||||
</TruncatedWideTableCell>
|
</TruncatedWideTableCell>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GitCallbacks } from "@yaakapp-internal/git";
|
import type { GitCallbacks } from "@yaakapp-internal/git";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { sync } from "../../init/sync";
|
import { sync } from "../../init/sync";
|
||||||
import { promptCredentials } from "./credentials";
|
import { promptCredentials } from "./credentials";
|
||||||
import { promptDivergedStrategy } from "./diverged";
|
import { promptDivergedStrategy } from "./diverged";
|
||||||
@@ -24,3 +25,7 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
|||||||
forceSync: () => sync({ force: true }),
|
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();
|
await sync();
|
||||||
}, 1000);
|
}, 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
|
* 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.
|
* simply add long-lived subscribers for the lifetime of the app.
|
||||||
*/
|
*/
|
||||||
function initModelListeners() {
|
function initModelListeners() {
|
||||||
listenToTauriEvent<ModelPayload>("model_write", (p) => {
|
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";
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a SyncModel to a clean YAML string for diffing.
|
* Convert a SyncModel to a YAML string for diffing.
|
||||||
* Removes noisy fields like updatedAt that change on every edit.
|
|
||||||
*/
|
*/
|
||||||
export function modelToYaml(model: SyncModel | null): string {
|
export function modelToYaml(model: SyncModel | null): string {
|
||||||
if (!model) return "";
|
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 { setPlatformOnDocument } from "@yaakapp-internal/theme";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { initGit } from "./init/git";
|
||||||
import { initSync } from "./init/sync";
|
import { initSync } from "./init/sync";
|
||||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||||
import { jotaiStore } from "./lib/jotai";
|
import { jotaiStore } from "./lib/jotai";
|
||||||
@@ -31,6 +32,7 @@ window.addEventListener("keydown", (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize a bunch of watchers
|
// Initialize a bunch of watchers
|
||||||
|
initGit();
|
||||||
initSync();
|
initSync();
|
||||||
initModelStore(jotaiStore);
|
initModelStore(jotaiStore);
|
||||||
initGlobalListeners();
|
initGlobalListeners();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"hexy": "^0.3.5",
|
"hexy": "^0.3.5",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
|
"jotai-family": "^1.0.1",
|
||||||
"js-md5": "^0.8.3",
|
"js-md5": "^0.8.3",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
|
|||||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||||
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai/utils";
|
import { atomFamily } from "jotai-family";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { httpExchangesAtom } from "../lib/store";
|
import { httpExchangesAtom } from "../lib/store";
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@yaakapp-internal/ui": "^1.0.0",
|
"@yaakapp-internal/ui": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
|
"jotai-family": "^1.0.1",
|
||||||
"motion": "^12.4.7",
|
"motion": "^12.4.7",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
|
|||||||
http = { version = "1.2.0", default-features = false }
|
http = { version = "1.2.0", default-features = false }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
md5 = "0.8.0"
|
md5 = "0.8.0"
|
||||||
|
notify = "8.0.0"
|
||||||
pretty_graphql = "0.2"
|
pretty_graphql = "0.2"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.25.0"
|
r2d2_sqlite = "0.25.0"
|
||||||
|
|||||||
2
crates-tauri/yaak-app-client/bindings/index.ts
generated
2
crates-tauri/yaak-app-client/bindings/index.ts
generated
@@ -12,6 +12,8 @@ export type UpdateResponseAction = "install" | "skip";
|
|||||||
|
|
||||||
export type WatchResult = { unlistenEvent: string, };
|
export type WatchResult = { unlistenEvent: string, };
|
||||||
|
|
||||||
|
export type GitWatchResult = { unlistenEvent: string, };
|
||||||
|
|
||||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||||
|
|
||||||
export type YaakNotificationAction = { label: string, url: string, };
|
export type YaakNotificationAction = { label: string, url: string, };
|
||||||
|
|||||||
@@ -3,14 +3,18 @@
|
|||||||
//! This module provides the Tauri commands for git functionality.
|
//! This module provides the Tauri commands for git functionality.
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::git_watcher::{GitWatchResult, watch_git_worktree_status};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::command;
|
use tauri::ipc::Channel;
|
||||||
|
use tauri::{AppHandle, Runtime, command};
|
||||||
use yaak_git::{
|
use yaak_git::{
|
||||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
||||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, git_add, git_add_credential,
|
||||||
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
git_add_remote, git_branch_info, git_checkout_branch, git_clone, git_commit, git_create_branch,
|
||||||
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
git_delete_branch, git_delete_remote_branch, git_fetch_all, git_file_diff_for_commit, git_init,
|
||||||
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
|
git_log, git_log_for_file, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge,
|
||||||
|
git_push, git_remotes, git_rename_branch, git_reset_changes, git_restore,
|
||||||
|
git_restore_file_from_commit, git_rm_remote, git_status, git_unstage, git_worktree_status,
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||||
@@ -54,11 +58,44 @@ pub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {
|
|||||||
Ok(git_status(dir)?)
|
Ok(git_status(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_branch_info(dir: &Path) -> Result<GitBranchInfo> {
|
||||||
|
Ok(git_branch_info(dir)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_worktree_status(dir: &Path) -> Result<GitWorktreeStatus> {
|
||||||
|
Ok(git_worktree_status(dir)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_watch_worktree_status<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
dir: &Path,
|
||||||
|
channel: Channel<GitWorktreeStatus>,
|
||||||
|
) -> Result<GitWatchResult> {
|
||||||
|
watch_git_worktree_status(app_handle, dir, channel).await
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
||||||
Ok(git_log(dir)?)
|
Ok(git_log(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_log_for_file(dir: &Path, rela_path: PathBuf) -> Result<Vec<GitCommit>> {
|
||||||
|
Ok(git_log_for_file(dir, &rela_path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_file_diff_for_commit(
|
||||||
|
dir: &Path,
|
||||||
|
commit_oid: &str,
|
||||||
|
rela_path: PathBuf,
|
||||||
|
) -> Result<GitFileDiff> {
|
||||||
|
Ok(git_file_diff_for_commit(dir, commit_oid, &rela_path)?)
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
||||||
Ok(git_init(dir)?)
|
Ok(git_init(dir)?)
|
||||||
@@ -124,6 +161,23 @@ pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
|||||||
Ok(git_reset_changes(dir).await?)
|
Ok(git_reset_changes(dir).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_restore_files(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||||
|
for path in rela_paths {
|
||||||
|
git_restore(dir, &path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_restore_file_from_commit(
|
||||||
|
dir: &Path,
|
||||||
|
commit_oid: &str,
|
||||||
|
rela_path: PathBuf,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(git_restore_file_from_commit(dir, commit_oid, &rela_path)?)
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_add_credential(
|
pub async fn cmd_git_add_credential(
|
||||||
remote_url: &str,
|
remote_url: &str,
|
||||||
|
|||||||
172
crates-tauri/yaak-app-client/src/git_watcher.rs
Normal file
172
crates-tauri/yaak-app-client/src/git_watcher.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::error::{Error, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use log::{debug, error, warn};
|
||||||
|
use notify::Watcher;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
use tauri::{AppHandle, Listener, Runtime};
|
||||||
|
use tokio::select;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use ts_rs::TS;
|
||||||
|
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
||||||
|
|
||||||
|
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "index.ts")]
|
||||||
|
pub(crate) struct GitWatchResult {
|
||||||
|
unlisten_event: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn watch_git_worktree_status<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
dir: &Path,
|
||||||
|
channel: Channel<GitWorktreeStatus>,
|
||||||
|
) -> Result<GitWatchResult> {
|
||||||
|
let paths = git_repository_paths(dir)?;
|
||||||
|
let repo_dir = dir.to_path_buf();
|
||||||
|
let workdir = paths.workdir;
|
||||||
|
let gitdir = paths.gitdir;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
|
||||||
|
let mut watcher = notify::recommended_watcher(tx)
|
||||||
|
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.watch(&workdir, notify::RecursiveMode::Recursive)
|
||||||
|
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
|
||||||
|
if gitdir != workdir {
|
||||||
|
watcher
|
||||||
|
.watch(&gitdir, notify::RecursiveMode::Recursive)
|
||||||
|
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for res in rx {
|
||||||
|
if async_tx.blocking_send(res).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (cancel_tx, cancel_rx) = watch::channel(());
|
||||||
|
let mut cancel_rx = cancel_rx;
|
||||||
|
send_worktree_status(&repo_dir, &channel);
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let _watcher = watcher;
|
||||||
|
loop {
|
||||||
|
select! {
|
||||||
|
Some(event_res) = async_rx.recv() => {
|
||||||
|
handle_git_watch_event(
|
||||||
|
event_res,
|
||||||
|
&mut async_rx,
|
||||||
|
&repo_dir,
|
||||||
|
&workdir,
|
||||||
|
&gitdir,
|
||||||
|
&channel,
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
_ = cancel_rx.changed() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_handle_inner = app_handle.clone();
|
||||||
|
let unlisten_event = format!("git-watch-unlisten-{}", Utc::now().timestamp_millis());
|
||||||
|
app_handle.listen_any(unlisten_event.clone(), move |event| {
|
||||||
|
app_handle_inner.unlisten(event.id());
|
||||||
|
if let Err(e) = cancel_tx.send(()) {
|
||||||
|
warn!("Failed to send git watch cancel signal {e:?}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(GitWatchResult { unlisten_event })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_git_watch_event(
|
||||||
|
event_res: notify::Result<notify::Event>,
|
||||||
|
async_rx: &mut tokio::sync::mpsc::Receiver<notify::Result<notify::Event>>,
|
||||||
|
repo_dir: &Path,
|
||||||
|
workdir: &Path,
|
||||||
|
gitdir: &Path,
|
||||||
|
channel: &Channel<GitWorktreeStatus>,
|
||||||
|
) {
|
||||||
|
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send_worktree_status(repo_dir, channel);
|
||||||
|
|
||||||
|
let settle_window = sleep(GIT_STATUS_COALESCE_WINDOW);
|
||||||
|
tokio::pin!(settle_window);
|
||||||
|
loop {
|
||||||
|
select! {
|
||||||
|
Some(event_res) = async_rx.recv() => {
|
||||||
|
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
|
||||||
|
}
|
||||||
|
_ = &mut settle_window => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send_worktree_status(repo_dir, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_relevant_git_watch_event(
|
||||||
|
event_res: notify::Result<notify::Event>,
|
||||||
|
repo_dir: &Path,
|
||||||
|
workdir: &Path,
|
||||||
|
gitdir: &Path,
|
||||||
|
) -> bool {
|
||||||
|
let event = match event_res {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Git watch error: {:?}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for path in event.paths {
|
||||||
|
if path.strip_prefix(gitdir).is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(rela_path) = path.strip_prefix(workdir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match git_path_is_ignored(repo_dir, rela_path) {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => return true,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to check Git ignore status for {:?}: {e}", rela_path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_worktree_status(repo_dir: &Path, channel: &Channel<GitWorktreeStatus>) {
|
||||||
|
match git_worktree_status(repo_dir) {
|
||||||
|
Ok(status) => {
|
||||||
|
if let Err(e) = channel.send(status) {
|
||||||
|
warn!("Failed to send git worktree status: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to get git worktree status: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ mod commands;
|
|||||||
mod encoding;
|
mod encoding;
|
||||||
mod error;
|
mod error;
|
||||||
mod git_ext;
|
mod git_ext;
|
||||||
|
mod git_watcher;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
mod history;
|
mod history;
|
||||||
mod http_request;
|
mod http_request;
|
||||||
@@ -1831,8 +1832,13 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_delete_remote_branch,
|
git_ext::cmd_git_delete_remote_branch,
|
||||||
git_ext::cmd_git_merge_branch,
|
git_ext::cmd_git_merge_branch,
|
||||||
git_ext::cmd_git_rename_branch,
|
git_ext::cmd_git_rename_branch,
|
||||||
|
git_ext::cmd_git_branch_info,
|
||||||
git_ext::cmd_git_status,
|
git_ext::cmd_git_status,
|
||||||
|
git_ext::cmd_git_worktree_status,
|
||||||
|
git_ext::cmd_git_watch_worktree_status,
|
||||||
git_ext::cmd_git_log,
|
git_ext::cmd_git_log,
|
||||||
|
git_ext::cmd_git_log_for_file,
|
||||||
|
git_ext::cmd_git_file_diff_for_commit,
|
||||||
git_ext::cmd_git_initialize,
|
git_ext::cmd_git_initialize,
|
||||||
git_ext::cmd_git_clone,
|
git_ext::cmd_git_clone,
|
||||||
git_ext::cmd_git_commit,
|
git_ext::cmd_git_commit,
|
||||||
@@ -1844,6 +1850,8 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_add,
|
git_ext::cmd_git_add,
|
||||||
git_ext::cmd_git_unstage,
|
git_ext::cmd_git_unstage,
|
||||||
git_ext::cmd_git_reset_changes,
|
git_ext::cmd_git_reset_changes,
|
||||||
|
git_ext::cmd_git_restore_files,
|
||||||
|
git_ext::cmd_git_restore_file_from_commit,
|
||||||
git_ext::cmd_git_add_credential,
|
git_ext::cmd_git_add_credential,
|
||||||
git_ext::cmd_git_remotes,
|
git_ext::cmd_git_remotes,
|
||||||
git_ext::cmd_git_add_remote,
|
git_ext::cmd_git_add_remote,
|
||||||
|
|||||||
10
crates/yaak-git/bindings/gen_git.ts
generated
10
crates/yaak-git/bindings/gen_git.ts
generated
@@ -7,7 +7,11 @@ export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "t
|
|||||||
|
|
||||||
export type GitAuthor = { name: string | null, email: string | null, };
|
export type GitAuthor = { name: string | null, email: string | null, };
|
||||||
|
|
||||||
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
export type GitBranchInfo = { path: string, headRef: string | null, headRefShorthand: string | null, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||||
|
|
||||||
|
export type GitCommit = { oid: string, author: GitAuthor, when: string, message: string | null, };
|
||||||
|
|
||||||
|
export type GitFileDiff = { original: string, modified: string, };
|
||||||
|
|
||||||
export type GitRemote = { name: string, url: string | null, };
|
export type GitRemote = { name: string, url: string | null, };
|
||||||
|
|
||||||
@@ -17,6 +21,10 @@ export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: bool
|
|||||||
|
|
||||||
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||||
|
|
||||||
|
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
|
||||||
|
|
||||||
|
export type GitWorktreeStatusEntry = { relaPath: string, modelId: string | null, status: GitStatus, staged: boolean, };
|
||||||
|
|
||||||
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
||||||
|
|
||||||
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
|
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
|
||||||
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
|
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
BranchDeleteResult,
|
BranchDeleteResult,
|
||||||
CloneResult,
|
CloneResult,
|
||||||
|
GitBranchInfo,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
|
GitFileDiff,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
GitStatusSummary,
|
GitStatusSummary,
|
||||||
|
GitWorktreeStatus,
|
||||||
PullResult,
|
PullResult,
|
||||||
PushResult,
|
PushResult,
|
||||||
} from "./bindings/gen_git";
|
} from "./bindings/gen_git";
|
||||||
@@ -26,6 +30,10 @@ export type DivergedStrategy = "force_reset" | "merge" | "cancel";
|
|||||||
|
|
||||||
export type UncommittedChangesStrategy = "reset" | "cancel";
|
export type UncommittedChangesStrategy = "reset" | "cancel";
|
||||||
|
|
||||||
|
interface GitWatchResult {
|
||||||
|
unlistenEvent: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitCallbacks {
|
export interface GitCallbacks {
|
||||||
addRemote: () => Promise<GitRemote | null>;
|
addRemote: () => Promise<GitRemote | null>;
|
||||||
promptCredentials: (
|
promptCredentials: (
|
||||||
@@ -38,13 +46,98 @@ export interface GitCallbacks {
|
|||||||
|
|
||||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
|
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
|
||||||
|
|
||||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) {
|
||||||
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
return refreshKey == null
|
||||||
const fetchAll = useQuery<void, string>({
|
? (["git", "worktree_status", dir] as const)
|
||||||
|
: (["git", "worktree_status", dir, refreshKey] as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateGitWorktreeStatus(dir?: string) {
|
||||||
|
return queryClient.invalidateQueries({ queryKey: gitWorktreeStatusQueryKey(dir) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitWorktreeStatus(dir: string, refreshKey?: string) {
|
||||||
|
return useQuery<GitWorktreeStatus, string>({
|
||||||
|
queryKey: gitWorktreeStatusQueryKey(dir, refreshKey),
|
||||||
|
queryFn: () => invoke("cmd_git_worktree_status", { dir }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktreeStatus) => void) {
|
||||||
|
const channel = new Channel<GitWorktreeStatus>();
|
||||||
|
channel.onmessage = callback;
|
||||||
|
const unlistenPromise = invoke<GitWatchResult>("cmd_git_watch_worktree_status", {
|
||||||
|
dir,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
void unlistenPromise
|
||||||
|
.then(({ unlistenEvent }) => {
|
||||||
|
addGitWatchKey(unlistenEvent);
|
||||||
|
})
|
||||||
|
.catch(console.debug);
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
unlistenPromise
|
||||||
|
.then(async ({ unlistenEvent }) => {
|
||||||
|
unlistenGitWatcher(unlistenEvent);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGitFetchAll(dir: string, refreshKey?: string) {
|
||||||
|
return useQuery<void, string>({
|
||||||
queryKey: ["git", "fetch_all", dir, refreshKey],
|
queryKey: ["git", "fetch_all", dir, refreshKey],
|
||||||
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
|
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
|
||||||
refetchInterval: 10 * 60_000,
|
refetchInterval: 10 * 60_000,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) {
|
||||||
|
return useQuery<GitBranchInfo, string>({
|
||||||
|
refetchOnMount: true,
|
||||||
|
queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt],
|
||||||
|
queryFn: () => invoke("cmd_git_branch_info", { dir }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitBranchInfo(dir: string, refreshKey?: string) {
|
||||||
|
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||||
|
return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) {
|
||||||
|
return useQuery<GitCommit[], string>({
|
||||||
|
queryKey: ["git", "log", dir, refreshKey, relaPath],
|
||||||
|
queryFn: () =>
|
||||||
|
relaPath == null
|
||||||
|
? invoke("cmd_git_log", { dir })
|
||||||
|
: invoke("cmd_git_log_for_file", { dir, relaPath }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitFileDiffForCommit(
|
||||||
|
dir: string,
|
||||||
|
relaPath: string,
|
||||||
|
commitOid: string | null | undefined,
|
||||||
|
) {
|
||||||
|
return useQuery<GitFileDiff, string>({
|
||||||
|
enabled: commitOid != null,
|
||||||
|
queryKey: ["git", "file_diff_for_commit", dir, relaPath, commitOid],
|
||||||
|
queryFn: () => {
|
||||||
|
if (commitOid == null) throw new Error("Missing commit oid");
|
||||||
|
return invoke("cmd_git_file_diff_for_commit", { dir, relaPath, commitOid });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||||
|
const mutations = useGitMutations(dir, callbacks);
|
||||||
|
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
remotes: useQuery<GitRemote[], string>({
|
remotes: useQuery<GitRemote[], string>({
|
||||||
@@ -52,11 +145,7 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
|
|||||||
queryFn: () => getRemotes(dir),
|
queryFn: () => getRemotes(dir),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
}),
|
}),
|
||||||
log: useQuery<GitCommit[], string>({
|
log: useGitLog(dir, refreshKey),
|
||||||
queryKey: ["git", "log", dir, refreshKey],
|
|
||||||
queryFn: () => invoke("cmd_git_log", { dir }),
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
}),
|
|
||||||
status: useQuery<GitStatusSummary, string>({
|
status: useQuery<GitStatusSummary, string>({
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
||||||
@@ -68,6 +157,10 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
|
|||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGitMutations(dir: string, callbacks: GitCallbacks) {
|
||||||
|
return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||||
|
}
|
||||||
|
|
||||||
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||||
const push = async () => {
|
const push = async () => {
|
||||||
const remotes = await getRemotes(dir);
|
const remotes = await getRemotes(dir);
|
||||||
@@ -250,6 +343,20 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
|
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
|
restore: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||||
|
mutationKey: ["git", "restore", dir],
|
||||||
|
mutationFn: (args) => invoke("cmd_git_restore_files", { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
restoreFileFromCommit: createFastMutation<
|
||||||
|
void,
|
||||||
|
string,
|
||||||
|
{ commitOid: string; relaPath: string }
|
||||||
|
>({
|
||||||
|
mutationKey: ["git", "restore-file-from-commit", dir],
|
||||||
|
mutationFn: (args) => invoke("cmd_git_restore_file_from_commit", { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,6 +364,35 @@ async function getRemotes(dir: string) {
|
|||||||
return invoke<GitRemote[]>("cmd_git_remotes", { dir });
|
return invoke<GitRemote[]>("cmd_git_remotes", { dir });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unlistenGitWatcher(unlistenEvent: string) {
|
||||||
|
void emit(unlistenEvent).then(() => {
|
||||||
|
removeGitWatchKey(unlistenEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGitWatchKeys() {
|
||||||
|
return sessionStorage.getItem("git-worktree-watchers")?.split(",").filter(Boolean) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGitWatchKeys(keys: string[]) {
|
||||||
|
sessionStorage.setItem("git-worktree-watchers", keys.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGitWatchKey(key: string) {
|
||||||
|
const keys = getGitWatchKeys();
|
||||||
|
setGitWatchKeys([...keys, key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGitWatchKey(key: string) {
|
||||||
|
const keys = getGitWatchKeys();
|
||||||
|
setGitWatchKeys(keys.filter((k) => k !== key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitWatchKeys = getGitWatchKeys();
|
||||||
|
if (gitWatchKeys.length > 0) {
|
||||||
|
gitWatchKeys.forEach(unlistenGitWatcher);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone a git repository, prompting for credentials if needed.
|
* Clone a git repository, prompting for credentials if needed.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mod push;
|
|||||||
mod remotes;
|
mod remotes;
|
||||||
mod repository;
|
mod repository;
|
||||||
mod reset;
|
mod reset;
|
||||||
|
mod restore;
|
||||||
mod status;
|
mod status;
|
||||||
mod unstage;
|
mod unstage;
|
||||||
mod util;
|
mod util;
|
||||||
@@ -29,10 +30,15 @@ pub use commit::git_commit;
|
|||||||
pub use credential::git_add_credential;
|
pub use credential::git_add_credential;
|
||||||
pub use fetch::git_fetch_all;
|
pub use fetch::git_fetch_all;
|
||||||
pub use init::git_init;
|
pub use init::git_init;
|
||||||
pub use log::{GitCommit, git_log};
|
pub use log::{GitCommit, GitFileDiff, git_file_diff_for_commit, git_log, git_log_for_file};
|
||||||
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
||||||
pub use push::{PushResult, git_push};
|
pub use push::{PushResult, git_push};
|
||||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||||
|
pub use repository::{GitRepositoryPaths, git_path_is_ignored, git_repository_paths};
|
||||||
pub use reset::git_reset_changes;
|
pub use reset::git_reset_changes;
|
||||||
pub use status::{GitStatusSummary, git_status};
|
pub use restore::{git_restore, git_restore_file_from_commit};
|
||||||
|
pub use status::{
|
||||||
|
GitBranchInfo, GitStatusSummary, GitWorktreeStatus, git_branch_info, git_status,
|
||||||
|
git_worktree_status,
|
||||||
|
};
|
||||||
pub use unstage::git_unstage;
|
pub use unstage::git_unstage;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ts_rs::TS;
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
pub struct GitCommit {
|
pub struct GitCommit {
|
||||||
|
pub oid: String,
|
||||||
pub author: GitAuthor,
|
pub author: GitAuthor,
|
||||||
pub when: DateTime<Utc>,
|
pub when: DateTime<Utc>,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
@@ -21,7 +22,23 @@ pub struct GitAuthor {
|
|||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub struct GitFileDiff {
|
||||||
|
pub original: String,
|
||||||
|
pub modified: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||||
|
git_log_inner(dir, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_log_for_file(dir: &Path, rela_path: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||||
|
git_log_inner(dir, Some(rela_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result<Vec<GitCommit>> {
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
|
|
||||||
// Return empty if empty repo or no head (new repo)
|
// Return empty if empty repo or no head (new repo)
|
||||||
@@ -46,8 +63,16 @@ pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
|||||||
.filter_map(|oid| {
|
.filter_map(|oid| {
|
||||||
let oid = filter_try!(oid);
|
let oid = filter_try!(oid);
|
||||||
let commit = filter_try!(repo.find_commit(oid));
|
let commit = filter_try!(repo.find_commit(oid));
|
||||||
|
if let Some(rela_path) = rela_path {
|
||||||
|
let touches_path = filter_try!(commit_touches_path(&repo, &commit, rela_path));
|
||||||
|
if !touches_path {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let author = commit.author();
|
let author = commit.author();
|
||||||
Some(GitCommit {
|
Some(GitCommit {
|
||||||
|
oid: oid.to_string(),
|
||||||
author: GitAuthor {
|
author: GitAuthor {
|
||||||
name: author.name().map(|s| s.to_string()),
|
name: author.name().map(|s| s.to_string()),
|
||||||
email: author.email().map(|s| s.to_string()),
|
email: author.email().map(|s| s.to_string()),
|
||||||
@@ -61,6 +86,53 @@ pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
|||||||
Ok(log)
|
Ok(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn git_file_diff_for_commit(
|
||||||
|
dir: &Path,
|
||||||
|
commit_oid: &str,
|
||||||
|
rela_path: &Path,
|
||||||
|
) -> crate::error::Result<GitFileDiff> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
let oid = git2::Oid::from_str(commit_oid)?;
|
||||||
|
let commit = repo.find_commit(oid)?;
|
||||||
|
let new_tree = commit.tree()?;
|
||||||
|
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
||||||
|
|
||||||
|
Ok(GitFileDiff {
|
||||||
|
original: blob_text_at_path(&repo, old_tree.as_ref(), rela_path)?,
|
||||||
|
modified: blob_text_at_path(&repo, Some(&new_tree), rela_path)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_touches_path(
|
||||||
|
repo: &git2::Repository,
|
||||||
|
commit: &git2::Commit,
|
||||||
|
rela_path: &Path,
|
||||||
|
) -> crate::error::Result<bool> {
|
||||||
|
let new_tree = commit.tree()?;
|
||||||
|
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
||||||
|
|
||||||
|
let mut opts = git2::DiffOptions::new();
|
||||||
|
opts.pathspec(rela_path);
|
||||||
|
|
||||||
|
let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
|
||||||
|
Ok(diff.deltas().len() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob_text_at_path(
|
||||||
|
repo: &git2::Repository,
|
||||||
|
tree: Option<&git2::Tree>,
|
||||||
|
rela_path: &Path,
|
||||||
|
) -> crate::error::Result<String> {
|
||||||
|
let Some(tree) = tree else {
|
||||||
|
return Ok(String::new());
|
||||||
|
};
|
||||||
|
let Ok(entry) = tree.get_path(rela_path) else {
|
||||||
|
return Ok(String::new());
|
||||||
|
};
|
||||||
|
let blob = entry.to_object(repo)?.peel_to_blob()?;
|
||||||
|
Ok(String::from_utf8(blob.content().to_vec())?)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
||||||
DateTime::from_timestamp(0, 0).unwrap()
|
DateTime::from_timestamp(0, 0).unwrap()
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
use crate::error::Error::{GitRepoNotFound, GitUnknown};
|
use crate::error::Error::{GitRepoNotFound, GitUnknown};
|
||||||
use std::path::Path;
|
use crate::error::{Error, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitRepositoryPaths {
|
||||||
|
pub workdir: PathBuf,
|
||||||
|
pub gitdir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
||||||
match git2::Repository::discover(dir) {
|
match git2::Repository::discover(dir) {
|
||||||
@@ -8,3 +15,17 @@ pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
|||||||
Err(e) => Err(GitUnknown(e)),
|
Err(e) => Err(GitUnknown(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
let workdir = repo
|
||||||
|
.workdir()
|
||||||
|
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
|
||||||
|
.to_path_buf();
|
||||||
|
Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
Ok(repo.status_should_ignore(rela_path)?)
|
||||||
|
}
|
||||||
|
|||||||
76
crates/yaak-git/src/restore.rs
Normal file
76
crates/yaak-git/src/restore.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::repository::open_repo;
|
||||||
|
use log::info;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Component, Path};
|
||||||
|
|
||||||
|
pub fn git_restore(dir: &Path, rela_path: &Path) -> Result<()> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
validate_relative_path(rela_path)?;
|
||||||
|
|
||||||
|
let status = repo.status_file(rela_path).ok();
|
||||||
|
let is_untracked = status
|
||||||
|
.is_some_and(|s| s.contains(git2::Status::WT_NEW) || s.contains(git2::Status::INDEX_NEW));
|
||||||
|
|
||||||
|
info!("Restoring file {rela_path:?} in {dir:?}");
|
||||||
|
if is_untracked {
|
||||||
|
let mut index = repo.index()?;
|
||||||
|
let _ = index.remove_path(rela_path);
|
||||||
|
index.write()?;
|
||||||
|
|
||||||
|
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
||||||
|
if path.is_dir() {
|
||||||
|
fs::remove_dir_all(path)?;
|
||||||
|
} else if path.exists() {
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = repo.head()?;
|
||||||
|
let commit = head.peel_to_commit()?;
|
||||||
|
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
|
||||||
|
|
||||||
|
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||||
|
checkout.force().path(rela_path);
|
||||||
|
repo.checkout_head(Some(&mut checkout))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_restore_file_from_commit(dir: &Path, commit_oid: &str, rela_path: &Path) -> Result<()> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
validate_relative_path(rela_path)?;
|
||||||
|
|
||||||
|
let oid = git2::Oid::from_str(commit_oid)?;
|
||||||
|
let commit = repo.find_commit(oid)?;
|
||||||
|
let tree = commit.tree()?;
|
||||||
|
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
||||||
|
|
||||||
|
info!("Restoring file {rela_path:?} from commit {commit_oid} in {dir:?}");
|
||||||
|
if tree.get_path(rela_path).is_err() {
|
||||||
|
if path.is_dir() {
|
||||||
|
fs::remove_dir_all(path)?;
|
||||||
|
} else if path.exists() {
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||||
|
checkout.force().path(rela_path);
|
||||||
|
repo.checkout_tree(commit.as_object(), Some(&mut checkout))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_relative_path(path: &Path) -> Result<()> {
|
||||||
|
let is_safe = !path.as_os_str().is_empty()
|
||||||
|
&& !path.is_absolute()
|
||||||
|
&& path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||||
|
if is_safe {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(crate::error::Error::GenericError(format!("Invalid restore path {}", path.display())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,20 @@ pub struct GitStatusSummary {
|
|||||||
pub behind: u32,
|
pub behind: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub struct GitBranchInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub head_ref: Option<String>,
|
||||||
|
pub head_ref_shorthand: Option<String>,
|
||||||
|
pub origins: Vec<String>,
|
||||||
|
pub local_branches: Vec<String>,
|
||||||
|
pub remote_branches: Vec<String>,
|
||||||
|
pub ahead: u32,
|
||||||
|
pub behind: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
@@ -33,6 +47,23 @@ pub struct GitStatusEntry {
|
|||||||
pub next: Option<SyncModel>,
|
pub next: Option<SyncModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub struct GitWorktreeStatus {
|
||||||
|
pub entries: Vec<GitWorktreeStatusEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub struct GitWorktreeStatusEntry {
|
||||||
|
pub rela_path: String,
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
pub status: GitStatus,
|
||||||
|
pub staged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
@@ -46,31 +77,43 @@ pub enum GitStatus {
|
|||||||
TypeChange,
|
TypeChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
let mut opts = git2::StatusOptions::new();
|
||||||
|
opts.include_ignored(false)
|
||||||
|
.include_untracked(true)
|
||||||
|
.recurse_untracked_dirs(true)
|
||||||
|
.include_unmodified(false);
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||||
|
let Some(rela_path) = entry.path() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(GitWorktreeStatusEntry {
|
||||||
|
rela_path: rela_path.to_string(),
|
||||||
|
model_id: model_id_from_rela_path(Path::new(rela_path)),
|
||||||
|
status,
|
||||||
|
staged,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(GitWorktreeStatus { entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_branch_info(dir: &Path) -> crate::error::Result<GitBranchInfo> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
git_branch_info_for_repo(&repo, dir)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
|
let branch_info = git_branch_info_for_repo(&repo, dir)?;
|
||||||
Ok(head) => {
|
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
|
||||||
let tree = head.peel_to_tree().ok();
|
|
||||||
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
|
||||||
let head_ref = head.name().map(|s| s.to_string());
|
|
||||||
|
|
||||||
(tree, head_ref, head_ref_shorthand)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
|
||||||
// See https://github.com/starship/starship/pull/1336
|
|
||||||
let head_path = repo.path().join("HEAD");
|
|
||||||
let head_ref = fs::read_to_string(&head_path)
|
|
||||||
.ok()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.lines()
|
|
||||||
.next()
|
|
||||||
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
|
||||||
let head_ref_shorthand =
|
|
||||||
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
|
||||||
(None, head_ref, head_ref_shorthand)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut opts = git2::StatusOptions::new();
|
let mut opts = git2::StatusOptions::new();
|
||||||
opts.include_ignored(false)
|
opts.include_ignored(false)
|
||||||
@@ -83,51 +126,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
||||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||||
let rela_path = entry.path().unwrap().to_string();
|
let rela_path = entry.path().unwrap().to_string();
|
||||||
let status = entry.status();
|
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
||||||
let index_status = match status {
|
continue;
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
|
||||||
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
|
||||||
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
|
||||||
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
|
||||||
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
|
||||||
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
|
||||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
|
||||||
s => {
|
|
||||||
warn!("Unknown index status {s:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let worktree_status = match status {
|
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
|
||||||
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
|
||||||
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
|
||||||
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
|
||||||
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
|
||||||
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
|
||||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
|
||||||
s => {
|
|
||||||
warn!("Unknown worktree status {s:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = if index_status == GitStatus::Current {
|
|
||||||
worktree_status.clone()
|
|
||||||
} else {
|
|
||||||
index_status.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
|
|
||||||
{
|
|
||||||
// No change, so can't be added
|
|
||||||
false
|
|
||||||
} else if index_status != GitStatus::Current {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get previous content from Git, if it's in there
|
// Get previous content from Git, if it's in there
|
||||||
@@ -158,9 +158,27 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(GitStatusSummary {
|
||||||
|
entries,
|
||||||
|
path: branch_info.path,
|
||||||
|
head_ref: branch_info.head_ref,
|
||||||
|
head_ref_shorthand: branch_info.head_ref_shorthand,
|
||||||
|
origins: branch_info.origins,
|
||||||
|
local_branches: branch_info.local_branches,
|
||||||
|
remote_branches: branch_info.remote_branches,
|
||||||
|
ahead: branch_info.ahead,
|
||||||
|
behind: branch_info.behind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_branch_info_for_repo(
|
||||||
|
repo: &git2::Repository,
|
||||||
|
dir: &Path,
|
||||||
|
) -> crate::error::Result<GitBranchInfo> {
|
||||||
|
let (head_ref, head_ref_shorthand) = git_head_refs(repo);
|
||||||
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
||||||
let local_branches = local_branch_names(&repo)?;
|
let local_branches = local_branch_names(repo)?;
|
||||||
let remote_branches = remote_branch_names(&repo)?;
|
let remote_branches = remote_branch_names(repo)?;
|
||||||
|
|
||||||
// Compute ahead/behind relative to remote tracking branch
|
// Compute ahead/behind relative to remote tracking branch
|
||||||
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
||||||
@@ -174,15 +192,85 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
})()
|
})()
|
||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
Ok(GitStatusSummary {
|
Ok(GitBranchInfo {
|
||||||
entries,
|
|
||||||
origins,
|
|
||||||
path: dir.to_string_lossy().to_string(),
|
path: dir.to_string_lossy().to_string(),
|
||||||
head_ref,
|
head_ref,
|
||||||
head_ref_shorthand,
|
head_ref_shorthand,
|
||||||
|
origins,
|
||||||
local_branches,
|
local_branches,
|
||||||
remote_branches,
|
remote_branches,
|
||||||
ahead: ahead as u32,
|
ahead: ahead as u32,
|
||||||
behind: behind as u32,
|
behind: behind as u32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_head_refs(repo: &git2::Repository) -> (Option<String>, Option<String>) {
|
||||||
|
match repo.head() {
|
||||||
|
Ok(head) => {
|
||||||
|
let head_ref = head.name().map(|s| s.to_string());
|
||||||
|
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
||||||
|
(head_ref, head_ref_shorthand)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
||||||
|
// See https://github.com/starship/starship/pull/1336
|
||||||
|
let head_path = repo.path().join("HEAD");
|
||||||
|
let head_ref = fs::read_to_string(&head_path)
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
||||||
|
let head_ref_shorthand =
|
||||||
|
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
||||||
|
(head_ref, head_ref_shorthand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
|
||||||
|
let index_status = match status {
|
||||||
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
|
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
||||||
|
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
||||||
|
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
||||||
|
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
||||||
|
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
||||||
|
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||||
|
s => {
|
||||||
|
warn!("Unknown index status {s:?}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let worktree_status = match status {
|
||||||
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
|
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
||||||
|
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
||||||
|
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
||||||
|
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
||||||
|
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
||||||
|
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||||
|
s => {
|
||||||
|
warn!("Unknown worktree status {s:?}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status =
|
||||||
|
if index_status == GitStatus::Current { worktree_status } else { index_status.clone() };
|
||||||
|
let staged = index_status != GitStatus::Current;
|
||||||
|
|
||||||
|
Some((status, staged))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_id_from_rela_path(path: &Path) -> Option<String> {
|
||||||
|
let ext = path.extension()?.to_str()?;
|
||||||
|
if ext != "yaml" && ext != "yml" && ext != "json" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
|
||||||
|
}
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -140,6 +140,7 @@
|
|||||||
"hexy": "^0.3.5",
|
"hexy": "^0.3.5",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
|
"jotai-family": "^1.0.1",
|
||||||
"js-md5": "^0.8.3",
|
"js-md5": "^0.8.3",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
@@ -248,6 +249,7 @@
|
|||||||
"@yaakapp-internal/ui": "^1.0.0",
|
"@yaakapp-internal/ui": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
|
"jotai-family": "^1.0.1",
|
||||||
"motion": "^12.4.7",
|
"motion": "^12.4.7",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
@@ -9733,6 +9735,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jotai-family": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jotai-family/-/jotai-family-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Zb/79GNDhC/z82R+6qTTpeKW4l4H6ZCApfF5W8G4SH37E4mhbysU7r8DkP0KX94hWvjB/6lt/97nSr3wB+64Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"jotai": ">=2.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-cookie": {
|
"node_modules/js-cookie": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
||||||
@@ -16948,7 +16962,10 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@yaakapp-internal/ui",
|
"name": "@yaakapp-internal/ui",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jotai-family": "^1.0.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins-external/faker": {
|
"plugins-external/faker": {
|
||||||
"name": "@yaak/faker",
|
"name": "@yaak/faker",
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts"
|
"types": "src/index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"jotai-family": "^1.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||||
const [editing, setEditing] = useState<boolean>(false);
|
const [editing, setEditing] = useState<boolean>(false);
|
||||||
const [dropHover, setDropHover] = useState<null | "drop" | "animate">(null);
|
const [dropHover, setDropHover] = useState<null | "drop" | "animate">(null);
|
||||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
const startedHoverTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const handle = useMemo<TreeItemHandle>(
|
const handle = useMemo<TreeItemHandle>(
|
||||||
() => ({
|
() => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
@@ -141,7 +141,13 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
|
|
||||||
const handleSubmitNameEdit = useCallback(
|
const handleSubmitNameEdit = useCallback(
|
||||||
async (el: HTMLInputElement) => {
|
async (el: HTMLInputElement) => {
|
||||||
getEditOptions?.(node.item).onChange(node.item, el.value);
|
const editOptions = getEditOptions?.(node.item);
|
||||||
|
if (editOptions == null || el.value === editOptions.defaultValue) {
|
||||||
|
setEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editOptions.onChange(node.item, el.value);
|
||||||
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
|
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
|
||||||
// Slight delay for the model to propagate to the local store
|
// Slight delay for the model to propagate to the local store
|
||||||
setTimeout(() => setEditing(false), 200);
|
setTimeout(() => setEditing(false), 200);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomFamily, selectAtom } from "jotai/utils";
|
import { atomFamily } from "jotai-family";
|
||||||
|
import { selectAtom } from "jotai/utils";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user