diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts index dca23c1f3e0..b10273700b5 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts @@ -5,132 +5,50 @@ import { } from "./githubQueryPolicy"; describe("getGitHubStatusQueryPolicy", () => { - test("enables focus-only refresh for the active changes sidebar diffs view", () => { - expect( - getGitHubStatusQueryPolicy("changes-sidebar", { - hasWorkspaceId: true, - isActive: true, - isReviewTabActive: false, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: true, - staleTime: 0, - }); - }); - - test("enables polling for the active changes sidebar review view", () => { - expect( - getGitHubStatusQueryPolicy("changes-sidebar", { - hasWorkspaceId: true, - isActive: true, - isReviewTabActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: 10_000, - refetchOnWindowFocus: true, - staleTime: 10_000, - }); - }); - - test("disables changes sidebar status when the surface is inactive", () => { - expect( - getGitHubStatusQueryPolicy("changes-sidebar", { - hasWorkspaceId: true, - isActive: false, - isReviewTabActive: true, - }), - ).toEqual({ - enabled: false, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 10_000, - }); - }); - - test("keeps the workspace page active without interval polling", () => { - expect( - getGitHubStatusQueryPolicy("workspace-page", { - hasWorkspaceId: true, - isActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 300_000, - }); - }); - - test("keeps hover-card surfaces lazy without focus refresh", () => { - expect( - getGitHubStatusQueryPolicy("workspace-hover-card", { - hasWorkspaceId: true, - isActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 300_000, - }); - }); - - test("keeps workspace list items cheaper than full-page PR surfaces", () => { - expect( - getGitHubStatusQueryPolicy("workspace-list-item", { - hasWorkspaceId: true, - isActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - }); - - test("disables passive hover surfaces when they are not visible", () => { - expect( - getGitHubStatusQueryPolicy("workspace-row", { - hasWorkspaceId: true, - isActive: false, - }), - ).toEqual({ - enabled: false, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 300_000, - }); + test("active surfaces poll every 10s", () => { + for (const surface of ["changes-sidebar", "workspace-page"] as const) { + expect( + getGitHubStatusQueryPolicy(surface, { + hasWorkspaceId: true, + isActive: true, + }), + ).toEqual({ + enabled: true, + refetchInterval: 10_000, + refetchOnWindowFocus: true, + staleTime: 10_000, + }); + } + }); + + test("hover surfaces rely on staleTime debounce, no polling", () => { + for (const surface of [ + "workspace-list-item", + "workspace-row", + "workspace-hover-card", + ] as const) { + expect( + getGitHubStatusQueryPolicy(surface, { + hasWorkspaceId: true, + isActive: true, + }), + ).toEqual({ + enabled: true, + refetchInterval: false, + refetchOnWindowFocus: false, + staleTime: 10_000, + }); + } }); }); describe("getGitHubPRCommentsQueryPolicy", () => { - test("fetches review comments without polling when changes is open on diffs", () => { + test("polls every 30s when active with a pull request", () => { expect( getGitHubPRCommentsQueryPolicy({ hasWorkspaceId: true, hasActivePullRequest: true, isActive: true, - isReviewTabActive: false, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - }); - - test("polls review comments while the review tab is active", () => { - expect( - getGitHubPRCommentsQueryPolicy({ - hasWorkspaceId: true, - hasActivePullRequest: true, - isActive: true, - isReviewTabActive: true, }), ).toEqual({ enabled: true, @@ -139,20 +57,4 @@ describe("getGitHubPRCommentsQueryPolicy", () => { staleTime: 30_000, }); }); - - test("disables comments when there is no active pull request", () => { - expect( - getGitHubPRCommentsQueryPolicy({ - hasWorkspaceId: true, - hasActivePullRequest: false, - isActive: true, - isReviewTabActive: true, - }), - ).toEqual({ - enabled: false, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - }); }); diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts index 6109e168286..c8c118c83be 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts @@ -1,7 +1,5 @@ -const ACTIVE_GITHUB_STATUS_STALE_TIME_MS = 10_000; -const ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS = 10_000; -const WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS = 30_000; -const PASSIVE_GITHUB_STATUS_STALE_TIME_MS = 5 * 60 * 1000; +export const GITHUB_STATUS_STALE_TIME_MS = 10_000; +const GITHUB_STATUS_REFETCH_INTERVAL_MS = 10_000; const GITHUB_PR_COMMENTS_STALE_TIME_MS = 30_000; const GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS = 30_000; @@ -22,81 +20,51 @@ export interface GitHubQueryPolicy { interface GitHubStatusQueryPolicyOptions { hasWorkspaceId: boolean; isActive?: boolean; - isReviewTabActive?: boolean; } interface GitHubPRCommentsQueryPolicyOptions { hasWorkspaceId: boolean; hasActivePullRequest: boolean; isActive?: boolean; - isReviewTabActive?: boolean; } +const HOVER_SURFACES: ReadonlySet = new Set([ + "workspace-list-item", + "workspace-row", + "workspace-hover-card", +]); + /** - * Centralizes GitHub query behavior so passive hover surfaces stay cheap while - * active workspace surfaces still revalidate when they become relevant again. + * Active surfaces (changes-sidebar, workspace-page) poll every 10s. + * Hover surfaces don't poll — callers trigger refetch on hover, debounced by staleTime. */ export function getGitHubStatusQueryPolicy( surface: GitHubStatusQuerySurface, - { - hasWorkspaceId, - isActive = true, - isReviewTabActive = false, - }: GitHubStatusQueryPolicyOptions, + { hasWorkspaceId, isActive = true }: GitHubStatusQueryPolicyOptions, ): GitHubQueryPolicy { const isEnabled = hasWorkspaceId && isActive; + const isHover = HOVER_SURFACES.has(surface); - switch (surface) { - case "changes-sidebar": - return { - enabled: isEnabled, - refetchInterval: - isEnabled && isReviewTabActive - ? ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS - : false, - refetchOnWindowFocus: isEnabled, - staleTime: isReviewTabActive ? ACTIVE_GITHUB_STATUS_STALE_TIME_MS : 0, - }; - case "workspace-page": - return { - enabled: isEnabled, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: PASSIVE_GITHUB_STATUS_STALE_TIME_MS, - }; - case "workspace-list-item": - return { - enabled: isEnabled, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS, - }; - case "workspace-hover-card": - case "workspace-row": - return { - enabled: isEnabled, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: PASSIVE_GITHUB_STATUS_STALE_TIME_MS, - }; - } + return { + enabled: isEnabled, + refetchInterval: + isEnabled && !isHover ? GITHUB_STATUS_REFETCH_INTERVAL_MS : false, + refetchOnWindowFocus: isEnabled && !isHover, + staleTime: GITHUB_STATUS_STALE_TIME_MS, + }; } export function getGitHubPRCommentsQueryPolicy({ hasWorkspaceId, hasActivePullRequest, isActive = true, - isReviewTabActive = false, }: GitHubPRCommentsQueryPolicyOptions): GitHubQueryPolicy { const isEnabled = hasWorkspaceId && isActive && hasActivePullRequest; return { enabled: isEnabled, - refetchInterval: - isEnabled && isReviewTabActive - ? GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS - : false, - refetchOnWindowFocus: isEnabled && isReviewTabActive, + refetchInterval: isEnabled ? GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS : false, + refetchOnWindowFocus: isEnabled, staleTime: GITHUB_PR_COMMENTS_STALE_TIME_MS, }; } diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts index de2aafc9ae8..ac1fd2cc036 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts @@ -6,3 +6,4 @@ export { getGitHubPRCommentsQueryPolicy, getGitHubStatusQueryPolicy, } from "./githubQueryPolicy"; +export { useHoverGitHubStatus } from "./useHoverGitHubStatus"; diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/useHoverGitHubStatus.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/useHoverGitHubStatus.ts new file mode 100644 index 00000000000..e833b9a7dca --- /dev/null +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/useHoverGitHubStatus.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + GITHUB_STATUS_STALE_TIME_MS, + type GitHubStatusQuerySurface, + getGitHubStatusQueryPolicy, +} from "./githubQueryPolicy"; + +interface UseHoverGitHubStatusOptions { + workspaceId: string | null | undefined; + surface: GitHubStatusQuerySurface; + isWorktree: boolean; +} + +export function useHoverGitHubStatus({ + workspaceId, + surface, + isWorktree, +}: UseHoverGitHubStatusOptions) { + const [hasHovered, setHasHovered] = useState(false); + + const queryPolicy = getGitHubStatusQueryPolicy(surface, { + hasWorkspaceId: !!workspaceId, + isActive: hasHovered && isWorktree, + }); + + const { + data: githubStatus, + dataUpdatedAt, + isStale, + refetch, + } = electronTrpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: workspaceId ?? "" }, + queryPolicy, + ); + + const pendingRefetchRef = useRef | null>(null); + useEffect( + () => () => { + if (pendingRefetchRef.current) clearTimeout(pendingRefetchRef.current); + }, + [], + ); + + const onMouseEnter = () => { + if (!hasHovered) { + setHasHovered(true); + } else if (isStale) { + if (pendingRefetchRef.current) { + clearTimeout(pendingRefetchRef.current); + pendingRefetchRef.current = null; + } + void refetch(); + } else if (!pendingRefetchRef.current) { + const msUntilStale = + GITHUB_STATUS_STALE_TIME_MS - (Date.now() - dataUpdatedAt); + pendingRefetchRef.current = setTimeout( + () => { + pendingRefetchRef.current = null; + void refetch(); + }, + Math.max(0, msUntilStale), + ); + } + }; + + return { githubStatus, hasHovered, onMouseEnter }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 92a7c84ac2e..e9374ffc89e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -3,12 +3,12 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getGitHubStatusQueryPolicy } from "renderer/lib/githubQueryPolicy"; +import { useHoverGitHubStatus } from "renderer/lib/githubQueryPolicy"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { WorkspaceRunIndicator } from "renderer/screens/main/components/WorkspaceRunIndicator"; @@ -67,7 +67,15 @@ export function WorkspaceListItem({ const isBranchWorkspace = type === "branch"; const navigate = useNavigate(); const matchRoute = useMatchRoute(); - const [hasHovered, setHasHovered] = useState(false); + const { + githubStatus, + hasHovered, + onMouseEnter: onGithubMouseEnter, + } = useHoverGitHubStatus({ + workspaceId: id, + surface: "workspace-list-item", + isWorktree: type === "worktree", + }); const rename = useWorkspaceRename(id, name, branch); const workspaceStatus = useTabsStore((state) => { function* paneStatuses() { @@ -147,20 +155,6 @@ export function WorkspaceListItem({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - const githubStatusQueryPolicy = getGitHubStatusQueryPolicy( - "workspace-list-item", - { - hasWorkspaceId: !!id, - isActive: hasHovered && type === "worktree", - }, - ); - - const { data: githubStatus } = - electronTrpc.workspaces.getGitHubStatus.useQuery( - { workspaceId: id }, - githubStatusQueryPolicy, - ); - const { status: localChanges } = useGitChangesStatus({ worktreePath, enabled: hasHovered && !!worktreePath, @@ -173,7 +167,6 @@ export function WorkspaceListItem({ { enabled: isBranchWorkspace, staleTime: GITHUB_STATUS_STALE_TIME, - refetchInterval: hasHovered ? GITHUB_STATUS_STALE_TIME : false, }, ); @@ -231,7 +224,7 @@ export function WorkspaceListItem({ }; const handleMouseEnter = () => { - if (!hasHovered) setHasHovered(true); + onGithubMouseEnter(); if (isBranchWorkspace) void refetchAheadBehind(); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts index b00bf489828..8a854f3bde4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts @@ -1,5 +1,5 @@ export const WORKSPACE_DND_TYPE = "WORKSPACE"; export const MAX_KEYBOARD_SHORTCUT_INDEX = 9; -export const GITHUB_STATUS_STALE_TIME = 30_000; +export const GITHUB_STATUS_STALE_TIME = 10_000; export const HOVER_CARD_OPEN_DELAY = 400; export const HOVER_CARD_CLOSE_DELAY = 100; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 665214e6097..77661eed921 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -91,13 +91,11 @@ export function ChangesView({ const worktreePath = workspace?.worktreePath; const projectId = workspace?.projectId; const activeTab = useChangesStore((s) => s.activeTab); - const isReviewTabActive = isActive && activeTab === "review"; const githubStatusQueryPolicy = getGitHubStatusQueryPolicy( "changes-sidebar", { hasWorkspaceId: !!workspaceId, isActive, - isReviewTabActive, }, ); @@ -262,7 +260,6 @@ export function ChangesView({ hasWorkspaceId: !!workspaceId, hasActivePullRequest: !!activePullRequest, isActive, - isReviewTabActive, }); const refreshTimerRef = useRef | null>(null); const pendingRefreshRef = useRef({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx index ac377143963..509e4608ef9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx @@ -7,7 +7,6 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useState } from "react"; import { LuArrowRight, LuExternalLink, @@ -16,7 +15,7 @@ import { LuRotateCw, } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getGitHubStatusQueryPolicy } from "renderer/lib/githubQueryPolicy"; +import { useHoverGitHubStatus } from "renderer/lib/githubQueryPolicy"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import { STROKE_WIDTH } from "../../WorkspaceSidebar/constants"; import { DeleteWorkspaceDialog } from "../../WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; @@ -38,7 +37,12 @@ export function WorkspaceRow({ isOpening, }: WorkspaceRowProps) { const isBranch = workspace.type === "branch"; - const [hasHovered, setHasHovered] = useState(false); + const { githubStatus, onMouseEnter: onGithubMouseEnter } = + useHoverGitHubStatus({ + workspaceId: workspace.workspaceId, + surface: "workspace-row", + isWorktree: workspace.type === "worktree", + }); const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); const openFileInEditor = electronTrpc.external.openFileInEditor.useMutation({ @@ -54,18 +58,6 @@ export function WorkspaceRow({ }); } }; - const githubStatusQueryPolicy = getGitHubStatusQueryPolicy("workspace-row", { - hasWorkspaceId: !!workspace.workspaceId, - isActive: - hasHovered && workspace.type === "worktree" && !!workspace.workspaceId, - }); - - // Lazy-load GitHub status on hover to avoid N+1 queries - const { data: githubStatus } = - electronTrpc.workspaces.getGitHubStatus.useQuery( - { workspaceId: workspace.workspaceId ?? "" }, - githubStatusQueryPolicy, - ); const pr = githubStatus?.pr; const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); @@ -87,7 +79,7 @@ export function WorkspaceRow({ type="button" onClick={handleClick} disabled={isOpening} - onMouseEnter={() => !hasHovered && setHasHovered(true)} + onMouseEnter={onGithubMouseEnter} className={cn( "flex items-center gap-3 w-full px-4 py-2 group text-left", "hover:bg-background/50 transition-colors",