diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 8849e43be4d..789dc30fecd 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -21,15 +21,6 @@ export const createQueryProcedures = () => { return workspace; }), - getAll: publicProcedure.query(() => { - return localDb - .select() - .from(workspaces) - .where(isNull(workspaces.deletingAt)) - .all() - .sort((a, b) => a.tabOrder - b.tabOrder); - }), - getAllGrouped: publicProcedure.query(() => { const activeProjects = localDb .select() diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 6ba9f0bf059..63a2be69712 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -13,7 +13,7 @@ import { createStatusProcedures } from "./procedures/status"; * Procedures are organized into logical groups: * - create: create, createBranchWorkspace, openWorktree * - delete: delete, close, canDelete - * - query: get, getAll, getAllGrouped, getActive + * - query: get, getAllGrouped, getActive * - branch: getBranches, switchBranchWorkspace * - git-status: refreshGitStatus, getGitHubStatus, getWorktreeInfo, getWorktreesByProject * - status: setActive, reorder, update, setUnread diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts index 27deddecacc..10e385eb91b 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -6,11 +6,6 @@ type CloseContext = { >["workspaces"]["getAllGrouped"]["getData"] extends () => infer R ? R : never; - previousAll: ReturnType< - typeof trpc.useUtils - >["workspaces"]["getAll"]["getData"] extends () => infer R - ? R - : never; previousActive: ReturnType< typeof trpc.useUtils >["workspaces"]["getActive"]["getData"] extends () => infer R @@ -19,9 +14,8 @@ type CloseContext = { }; /** - * Mutation hook for closing a workspace without deleting the worktree - * Uses optimistic updates to immediately remove workspace from UI, - * then performs actual close in background. + * Closes a workspace without deleting the worktree. + * Uses getAllGrouped as source of truth since it's always cached by the sidebar. */ export function useCloseWorkspace( options?: Parameters[0], @@ -31,19 +25,14 @@ export function useCloseWorkspace( return trpc.workspaces.close.useMutation({ ...options, onMutate: async ({ id }) => { - // Cancel outgoing refetches to avoid overwriting optimistic update await Promise.all([ - utils.workspaces.getAll.cancel(), utils.workspaces.getAllGrouped.cancel(), utils.workspaces.getActive.cancel(), ]); - // Snapshot previous values for rollback const previousGrouped = utils.workspaces.getAllGrouped.getData(); - const previousAll = utils.workspaces.getAll.getData(); const previousActive = utils.workspaces.getActive.getData(); - // Optimistically remove workspace from getAllGrouped cache if (previousGrouped) { utils.workspaces.getAllGrouped.setData( undefined, @@ -56,106 +45,77 @@ export function useCloseWorkspace( ); } - // Optimistically remove workspace from getAll cache - if (previousAll) { - utils.workspaces.getAll.setData( - undefined, - previousAll.filter((w) => w.id !== id), - ); - } - - // Switch to next workspace to prevent "no workspace" flash + // Prevent "no workspace" flash by switching to next workspace if (previousActive?.id === id) { - const remainingWorkspaces = previousAll - ?.filter((w) => w.id !== id) - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + const remainingWorkspaces = previousGrouped + ?.flatMap((g) => + g.workspaces + .filter((w) => w.id !== id) + .map((w) => ({ workspace: w, project: g.project })), + ) + .sort((a, b) => b.workspace.lastOpenedAt - a.workspace.lastOpenedAt); if (remainingWorkspaces && remainingWorkspaces.length > 0) { - // Find a workspace with full data available in previousGrouped - let selectedWorkspace = null; - let projectGroup = null; - let workspaceFromGrouped = null; - - for (const candidate of remainingWorkspaces) { - const group = previousGrouped?.find((g) => - g.workspaces.some((w) => w.id === candidate.id), - ); - if (group) { - selectedWorkspace = candidate; - projectGroup = group; - workspaceFromGrouped = group.workspaces.find( - (w) => w.id === candidate.id, - ); - break; - } - } + const { workspace: nextWorkspace, project } = remainingWorkspaces[0]; - if (selectedWorkspace && projectGroup && workspaceFromGrouped) { - const worktreeData = - workspaceFromGrouped.type === "worktree" - ? { - branch: selectedWorkspace.branch, - baseBranch: null, - gitStatus: { - branch: selectedWorkspace.branch, - needsRebase: false, - lastRefreshed: Date.now(), - }, - } - : null; + const worktreeData = + nextWorkspace.type === "worktree" + ? { + branch: nextWorkspace.branch, + baseBranch: null, + gitStatus: { + branch: nextWorkspace.branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, + } + : null; - utils.workspaces.getActive.setData(undefined, { - ...selectedWorkspace, - type: workspaceFromGrouped.type, - worktreePath: workspaceFromGrouped.worktreePath, - project: { - id: projectGroup.project.id, - name: projectGroup.project.name, - mainRepoPath: projectGroup.project.mainRepoPath, - }, - worktree: worktreeData, - }); - } else { - // Fallback: set minimal data to prevent StartView flash (refetch will populate full data) - const fallback = remainingWorkspaces[0]; - utils.workspaces.getActive.setData(undefined, { - ...fallback, - type: fallback.type === "branch" ? "branch" : "worktree", - worktreePath: "", - project: null, - worktree: null, - }); - } + utils.workspaces.getActive.setData(undefined, { + id: nextWorkspace.id, + projectId: nextWorkspace.projectId, + worktreeId: nextWorkspace.worktreeId, + branch: nextWorkspace.branch, + name: nextWorkspace.name, + tabOrder: nextWorkspace.tabOrder, + createdAt: nextWorkspace.createdAt, + updatedAt: nextWorkspace.updatedAt, + lastOpenedAt: nextWorkspace.lastOpenedAt, + isUnread: nextWorkspace.isUnread, + type: nextWorkspace.type, + worktreePath: nextWorkspace.worktreePath, + deletingAt: null, + project: { + id: project.id, + name: project.name, + mainRepoPath: project.mainRepoPath, + }, + worktree: worktreeData, + }); } else { utils.workspaces.getActive.setData(undefined, null); } } - // Return context for rollback - return { previousGrouped, previousAll, previousActive } as CloseContext; + return { previousGrouped, previousActive } as CloseContext; }, onError: (_err, _variables, context) => { - // Rollback to previous state on error if (context?.previousGrouped !== undefined) { utils.workspaces.getAllGrouped.setData( undefined, context.previousGrouped, ); } - if (context?.previousAll !== undefined) { - utils.workspaces.getAll.setData(undefined, context.previousAll); - } if (context?.previousActive !== undefined) { utils.workspaces.getActive.setData(undefined, context.previousActive); } }, onSuccess: async (...args) => { - // Invalidate to ensure consistency with backend state - await utils.workspaces.invalidate(); - // Invalidate project queries since close updates project metadata + // Only invalidate getAllGrouped, not getActive - we already set it optimistically + // and invalidating it causes a brief flash while refetching + await utils.workspaces.getAllGrouped.invalidate(); + // Close updates project metadata (lastOpenedAt, etc.) await utils.projects.getRecents.invalidate(); - - // Call user's onSuccess if provided await options?.onSuccess?.(...args); }, }); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts index bfa21e95f26..b149655c826 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -6,11 +6,6 @@ type DeleteContext = { >["workspaces"]["getAllGrouped"]["getData"] extends () => infer R ? R : never; - previousAll: ReturnType< - typeof trpc.useUtils - >["workspaces"]["getAll"]["getData"] extends () => infer R - ? R - : never; previousActive: ReturnType< typeof trpc.useUtils >["workspaces"]["getActive"]["getData"] extends () => infer R @@ -19,8 +14,8 @@ type DeleteContext = { }; /** - * Mutation hook for deleting a workspace with optimistic updates. - * Server marks `deletingAt` immediately so refetches stay correct during slow git operations. + * Deletes a workspace with optimistic updates. + * Uses getAllGrouped as source of truth since it's always cached by the sidebar. */ export function useDeleteWorkspace( options?: Parameters[0], @@ -31,13 +26,11 @@ export function useDeleteWorkspace( ...options, onMutate: async ({ id }) => { await Promise.all([ - utils.workspaces.getAll.cancel(), utils.workspaces.getAllGrouped.cancel(), utils.workspaces.getActive.cancel(), ]); const previousGrouped = utils.workspaces.getAllGrouped.getData(); - const previousAll = utils.workspaces.getAll.getData(); const previousActive = utils.workspaces.getActive.getData(); if (previousGrouped) { @@ -52,84 +45,64 @@ export function useDeleteWorkspace( ); } - if (previousAll) { - utils.workspaces.getAll.setData( - undefined, - previousAll.filter((w) => w.id !== id), - ); - } - - // Switch to next workspace to prevent "no workspace" flash + // Prevent "no workspace" flash by switching to next workspace if (previousActive?.id === id) { - const remainingWorkspaces = previousAll - ?.filter((w) => w.id !== id) - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + const remainingWorkspaces = previousGrouped + ?.flatMap((g) => + g.workspaces + .filter((w) => w.id !== id) + .map((w) => ({ workspace: w, project: g.project })), + ) + .sort((a, b) => b.workspace.lastOpenedAt - a.workspace.lastOpenedAt); if (remainingWorkspaces && remainingWorkspaces.length > 0) { - // Find a workspace with full data available in previousGrouped - let selectedWorkspace = null; - let projectGroup = null; - let workspaceFromGrouped = null; + const { workspace: nextWorkspace, project } = remainingWorkspaces[0]; - for (const candidate of remainingWorkspaces) { - const group = previousGrouped?.find((g) => - g.workspaces.some((w) => w.id === candidate.id), - ); - if (group) { - selectedWorkspace = candidate; - projectGroup = group; - workspaceFromGrouped = group.workspaces.find( - (w) => w.id === candidate.id, - ); - break; - } - } + const worktreeData = + nextWorkspace.type === "worktree" + ? { + branch: nextWorkspace.branch, + baseBranch: null, + gitStatus: { + branch: nextWorkspace.branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, + } + : null; - if (selectedWorkspace && projectGroup && workspaceFromGrouped) { - const worktreeData = - workspaceFromGrouped.type === "worktree" - ? { - branch: selectedWorkspace.branch, - baseBranch: null, - gitStatus: { - branch: selectedWorkspace.branch, - needsRebase: false, - lastRefreshed: Date.now(), - }, - } - : null; - - utils.workspaces.getActive.setData(undefined, { - ...selectedWorkspace, - type: workspaceFromGrouped.type, - worktreePath: workspaceFromGrouped.worktreePath, - project: { - id: projectGroup.project.id, - name: projectGroup.project.name, - mainRepoPath: projectGroup.project.mainRepoPath, - }, - worktree: worktreeData, - }); - } else { - // Fallback: set minimal data to prevent StartView flash (refetch will populate full data) - const fallback = remainingWorkspaces[0]; - utils.workspaces.getActive.setData(undefined, { - ...fallback, - type: fallback.type === "branch" ? "branch" : "worktree", - worktreePath: "", - project: null, - worktree: null, - }); - } + utils.workspaces.getActive.setData(undefined, { + id: nextWorkspace.id, + projectId: nextWorkspace.projectId, + worktreeId: nextWorkspace.worktreeId, + branch: nextWorkspace.branch, + name: nextWorkspace.name, + tabOrder: nextWorkspace.tabOrder, + createdAt: nextWorkspace.createdAt, + updatedAt: nextWorkspace.updatedAt, + lastOpenedAt: nextWorkspace.lastOpenedAt, + isUnread: nextWorkspace.isUnread, + type: nextWorkspace.type, + worktreePath: nextWorkspace.worktreePath, + deletingAt: null, + project: { + id: project.id, + name: project.name, + mainRepoPath: project.mainRepoPath, + }, + worktree: worktreeData, + }); } else { utils.workspaces.getActive.setData(undefined, null); } } - return { previousGrouped, previousAll, previousActive } as DeleteContext; + return { previousGrouped, previousActive } as DeleteContext; }, onSettled: async (...args) => { - await utils.workspaces.invalidate(); + // Only invalidate getAllGrouped, not getActive - we already set it optimistically + // and invalidating it causes a brief flash while refetching + await utils.workspaces.getAllGrouped.invalidate(); await options?.onSettled?.(...args); }, onSuccess: async (...args) => { @@ -142,9 +115,6 @@ export function useDeleteWorkspace( context.previousGrouped, ); } - if (context?.previousAll !== undefined) { - utils.workspaces.getAll.setData(undefined, context.previousAll); - } if (context?.previousActive !== undefined) { utils.workspaces.getActive.setData(undefined, context.previousActive); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts b/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts index 35bbe675ee4..169848c004d 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts @@ -1,9 +1,5 @@ import { trpc } from "renderer/lib/trpc"; -/** - * Mutation hook for reordering workspaces - * Automatically invalidates workspace queries on success - */ export function useReorderWorkspaces( options?: Parameters[0], ) { @@ -12,7 +8,6 @@ export function useReorderWorkspaces( return trpc.workspaces.reorder.useMutation({ ...options, onSuccess: async (...args) => { - await utils.workspaces.getAll.invalidate(); await utils.workspaces.getAllGrouped.invalidate(); await options?.onSuccess?.(...args); }, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts index debb3a08b2a..ba17dea4a84 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -2,9 +2,8 @@ import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; /** - * Mutation hook for setting the active workspace - * Automatically invalidates getActive and getAll queries on success - * Shows undo toast if workspace was marked as unread (auto-cleared on switch) + * Sets the active workspace. + * Shows undo toast if workspace was marked as unread (auto-cleared on switch). */ export function useSetActiveWorkspace( options?: Parameters[0], @@ -33,10 +32,8 @@ export function useSetActiveWorkspace( options?.onError?.(error, variables, context, meta); }, onSuccess: async (data, variables, ...rest) => { - // Auto-invalidate active workspace and all workspaces queries await Promise.all([ utils.workspaces.getActive.invalidate(), - utils.workspaces.getAll.invalidate(), utils.workspaces.getAllGrouped.invalidate(), ]); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index b9498c83797..8f2c27301bc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -14,7 +14,12 @@ export interface MergedWorkspaceGroup { export function usePortsData() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: allWorkspaces } = trpc.workspaces.getAll.useQuery(); + // Use getAllGrouped (always cached) instead of getAll to avoid cache sync issues + const { data: groupedWorkspaces } = trpc.workspaces.getAllGrouped.useQuery(); + const allWorkspaces = useMemo( + () => groupedWorkspaces?.flatMap((g) => g.workspaces) ?? [], + [groupedWorkspaces], + ); const ports = usePortsStore((s) => s.ports); const setPorts = usePortsStore((s) => s.setPorts); const addPort = usePortsStore((s) => s.addPort); @@ -53,7 +58,6 @@ export function usePortsData() { }); const workspaceNames = useMemo(() => { - if (!allWorkspaces) return {}; return allWorkspaces.reduce( (acc, ws) => { acc[ws.id] = ws.name;