Unverified 提交 6a53f6fa authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

refactor: migrate hooks to React Query for data fetching (#2536)

## Summary - Replaced manual `useState`/`useEffect` data fetching patterns with React Query's `useQuery` and `useMutation` in `useSettings`, `useLoadAppFile`, `useLoadApps`, `useAppVersion`, and related hooks - Added centralized query keys for `system`, `settings`, `appFiles`, and `github` domains in `queryKeys.ts` - Added new `useGithubRepos` and `useSystemPlatform` hooks using React Query - Simplified `TitleBar` and `ImportAppDialog` to use the updated hook interfaces ## Test plan - All 784 unit tests pass - Verify settings load correctly on app startup - Verify file loading works in the code viewer - Verify app list loads and refreshes properly - Verify title bar shows correct app version 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2536" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core loading paths (settings, app list, file reads) and caching/invalidations, so regressions could show up as stale data or missing refreshes; changes are mostly refactors with minimal behavioral intent. > > **Overview** > **Migrates several app/system/settings data flows from local state/Jotai-driven fetching to React Query.** `useLoadApps`, `useLoadAppFile`, `useAppVersion`, and `useSettings` now use `useQuery`/`useMutation` with cache updates and refresh via query invalidation, and app favorites (`useAddAppToFavorite`) update the React Query apps cache instead of an `appsListAtom` (which is removed). > > Adds new React Query-powered hooks (`useSystemPlatform`, `useGithubRepos`) and extends `queryKeys` with `system`, `settings`, `appFiles`, and `github` domains; UI callers (`TitleBar`, `ImportAppDialog`, `app-details`) are adjusted to consume the new hooks. E2E favorite-app tests are hardened by hovering before clicks and using a longer assertion timeout. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c82dfca589d0186d25cec33a2453fc5b3f28520b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Migrated core data fetching to React Query to standardize caching, loading, and error handling. Cleaned up UI and tests, removed the apps Jotai atom, and made refresh flows consistent via query invalidation. - **Refactors** - Replaced manual state/effect with useQuery/useMutation in useSettings, useLoadApps, useLoadAppFile, and useAppVersion. - Centralized query keys for system, settings, appFiles, and github in queryKeys.ts. - Removed appsListAtom; apps now read/write via React Query. Kept Jotai sync for settings and env vars. - Simplified TitleBar (uses useSystemPlatform), ImportAppDialog (uses useGithubRepos), and app-details (reads apps from useLoadApps). - Updated useAddAppToFavorite to update the React Query cache. - Reduced flakiness in favorite app e2e tests by hovering before clicks and using a medium timeout. - **New Features** - Added useGithubRepos and useSystemPlatform hooks with session-level caching. <sup>Written for commit c82dfca589d0186d25cec33a2453fc5b3f28520b. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 9a526e9e
import { test } from "./helpers/test_helper"; import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
test.describe("Favorite App Tests", () => { test.describe("Favorite App Tests", () => {
...@@ -20,16 +20,22 @@ test.describe("Favorite App Tests", () => { ...@@ -20,16 +20,22 @@ test.describe("Favorite App Tests", () => {
const appItem = po.page.locator(`[data-testid="app-list-item-${appName}"]`); const appItem = po.page.locator(`[data-testid="app-list-item-${appName}"]`);
await expect(appItem).toBeVisible(); await expect(appItem).toBeVisible();
// Click the favorite button // Click the favorite button — hover first like a real user would,
// then wait for the app to finish starting before the click resolves.
const favoriteButton = appItem const favoriteButton = appItem
.locator("xpath=..") .locator("xpath=..")
.locator('[data-testid="favorite-button"]'); .locator('[data-testid="favorite-button"]');
await expect(favoriteButton).toBeVisible(); await expect(favoriteButton).toBeVisible();
await appItem.hover();
await favoriteButton.click(); await favoriteButton.click();
// Check that the star is filled (favorited) // Check that the star is filled (favorited).
// Use a longer timeout because the addToFavorite IPC call may be waiting
// for the app startup lock to release.
const star = favoriteButton.locator("svg"); const star = favoriteButton.locator("svg");
await expect(star).toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/); await expect(star).toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, {
timeout: Timeout.MEDIUM,
});
}); });
test("Remove app from favorite", async ({ po }) => { test("Remove app from favorite", async ({ po }) => {
...@@ -53,21 +59,27 @@ test.describe("Favorite App Tests", () => { ...@@ -53,21 +59,27 @@ test.describe("Favorite App Tests", () => {
const favoriteButton = appItem const favoriteButton = appItem
.locator("xpath=..") .locator("xpath=..")
.locator('[data-testid="favorite-button"]'); .locator('[data-testid="favorite-button"]');
await appItem.hover();
await favoriteButton.click(); await favoriteButton.click();
// Check that the star is filled (favorited) // Check that the star is filled (favorited)
const star = favoriteButton.locator("svg"); const star = favoriteButton.locator("svg");
await expect(star).toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/); await expect(star).toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, {
timeout: Timeout.MEDIUM,
});
// Now, remove from favorite // Now, remove from favorite
const unfavoriteButton = appItem const unfavoriteButton = appItem
.locator("xpath=..") .locator("xpath=..")
.locator('[data-testid="favorite-button"]'); .locator('[data-testid="favorite-button"]');
await expect(unfavoriteButton).toBeVisible(); await expect(unfavoriteButton).toBeVisible();
await appItem.hover();
await unfavoriteButton.click(); await unfavoriteButton.click();
// Check that the star is not filled (unfavorited) // Check that the star is not filled (unfavorited)
// Match fill-[#6c55dc] only at start or after whitespace (not as part of hover:fill-...) // Match fill-[#6c55dc] only at start or after whitespace (not as part of hover:fill-...)
await expect(star).not.toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/); await expect(star).not.toHaveClass(/(?:^|\s)fill-\[#6c55dc\]/, {
timeout: Timeout.MEDIUM,
});
}); });
}); });
...@@ -13,6 +13,7 @@ import { useEffect, useState } from "react"; ...@@ -13,6 +13,7 @@ import { useEffect, useState } from "react";
import { DyadProSuccessDialog } from "@/components/DyadProSuccessDialog"; import { DyadProSuccessDialog } from "@/components/DyadProSuccessDialog";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { useSystemPlatform } from "@/hooks/useSystemPlatform";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import type { UserBudgetInfo } from "@/ipc/types"; import type { UserBudgetInfo } from "@/ipc/types";
import { import {
...@@ -29,21 +30,8 @@ export const TitleBar = () => { ...@@ -29,21 +30,8 @@ export const TitleBar = () => {
const location = useLocation(); const location = useLocation();
const { settings, refreshSettings } = useSettings(); const { settings, refreshSettings } = useSettings();
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false); const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
const [showWindowControls, setShowWindowControls] = useState(false); const platform = useSystemPlatform();
const showWindowControls = platform !== null && platform !== "darwin";
useEffect(() => {
// Check if we're running on Windows
const checkPlatform = async () => {
try {
const platform = await ipc.system.getSystemPlatform();
setShowWindowControls(platform !== "darwin");
} catch (error) {
console.error("Failed to get platform info:", error);
}
};
checkPlatform();
}, []);
const showDyadProSuccessDialog = () => { const showDyadProSuccessDialog = () => {
setIsSuccessDialogOpen(true); setIsSuccessDialogOpen(true);
......
import { atom } from "jotai"; import { atom } from "jotai";
import type { App, Version, ConsoleEntry } from "@/ipc/types"; import type { App, Version, ConsoleEntry } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import type { UserSettings } from "@/lib/schemas"; import type { UserSettings } from "@/lib/schemas";
export const currentAppAtom = atom<App | null>(null); export const currentAppAtom = atom<App | null>(null);
export const selectedAppIdAtom = atom<number | null>(null); export const selectedAppIdAtom = atom<number | null>(null);
export const appsListAtom = atom<ListedApp[]>([]);
export const versionsListAtom = atom<Version[]>([]); export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom< export const previewModeAtom = atom<
| "preview" | "preview"
......
...@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label"; ...@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import type { GithubRepository } from "@/ipc/types"; import type { GithubRepository } from "@/ipc/types";
import { useGithubRepos } from "@/hooks/useGithubRepos";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
...@@ -54,12 +55,13 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { ...@@ -54,12 +55,13 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const { refreshApps } = useLoadApps(); const { refreshApps } = useLoadApps();
const setSelectedAppId = useSetAtom(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom);
// GitHub import state // GitHub import state
const [repos, setRepos] = useState<GithubRepository[]>([]);
const [loading, setLoading] = useState(false);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const { settings, refreshSettings } = useSettings(); const { settings, refreshSettings } = useSettings();
const isAuthenticated = !!settings?.githubAccessToken; const isAuthenticated = !!settings?.githubAccessToken;
const { repos, loading } = useGithubRepos({
enabled: isOpen && isAuthenticated,
});
const [githubAppName, setGithubAppName] = useState(""); const [githubAppName, setGithubAppName] = useState("");
const [githubNameExists, setGithubNameExists] = useState(false); const [githubNameExists, setGithubNameExists] = useState(false);
...@@ -68,12 +70,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { ...@@ -68,12 +70,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
if (isOpen) { if (isOpen) {
setGithubAppName(""); setGithubAppName("");
setGithubNameExists(false); setGithubNameExists(false);
// Fetch GitHub repos if authenticated
if (isAuthenticated) {
fetchRepos();
}
} }
}, [isOpen, isAuthenticated]); }, [isOpen]);
// Re-check app name when copyToDyadApps changes // Re-check app name when copyToDyadApps changes
useEffect(() => { useEffect(() => {
...@@ -82,17 +80,6 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { ...@@ -82,17 +80,6 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
} }
}, [copyToDyadApps]); }, [copyToDyadApps]);
const fetchRepos = async () => {
setLoading(true);
try {
const fetchedRepos = await ipc.github.listRepos();
setRepos(fetchedRepos);
} catch (err: unknown) {
showError("Failed to fetch repositories.: " + (err as any).toString());
} finally {
setLoading(false);
}
};
const handleUrlBlur = async () => { const handleUrlBlur = async () => {
if (!url.trim()) return; if (!url.trim()) return;
const repoName = extractRepoNameFromUrl(url); const repoName = extractRepoNameFromUrl(url);
......
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import { showError, showSuccess } from "@/lib/toast"; import { showError, showSuccess } from "@/lib/toast";
import { useAtom } from "jotai"; import { queryKeys } from "@/lib/queryKeys";
import { appsListAtom } from "@/atoms/appAtoms";
export function useAddAppToFavorite() { export function useAddAppToFavorite() {
const [_, setApps] = useAtom(appsListAtom); const queryClient = useQueryClient();
const mutation = useMutation<boolean, Error, number>({ const mutation = useMutation<boolean, Error, number>({
mutationFn: async (appId: number): Promise<boolean> => { mutationFn: async (appId: number): Promise<boolean> => {
...@@ -13,8 +13,8 @@ export function useAddAppToFavorite() { ...@@ -13,8 +13,8 @@ export function useAddAppToFavorite() {
return result.isFavorite; return result.isFavorite;
}, },
onSuccess: (newIsFavorite, appId) => { onSuccess: (newIsFavorite, appId) => {
setApps((currentApps) => queryClient.setQueryData<ListedApp[]>(queryKeys.apps.all, (oldApps) =>
currentApps.map((app) => oldApps?.map((app) =>
app.id === appId ? { ...app, isFavorite: newIsFavorite } : app, app.id === appId ? { ...app, isFavorite: newIsFavorite } : app,
), ),
); );
......
import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
export function useAppVersion() { export function useAppVersion() {
const [appVersion, setAppVersion] = useState<string | null>(null); const { data } = useQuery({
queryKey: queryKeys.system.appVersion,
useEffect(() => { queryFn: async () => {
const fetchVersion = async () => {
try {
const result = await ipc.system.getAppVersion(); const result = await ipc.system.getAppVersion();
setAppVersion(result.version); return result.version;
} catch { },
setAppVersion(null); staleTime: Infinity, // App version never changes during a session
} });
};
fetchVersion();
}, []);
return appVersion; return data ?? null;
} }
import { useQuery } from "@tanstack/react-query";
import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
export function useGithubRepos({ enabled }: { enabled: boolean }) {
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.github.repos,
queryFn: () => ipc.github.listRepos(),
enabled,
meta: { showErrorToast: true },
});
return {
repos: data ?? [],
loading: isLoading,
error,
};
}
import { useState, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
export function useLoadAppFile(appId: number | null, filePath: string | null) { export function useLoadAppFile(appId: number | null, filePath: string | null) {
const [content, setContent] = useState<string | null>(null); const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const { data, isLoading, error } = useQuery({
queryKey: queryKeys.appFiles.content({ appId, filePath }),
useEffect(() => { queryFn: async () => {
const loadFile = async () => { return ipc.app.readAppFile({ appId: appId!, filePath: filePath! });
if (appId === null || filePath === null) { },
setContent(null); enabled: appId !== null && filePath !== null,
setError(null); });
return;
} const refreshFile = () => {
if (appId === null || filePath === null) return Promise.resolve();
setLoading(true); return queryClient.invalidateQueries({
try { queryKey: queryKeys.appFiles.content({ appId, filePath }),
const fileContent = await ipc.app.readAppFile({ appId, filePath }); });
setContent(fileContent);
setError(null);
} catch (error) {
console.error(
`Error loading file ${filePath} for app ${appId}:`,
error,
);
setError(error instanceof Error ? error : new Error(String(error)));
setContent(null);
} finally {
setLoading(false);
}
}; };
loadFile(); return {
}, [appId, filePath]); content: data ?? null,
loading: isLoading,
const refreshFile = async () => { error: error ?? null,
if (appId === null || filePath === null) { refreshFile,
return;
}
setLoading(true);
try {
const fileContent = await ipc.app.readAppFile({ appId, filePath });
setContent(fileContent);
setError(null);
} catch (error) {
console.error(
`Error refreshing file ${filePath} for app ${appId}:`,
error,
);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
}; };
return { content, loading, error, refreshFile };
} }
import { useState, useEffect, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { appsListAtom } from "@/atoms/appAtoms";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
export function useLoadApps() { export function useLoadApps() {
const [apps, setApps] = useAtom(appsListAtom); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const refreshApps = useCallback(async () => { const { data, isLoading, error } = useQuery({
setLoading(true); queryKey: queryKeys.apps.all,
try { queryFn: async () => {
const appListResponse = await ipc.app.listApps(); const appListResponse = await ipc.app.listApps();
setApps(appListResponse.apps); return appListResponse.apps;
setError(null); },
} catch (error) { });
console.error("Error refreshing apps:", error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
}, [setApps, setError, setLoading]);
useEffect(() => { const refreshApps = () => {
refreshApps(); return queryClient.invalidateQueries({ queryKey: queryKeys.apps.all });
}, [refreshApps]); };
return { apps, loading, error, refreshApps }; return {
apps: data ?? [],
loading: isLoading,
error: error ?? null,
refreshApps,
};
} }
import { useState, useEffect, useCallback } from "react"; import { useEffect, useCallback } from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms"; import { userSettingsAtom, envVarsAtom } from "@/atoms/appAtoms";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { type UserSettings, hasDyadProKey } from "@/lib/schemas"; import { type UserSettings, hasDyadProKey } from "@/lib/schemas";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { useAppVersion } from "./useAppVersion"; import { useAppVersion } from "./useAppVersion";
import { queryKeys } from "@/lib/queryKeys";
const TELEMETRY_CONSENT_KEY = "dyadTelemetryConsent"; const TELEMETRY_CONSENT_KEY = "dyadTelemetryConsent";
const TELEMETRY_USER_ID_KEY = "dyadTelemetryUserId"; const TELEMETRY_USER_ID_KEY = "dyadTelemetryUserId";
...@@ -21,21 +23,28 @@ let isInitialLoad = false; ...@@ -21,21 +23,28 @@ let isInitialLoad = false;
export function useSettings() { export function useSettings() {
const posthog = usePostHog(); const posthog = usePostHog();
const [settings, setSettingsAtom] = useAtom(userSettingsAtom); const [, setSettingsAtom] = useAtom(userSettingsAtom);
const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom); const [, setEnvVarsAtom] = useAtom(envVarsAtom);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const appVersion = useAppVersion(); const appVersion = useAppVersion();
const loadInitialData = useCallback(async () => { const queryClient = useQueryClient();
setLoading(true);
try { // Query for user settings
// Fetch settings and env vars concurrently const settingsQuery = useQuery({
const [userSettings, fetchedEnvVars] = await Promise.all([ queryKey: queryKeys.settings.user,
ipc.settings.getUserSettings(), queryFn: () => ipc.settings.getUserSettings(),
ipc.misc.getEnvVars(), });
]);
processSettingsForTelemetry(userSettings); // Query for env vars
const isPro = hasDyadProKey(userSettings); const envVarsQuery = useQuery({
queryKey: queryKeys.settings.envVars,
queryFn: () => ipc.misc.getEnvVars(),
});
// Process telemetry side effects when settings load/change
useEffect(() => {
if (settingsQuery.data) {
processSettingsForTelemetry(settingsQuery.data);
const isPro = hasDyadProKey(settingsQuery.data);
posthog.people.set({ isPro }); posthog.people.set({ isPro });
if (!isInitialLoad && appVersion) { if (!isInitialLoad && appVersion) {
posthog.capture("app:initial-load", { posthog.capture("app:initial-load", {
...@@ -44,51 +53,54 @@ export function useSettings() { ...@@ -44,51 +53,54 @@ export function useSettings() {
}); });
isInitialLoad = true; isInitialLoad = true;
} }
setSettingsAtom(userSettings); setSettingsAtom(settingsQuery.data);
setEnvVarsAtom(fetchedEnvVars);
setError(null);
} catch (error) {
console.error("Error loading initial data:", error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
} }
}, [setSettingsAtom, setEnvVarsAtom, appVersion]); }, [settingsQuery.data, appVersion, posthog, setSettingsAtom]);
// Sync env vars to Jotai atom
useEffect(() => { useEffect(() => {
// Only run once on mount, dependencies are stable getters/setters if (envVarsQuery.data) {
loadInitialData(); setEnvVarsAtom(envVarsQuery.data);
}, [loadInitialData]); }
}, [envVarsQuery.data, setEnvVarsAtom]);
const updateSettings = async (newSettings: Partial<UserSettings>) => { // Mutation for updating settings
setLoading(true); const updateSettingsMutation = useMutation({
try { mutationFn: async (newSettings: Partial<UserSettings>) => {
const updatedSettings = await ipc.settings.setUserSettings(newSettings); return ipc.settings.setUserSettings(newSettings);
setSettingsAtom(updatedSettings); },
onSuccess: (updatedSettings) => {
queryClient.setQueryData(queryKeys.settings.user, updatedSettings);
processSettingsForTelemetry(updatedSettings); processSettingsForTelemetry(updatedSettings);
posthog.people.set({ isPro: hasDyadProKey(updatedSettings) }); posthog.people.set({ isPro: hasDyadProKey(updatedSettings) });
setSettingsAtom(updatedSettings);
},
meta: { showErrorToast: true },
});
setError(null); const updateSettings = useCallback(
return updatedSettings; async (newSettings: Partial<UserSettings>) => {
} catch (error) { return updateSettingsMutation.mutateAsync(newSettings);
console.error("Error updating settings:", error); },
setError(error instanceof Error ? error : new Error(String(error))); [updateSettingsMutation],
throw error; );
} finally {
setLoading(false); const refreshSettings = useCallback(() => {
} return queryClient.invalidateQueries({
}; queryKey: queryKeys.settings.all,
});
}, [queryClient]);
const loading = settingsQuery.isLoading || envVarsQuery.isLoading;
const error = settingsQuery.error || envVarsQuery.error || null;
return { return {
settings, settings: settingsQuery.data ?? null,
envVars, envVars: envVarsQuery.data ?? {},
loading, loading,
error, error,
updateSettings, updateSettings,
refreshSettings,
refreshSettings: () => {
return loadInitialData();
},
}; };
} }
......
import { useQuery } from "@tanstack/react-query";
import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
export function useSystemPlatform() {
const { data } = useQuery({
queryKey: queryKeys.system.platform,
queryFn: () => ipc.system.getSystemPlatform(),
staleTime: Infinity, // Platform never changes during a session
});
return data ?? null;
}
...@@ -15,6 +15,24 @@ ...@@ -15,6 +15,24 @@
*/ */
export const queryKeys = { export const queryKeys = {
// ─────────────────────────────────────────────────────────────────────────────
// System
// ─────────────────────────────────────────────────────────────────────────────
system: {
all: ["system"] as const,
appVersion: ["system", "appVersion"] as const,
platform: ["system", "platform"] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
// Settings
// ─────────────────────────────────────────────────────────────────────────────
settings: {
all: ["settings"] as const,
user: ["settings", "user"] as const,
envVars: ["settings", "envVars"] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Apps // Apps
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
...@@ -114,6 +132,20 @@ export const queryKeys = { ...@@ -114,6 +132,20 @@ export const queryKeys = {
["search-app-files", appId, query] as const, ["search-app-files", appId, query] as const,
}, },
// ─────────────────────────────────────────────────────────────────────────────
// App Files
// ─────────────────────────────────────────────────────────────────────────────
appFiles: {
all: ["app-files"] as const,
content: ({
appId,
filePath,
}: {
appId: number | null;
filePath: string | null;
}) => ["app-files", "content", appId, filePath] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// App Name Check // App Name Check
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
...@@ -246,6 +278,14 @@ export const queryKeys = { ...@@ -246,6 +278,14 @@ export const queryKeys = {
}) => ["supabase", "branches", projectId, organizationSlug] as const, }) => ["supabase", "branches", projectId, organizationSlug] as const,
}, },
// ─────────────────────────────────────────────────────────────────────────────
// GitHub
// ─────────────────────────────────────────────────────────────────────────────
github: {
all: ["github"] as const,
repos: ["github", "repos"] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Neon // Neon
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
...@@ -276,6 +316,8 @@ export type QueryKeyOf<T> = T extends readonly unknown[] ...@@ -276,6 +316,8 @@ export type QueryKeyOf<T> = T extends readonly unknown[]
/** All possible query keys (useful for typing queryClient operations) */ /** All possible query keys (useful for typing queryClient operations) */
export type AppQueryKey = export type AppQueryKey =
| QueryKeyOf<(typeof queryKeys.system)[keyof typeof queryKeys.system]>
| QueryKeyOf<(typeof queryKeys.settings)[keyof typeof queryKeys.settings]>
| QueryKeyOf<(typeof queryKeys.apps)[keyof typeof queryKeys.apps]> | QueryKeyOf<(typeof queryKeys.apps)[keyof typeof queryKeys.apps]>
| QueryKeyOf<(typeof queryKeys.chats)[keyof typeof queryKeys.chats]> | QueryKeyOf<(typeof queryKeys.chats)[keyof typeof queryKeys.chats]>
| QueryKeyOf<(typeof queryKeys.plans)[keyof typeof queryKeys.plans]> | QueryKeyOf<(typeof queryKeys.plans)[keyof typeof queryKeys.plans]>
...@@ -290,6 +332,7 @@ export type AppQueryKey = ...@@ -290,6 +332,7 @@ export type AppQueryKey =
(typeof queryKeys.contextPaths)[keyof typeof queryKeys.contextPaths] (typeof queryKeys.contextPaths)[keyof typeof queryKeys.contextPaths]
> >
| QueryKeyOf<(typeof queryKeys.tokenCount)[keyof typeof queryKeys.tokenCount]> | QueryKeyOf<(typeof queryKeys.tokenCount)[keyof typeof queryKeys.tokenCount]>
| QueryKeyOf<(typeof queryKeys.appFiles)[keyof typeof queryKeys.appFiles]>
| QueryKeyOf<(typeof queryKeys.files)[keyof typeof queryKeys.files]> | QueryKeyOf<(typeof queryKeys.files)[keyof typeof queryKeys.files]>
| QueryKeyOf<(typeof queryKeys.appName)[keyof typeof queryKeys.appName]> | QueryKeyOf<(typeof queryKeys.appName)[keyof typeof queryKeys.appName]>
| QueryKeyOf< | QueryKeyOf<
...@@ -316,6 +359,7 @@ export type AppQueryKey = ...@@ -316,6 +359,7 @@ export type AppQueryKey =
> >
| QueryKeyOf<(typeof queryKeys.mcp)[keyof typeof queryKeys.mcp]> | QueryKeyOf<(typeof queryKeys.mcp)[keyof typeof queryKeys.mcp]>
| QueryKeyOf<(typeof queryKeys.supabase)[keyof typeof queryKeys.supabase]> | QueryKeyOf<(typeof queryKeys.supabase)[keyof typeof queryKeys.supabase]>
| QueryKeyOf<(typeof queryKeys.github)[keyof typeof queryKeys.github]>
| QueryKeyOf<(typeof queryKeys.neon)[keyof typeof queryKeys.neon]> | QueryKeyOf<(typeof queryKeys.neon)[keyof typeof queryKeys.neon]>
| QueryKeyOf< | QueryKeyOf<
(typeof queryKeys.appEnvVars)[keyof typeof queryKeys.appEnvVars] (typeof queryKeys.appEnvVars)[keyof typeof queryKeys.appEnvVars]
......
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router"; import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import { normalizePath } from "../../shared/normalizePath"; import { normalizePath } from "../../shared/normalizePath";
import { useAtom, useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { appsListAtom, selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { useLoadApps } from "@/hooks/useLoadApps"; import { useLoadApps } from "@/hooks/useLoadApps";
import { useState } from "react"; import { useState } from "react";
...@@ -49,8 +49,7 @@ export default function AppDetailsPage() { ...@@ -49,8 +49,7 @@ export default function AppDetailsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
const search = useSearch({ from: "/app-details" as const }); const search = useSearch({ from: "/app-details" as const });
const [appsList] = useAtom(appsListAtom); const { apps: appsList, refreshApps } = useLoadApps();
const { refreshApps } = useLoadApps();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论