make checkout version and revert version fit pattern (#118)

This commit is contained in:
Will Chen
2025-05-08 23:23:24 -07:00
committed by GitHub
parent 8d61659c60
commit c203b1d009
9 changed files with 161 additions and 92 deletions

View File

@@ -117,6 +117,7 @@ export function ChatPanel({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<ChatHeader <ChatHeader
isVersionPaneOpen={isVersionPaneOpen}
isPreviewOpen={isPreviewOpen} isPreviewOpen={isPreviewOpen}
onTogglePreview={onTogglePreview} onTogglePreview={onTogglePreview}
onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)} onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)}

View File

@@ -21,17 +21,20 @@ import { useRouter } from "@tanstack/react-router";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useChats } from "@/hooks/useChats"; import { useChats } from "@/hooks/useChats";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { useCurrentBranch } from "@/hooks/useCurrentBranch"; import { useCurrentBranch } from "@/hooks/useCurrentBranch";
import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
interface ChatHeaderProps { interface ChatHeaderProps {
isVersionPaneOpen: boolean;
isPreviewOpen: boolean; isPreviewOpen: boolean;
onTogglePreview: () => void; onTogglePreview: () => void;
onVersionClick: () => void; onVersionClick: () => void;
} }
export function ChatHeader({ export function ChatHeader({
isVersionPaneOpen,
isPreviewOpen, isPreviewOpen,
onTogglePreview, onTogglePreview,
onVersionClick, onVersionClick,
@@ -41,7 +44,6 @@ export function ChatHeader({
const { navigate } = useRouter(); const { navigate } = useRouter();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const { refreshChats } = useChats(appId); const { refreshChats } = useChats(appId);
const [checkingOutMain, setCheckingOutMain] = useState(false);
const { isStreaming } = useStreamChat(); const { isStreaming } = useStreamChat();
const { const {
@@ -50,6 +52,8 @@ export function ChatHeader({
refetchBranchInfo, refetchBranchInfo,
} = useCurrentBranch(appId); } = useCurrentBranch(appId);
const { checkoutVersion, isCheckingOutVersion } = useCheckoutVersion();
useEffect(() => { useEffect(() => {
if (appId) { if (appId) {
refetchBranchInfo(); refetchBranchInfo();
@@ -58,19 +62,7 @@ export function ChatHeader({
const handleCheckoutMainBranch = async () => { const handleCheckoutMainBranch = async () => {
if (!appId) return; if (!appId) return;
await checkoutVersion({ appId, versionId: "main" });
try {
setCheckingOutMain(true);
await IpcClient.getInstance().checkoutVersion({
appId,
versionId: "main",
});
await refetchBranchInfo();
} catch (error) {
showError(`Failed to checkout main branch: ${error}`);
} finally {
setCheckingOutMain(false);
}
}; };
const handleNewChat = async () => { const handleNewChat = async () => {
@@ -100,7 +92,8 @@ export function ChatHeader({
return ( return (
<div className="flex flex-col w-full @container"> <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 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"> <div className="flex items-center gap-2 text-sm">
<GitBranch size={16} /> <GitBranch size={16} />
@@ -138,9 +131,9 @@ export function ChatHeader({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleCheckoutMainBranch} onClick={handleCheckoutMainBranch}
disabled={checkingOutMain || branchInfoLoading} disabled={isCheckingOutVersion || branchInfoLoading}
> >
{checkingOutMain ? "Checking out..." : "Switch to main branch"} {isCheckingOutVersion ? "Checking out..." : "Switch to main branch"}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -4,9 +4,9 @@ import { useVersions } from "@/hooks/useVersions";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { RotateCcw, X } from "lucide-react"; import { RotateCcw, X } from "lucide-react";
import type { Version } from "@/ipc/ipc_types"; import type { Version } from "@/ipc/ipc_types";
import { IpcClient } from "@/ipc/ipc_client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEffect } from "react"; import { useEffect, useRef, useState } from "react";
import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
interface VersionPaneProps { interface VersionPaneProps {
isVisible: boolean; isVisible: boolean;
@@ -15,31 +15,69 @@ interface VersionPaneProps {
export function VersionPane({ isVisible, onClose }: VersionPaneProps) { export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const { versions, loading, refreshVersions, revertVersion } = const {
useVersions(appId); versions: liveVersions,
refreshVersions,
revertVersion,
} = useVersions(appId);
const [selectedVersionId, setSelectedVersionId] = useAtom( const [selectedVersionId, setSelectedVersionId] = useAtom(
selectedVersionIdAtom, selectedVersionIdAtom,
); );
const { checkoutVersion, isCheckingOutVersion } = useCheckoutVersion();
const wasVisibleRef = useRef(false);
const [cachedVersions, setCachedVersions] = useState<Version[]>([]);
useEffect(() => { useEffect(() => {
async function updateVersions() { async function updatePaneState() {
// Refresh versions in case the user updated versions outside of the app // When pane becomes visible after being closed
// (e.g. manually using git). if (isVisible && !wasVisibleRef.current) {
// Avoid loading state which causes brief flash of loading state. if (appId) {
await refreshVersions();
setCachedVersions(liveVersions);
}
}
// Reset when closing
if (!isVisible && selectedVersionId) { if (!isVisible && selectedVersionId) {
setSelectedVersionId(null); setSelectedVersionId(null);
await IpcClient.getInstance().checkoutVersion({ if (appId) {
appId: appId!, await checkoutVersion({ appId, versionId: "main" });
versionId: "main", }
});
} }
refreshVersions();
wasVisibleRef.current = isVisible;
} }
updateVersions(); updatePaneState();
}, [isVisible, refreshVersions]); }, [
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) { if (!isVisible) {
return null; return null;
} }
const handleVersionClick = async (versionOid: string) => {
if (appId) {
setSelectedVersionId(versionOid);
await checkoutVersion({ appId, versionId: versionOid });
}
};
const versions = cachedVersions.length > 0 ? cachedVersions : liveVersions;
return ( return (
<div className="h-full border-t border-2 border-border w-full"> <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"> <div className="p-2 border-b border-border flex items-center justify-between">
@@ -53,26 +91,25 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
</button> </button>
</div> </div>
<div className="overflow-y-auto h-[calc(100%-60px)]"> <div className="overflow-y-auto h-[calc(100%-60px)]">
{loading ? ( {versions.length === 0 ? (
<div className="p-4 ">Loading versions...</div>
) : versions.length === 0 ? (
<div className="p-4 ">No versions available</div> <div className="p-4 ">No versions available</div>
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{versions.map((version: Version, index) => ( {versions.map((version: Version, index) => (
<div <div
key={version.oid} key={version.oid}
className={`px-4 py-2 hover:bg-(--background-lightest) cursor-pointer ${ className={cn(
selectedVersionId === version.oid "px-4 py-2 hover:bg-(--background-lightest) cursor-pointer",
? "bg-(--background-lightest)" selectedVersionId === version.oid &&
: "" "bg-(--background-lightest)",
}`} isCheckingOutVersion &&
selectedVersionId === version.oid &&
"opacity-50 cursor-not-allowed",
)}
onClick={() => { onClick={() => {
IpcClient.getInstance().checkoutVersion({ if (!isCheckingOutVersion) {
appId: appId!, handleVersionClick(version.oid);
versionId: version.oid, }
});
setSelectedVersionId(version.oid);
}} }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -115,6 +152,8 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
await revertVersion({ await revertVersion({
versionId: version.oid, versionId: version.oid,
}); });
// Close the pane after revert to force a refresh on next open
onClose();
}} }}
className={cn( 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", "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",

View 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,
};
}

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect } from "react"; import { useEffect } from "react";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { versionsListAtom } from "@/atoms/appAtoms"; import { versionsListAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { showError } from "@/lib/toast";
import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Version } from "@/ipc/ipc_types"; import type { Version } from "@/ipc/ipc_types";
@@ -41,40 +41,35 @@ export function useVersions(appId: number | null) {
const revertVersionMutation = useMutation<void, Error, { versionId: string }>( const revertVersionMutation = useMutation<void, Error, { versionId: string }>(
{ {
mutationFn: async ({ versionId }: { versionId: string }) => { mutationFn: async ({ versionId }: { versionId: string }) => {
if (appId === null) { const currentAppId = appId;
if (currentAppId === null) {
throw new Error("App ID is null"); throw new Error("App ID is null");
} }
const ipcClient = IpcClient.getInstance(); const ipcClient = IpcClient.getInstance();
await ipcClient.revertVersion({ appId, previousVersionId: versionId }); await ipcClient.revertVersion({
appId: currentAppId,
previousVersionId: versionId,
});
}, },
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["versions", appId] }); await queryClient.invalidateQueries({ queryKey: ["versions", appId] });
await queryClient.invalidateQueries({
queryKey: ["currentBranch", appId],
});
if (selectedChatId) { if (selectedChatId) {
const chat = await IpcClient.getInstance().getChat(selectedChatId); const chat = await IpcClient.getInstance().getChat(selectedChatId);
setMessages(chat.messages); setMessages(chat.messages);
} }
}, },
onError: (e: Error) => { meta: { showErrorToast: true },
showError(e);
},
}, },
); );
const revertVersion = useCallback(
async ({ versionId }: { versionId: string }) => {
if (appId === null) {
return;
}
await revertVersionMutation.mutateAsync({ versionId });
},
[appId, revertVersionMutation],
);
return { return {
versions: versions || [], versions: versions || [],
loading, loading,
error, error,
refreshVersions, refreshVersions,
revertVersion, revertVersion: revertVersionMutation.mutateAsync,
}; };
} }

View File

@@ -94,7 +94,7 @@ export function registerVersionHandlers() {
appId, appId,
previousVersionId, previousVersionId,
}: { appId: number; previousVersionId: string }, }: { appId: number; previousVersionId: string },
) => { ): Promise<void> => {
return withLock(appId, async () => { return withLock(appId, async () => {
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
@@ -205,8 +205,6 @@ export function registerVersionHandlers() {
); );
} }
} }
return { success: true };
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`Error reverting to version ${previousVersionId} for app ${appId}:`, `Error reverting to version ${previousVersionId} for app ${appId}:`,
@@ -220,7 +218,10 @@ export function registerVersionHandlers() {
ipcMain.handle( ipcMain.handle(
"checkout-version", "checkout-version",
async (_, { appId, versionId }: { appId: number; versionId: string }) => { async (
_,
{ appId, versionId }: { appId: number; versionId: string },
): Promise<void> => {
return withLock(appId, async () => { return withLock(appId, async () => {
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
@@ -240,8 +241,6 @@ export function registerVersionHandlers() {
ref: versionId, ref: versionId,
force: true, force: true,
}); });
return { success: true };
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`Error checking out version ${versionId} for app ${appId}:`, `Error checking out version ${versionId} for app ${appId}:`,

View File

@@ -458,17 +458,11 @@ export class IpcClient {
}: { }: {
appId: number; appId: number;
previousVersionId: string; previousVersionId: string;
}): Promise<{ success: boolean }> { }): Promise<void> {
try { await this.ipcRenderer.invoke("revert-version", {
const result = await this.ipcRenderer.invoke("revert-version", { appId,
appId, previousVersionId,
previousVersionId, });
});
return result;
} catch (error) {
showError(error);
throw error;
}
} }
// Checkout a specific version without creating a revert commit // Checkout a specific version without creating a revert commit
@@ -478,17 +472,11 @@ export class IpcClient {
}: { }: {
appId: number; appId: number;
versionId: string; versionId: string;
}): Promise<{ success: boolean }> { }): Promise<void> {
try { await this.ipcRenderer.invoke("checkout-version", {
const result = await this.ipcRenderer.invoke("checkout-version", { appId,
appId, versionId,
versionId, });
});
return result;
} catch (error) {
showError(error);
throw error;
}
} }
// Get the current branch of an app // Get the current branch of an app

8
src/lib/assert.ts Normal file
View 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);
}
}

View File

@@ -9,6 +9,7 @@ import {
QueryCache, QueryCache,
QueryClient, QueryClient,
QueryClientProvider, QueryClientProvider,
MutationCache,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { showError } from "./lib/toast"; 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( const posthogClient = posthog.init(