make checkout version and revert version fit pattern (#118)
This commit is contained in:
@@ -117,6 +117,7 @@ export function ChatPanel({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ChatHeader
|
||||
isVersionPaneOpen={isVersionPaneOpen}
|
||||
isPreviewOpen={isPreviewOpen}
|
||||
onTogglePreview={onTogglePreview}
|
||||
onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)}
|
||||
|
||||
@@ -21,17 +21,20 @@ import { useRouter } from "@tanstack/react-router";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { showError } from "@/lib/toast";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useCurrentBranch } from "@/hooks/useCurrentBranch";
|
||||
import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
isVersionPaneOpen: boolean;
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onVersionClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
isVersionPaneOpen,
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
onVersionClick,
|
||||
@@ -41,7 +44,6 @@ export function ChatHeader({
|
||||
const { navigate } = useRouter();
|
||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
||||
const { refreshChats } = useChats(appId);
|
||||
const [checkingOutMain, setCheckingOutMain] = useState(false);
|
||||
const { isStreaming } = useStreamChat();
|
||||
|
||||
const {
|
||||
@@ -50,6 +52,8 @@ export function ChatHeader({
|
||||
refetchBranchInfo,
|
||||
} = useCurrentBranch(appId);
|
||||
|
||||
const { checkoutVersion, isCheckingOutVersion } = useCheckoutVersion();
|
||||
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
refetchBranchInfo();
|
||||
@@ -58,19 +62,7 @@ export function ChatHeader({
|
||||
|
||||
const handleCheckoutMainBranch = async () => {
|
||||
if (!appId) return;
|
||||
|
||||
try {
|
||||
setCheckingOutMain(true);
|
||||
await IpcClient.getInstance().checkoutVersion({
|
||||
appId,
|
||||
versionId: "main",
|
||||
});
|
||||
await refetchBranchInfo();
|
||||
} catch (error) {
|
||||
showError(`Failed to checkout main branch: ${error}`);
|
||||
} finally {
|
||||
setCheckingOutMain(false);
|
||||
}
|
||||
await checkoutVersion({ appId, versionId: "main" });
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
@@ -100,7 +92,8 @@ export function ChatHeader({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full @container">
|
||||
{isNotMainBranch && (
|
||||
{/* If the version pane is open, it's expected to not always be on the main branch. */}
|
||||
{isNotMainBranch && !isVersionPaneOpen && (
|
||||
<div className="flex flex-col @sm:flex-row items-center justify-between px-4 py-2 bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<GitBranch size={16} />
|
||||
@@ -138,9 +131,9 @@ export function ChatHeader({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheckoutMainBranch}
|
||||
disabled={checkingOutMain || branchInfoLoading}
|
||||
disabled={isCheckingOutVersion || branchInfoLoading}
|
||||
>
|
||||
{checkingOutMain ? "Checking out..." : "Switch to main branch"}
|
||||
{isCheckingOutVersion ? "Checking out..." : "Switch to main branch"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useVersions } from "@/hooks/useVersions";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
|
||||
|
||||
interface VersionPaneProps {
|
||||
isVisible: boolean;
|
||||
@@ -15,31 +15,69 @@ interface VersionPaneProps {
|
||||
|
||||
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading, refreshVersions, revertVersion } =
|
||||
useVersions(appId);
|
||||
const {
|
||||
versions: liveVersions,
|
||||
refreshVersions,
|
||||
revertVersion,
|
||||
} = useVersions(appId);
|
||||
const [selectedVersionId, setSelectedVersionId] = useAtom(
|
||||
selectedVersionIdAtom,
|
||||
);
|
||||
const { checkoutVersion, isCheckingOutVersion } = useCheckoutVersion();
|
||||
const wasVisibleRef = useRef(false);
|
||||
const [cachedVersions, setCachedVersions] = useState<Version[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function updateVersions() {
|
||||
// Refresh versions in case the user updated versions outside of the app
|
||||
// (e.g. manually using git).
|
||||
// Avoid loading state which causes brief flash of loading state.
|
||||
async function updatePaneState() {
|
||||
// When pane becomes visible after being closed
|
||||
if (isVisible && !wasVisibleRef.current) {
|
||||
if (appId) {
|
||||
await refreshVersions();
|
||||
setCachedVersions(liveVersions);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset when closing
|
||||
if (!isVisible && selectedVersionId) {
|
||||
setSelectedVersionId(null);
|
||||
await IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: "main",
|
||||
});
|
||||
if (appId) {
|
||||
await checkoutVersion({ appId, versionId: "main" });
|
||||
}
|
||||
}
|
||||
refreshVersions();
|
||||
|
||||
wasVisibleRef.current = isVisible;
|
||||
}
|
||||
updateVersions();
|
||||
}, [isVisible, refreshVersions]);
|
||||
updatePaneState();
|
||||
}, [
|
||||
isVisible,
|
||||
selectedVersionId,
|
||||
setSelectedVersionId,
|
||||
appId,
|
||||
checkoutVersion,
|
||||
refreshVersions,
|
||||
liveVersions,
|
||||
]);
|
||||
|
||||
// Initial load of cached versions when live versions become available
|
||||
useEffect(() => {
|
||||
if (isVisible && liveVersions.length > 0 && cachedVersions.length === 0) {
|
||||
setCachedVersions(liveVersions);
|
||||
}
|
||||
}, [isVisible, liveVersions, cachedVersions.length]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleVersionClick = async (versionOid: string) => {
|
||||
if (appId) {
|
||||
setSelectedVersionId(versionOid);
|
||||
await checkoutVersion({ appId, versionId: versionOid });
|
||||
}
|
||||
};
|
||||
|
||||
const versions = cachedVersions.length > 0 ? cachedVersions : liveVersions;
|
||||
|
||||
return (
|
||||
<div className="h-full border-t border-2 border-border w-full">
|
||||
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||
@@ -53,26 +91,25 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{loading ? (
|
||||
<div className="p-4 ">Loading versions...</div>
|
||||
) : versions.length === 0 ? (
|
||||
{versions.length === 0 ? (
|
||||
<div className="p-4 ">No versions available</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{versions.map((version: Version, index) => (
|
||||
<div
|
||||
key={version.oid}
|
||||
className={`px-4 py-2 hover:bg-(--background-lightest) cursor-pointer ${
|
||||
selectedVersionId === version.oid
|
||||
? "bg-(--background-lightest)"
|
||||
: ""
|
||||
}`}
|
||||
className={cn(
|
||||
"px-4 py-2 hover:bg-(--background-lightest) cursor-pointer",
|
||||
selectedVersionId === version.oid &&
|
||||
"bg-(--background-lightest)",
|
||||
isCheckingOutVersion &&
|
||||
selectedVersionId === version.oid &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: version.oid,
|
||||
});
|
||||
setSelectedVersionId(version.oid);
|
||||
if (!isCheckingOutVersion) {
|
||||
handleVersionClick(version.oid);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -115,6 +152,8 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
await revertVersion({
|
||||
versionId: version.oid,
|
||||
});
|
||||
// Close the pane after revert to force a refresh on next open
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
|
||||
38
src/hooks/useCheckoutVersion.ts
Normal file
38
src/hooks/useCheckoutVersion.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface CheckoutVersionVariables {
|
||||
appId: number;
|
||||
versionId: string;
|
||||
}
|
||||
|
||||
export function useCheckoutVersion() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isPending: isCheckingOutVersion, mutateAsync: checkoutVersion } =
|
||||
useMutation<void, Error, CheckoutVersionVariables>({
|
||||
mutationFn: async ({ appId, versionId }) => {
|
||||
if (appId === null) {
|
||||
// Should be caught by UI logic before calling, but as a safeguard.
|
||||
throw new Error("App ID is null, cannot checkout version.");
|
||||
}
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.checkoutVersion({ appId, versionId });
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate queries that depend on the current version/branch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["currentBranch", variables.appId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["versions", variables.appId],
|
||||
});
|
||||
},
|
||||
meta: { showErrorToast: true },
|
||||
});
|
||||
|
||||
return {
|
||||
checkoutVersion,
|
||||
isCheckingOutVersion,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { versionsListAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
@@ -41,40 +41,35 @@ export function useVersions(appId: number | null) {
|
||||
const revertVersionMutation = useMutation<void, Error, { versionId: string }>(
|
||||
{
|
||||
mutationFn: async ({ versionId }: { versionId: string }) => {
|
||||
if (appId === null) {
|
||||
const currentAppId = appId;
|
||||
if (currentAppId === null) {
|
||||
throw new Error("App ID is null");
|
||||
}
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.revertVersion({ appId, previousVersionId: versionId });
|
||||
await ipcClient.revertVersion({
|
||||
appId: currentAppId,
|
||||
previousVersionId: versionId,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["versions", appId] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["currentBranch", appId],
|
||||
});
|
||||
if (selectedChatId) {
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
showError(e);
|
||||
},
|
||||
meta: { showErrorToast: true },
|
||||
},
|
||||
);
|
||||
|
||||
const revertVersion = useCallback(
|
||||
async ({ versionId }: { versionId: string }) => {
|
||||
if (appId === null) {
|
||||
return;
|
||||
}
|
||||
await revertVersionMutation.mutateAsync({ versionId });
|
||||
},
|
||||
[appId, revertVersionMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
versions: versions || [],
|
||||
loading,
|
||||
error,
|
||||
refreshVersions,
|
||||
revertVersion,
|
||||
revertVersion: revertVersionMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function registerVersionHandlers() {
|
||||
appId,
|
||||
previousVersionId,
|
||||
}: { appId: number; previousVersionId: string },
|
||||
) => {
|
||||
): Promise<void> => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
@@ -205,8 +205,6 @@ export function registerVersionHandlers() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error reverting to version ${previousVersionId} for app ${appId}:`,
|
||||
@@ -220,7 +218,10 @@ export function registerVersionHandlers() {
|
||||
|
||||
ipcMain.handle(
|
||||
"checkout-version",
|
||||
async (_, { appId, versionId }: { appId: number; versionId: string }) => {
|
||||
async (
|
||||
_,
|
||||
{ appId, versionId }: { appId: number; versionId: string },
|
||||
): Promise<void> => {
|
||||
return withLock(appId, async () => {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
@@ -240,8 +241,6 @@ export function registerVersionHandlers() {
|
||||
ref: versionId,
|
||||
force: true,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error checking out version ${versionId} for app ${appId}:`,
|
||||
|
||||
@@ -458,17 +458,11 @@ export class IpcClient {
|
||||
}: {
|
||||
appId: number;
|
||||
previousVersionId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("revert-version", {
|
||||
appId,
|
||||
previousVersionId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}): Promise<void> {
|
||||
await this.ipcRenderer.invoke("revert-version", {
|
||||
appId,
|
||||
previousVersionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Checkout a specific version without creating a revert commit
|
||||
@@ -478,17 +472,11 @@ export class IpcClient {
|
||||
}: {
|
||||
appId: number;
|
||||
versionId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const result = await this.ipcRenderer.invoke("checkout-version", {
|
||||
appId,
|
||||
versionId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}): Promise<void> {
|
||||
await this.ipcRenderer.invoke("checkout-version", {
|
||||
appId,
|
||||
versionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current branch of an app
|
||||
|
||||
8
src/lib/assert.ts
Normal file
8
src/lib/assert.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function assertExists<T>(
|
||||
value: T,
|
||||
message: string,
|
||||
): asserts value is NonNullable<T> {
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
MutationCache,
|
||||
} from "@tanstack/react-query";
|
||||
import { showError } from "./lib/toast";
|
||||
|
||||
@@ -39,6 +40,13 @@ const queryClient = new QueryClient({
|
||||
}
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (mutation.meta?.showErrorToast) {
|
||||
showError(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const posthogClient = posthog.init(
|
||||
|
||||
Reference in New Issue
Block a user