From 14d176a4e23fe5ba9eb80cc84f2c5b0cf9be2dd4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 14:23:16 +0800 Subject: [PATCH] Add manual refresh button to git diff controls (#1216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(git): add manual refresh button to diff controls Adds a checkout_refresh RPC that forces a hard re-read of the git and GitHub snapshot plus the diff, bypassing polling. The diff controls now show a refresh button (feature-gated) that triggers it and surfaces errors via a toast — an escape hatch when the polled state goes stale. * refactor(git): address review — dotted RPC name + withUnistyles - Rename checkout_refresh RPC to the dotted convention (checkout.refresh.request/response) per docs/rpc-namespacing.md. - Replace the banned useUnistyles() call in DiffRefreshButton with withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md. * refactor(git): move refresh into the checkout actions store Per the feature audit: the manual refresh was the one checkout action whose UI logic lived inline in the component instead of alongside its siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into the store so there's one home for "UI triggers a checkout RPC" — it now reuses runCheckoutAction's pending/success status and query invalidation. The component just reads status and shows an error toast. * fix(git): size refresh loader to the icon to stop layout shift ActivityIndicator size="small" renders ~20px regardless of platform, wider than the 14px refresh icon, so swapping to it grew the button and shifted the controls row. Use SyncedLoader at the same iconSize — it renders into a size×size box, so both states share one footprint. * fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner) Mirror the file-explorer pane's refresh button exactly: RotateCw icon, LoadingSpinner while refreshing, both centered in a fixed icon-sized box so the spinner can't grow the control or shift the row. Replaces the ad-hoc RefreshCw + SyncedLoader pairing. --- packages/app/src/git/actions-store.test.ts | 40 +++++++++ packages/app/src/git/actions-store.ts | 17 ++++ packages/app/src/git/diff-pane.tsx | 82 ++++++++++++++++++- packages/client/src/daemon-client.ts | 14 ++++ packages/protocol/src/messages.ts | 22 +++++ packages/server/src/server/session.test.ts | 71 ++++++++++++++++ packages/server/src/server/session.ts | 37 +++++++++ .../server/src/server/websocket-server.ts | 2 + 8 files changed, 284 insertions(+), 1 deletion(-) diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index a5e1d12ed..b42e01da4 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -170,6 +170,46 @@ describe("checkout-git-actions-store", () => { ).toBe("idle"); }); + it("refreshes git and GitHub state and reports success", async () => { + const client = { + checkoutRefresh: vi.fn(async () => ({ success: true, error: null })), + }; + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); + + await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd }); + + expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd); + expect( + useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), + ).toBe("success"); + }); + + it("surfaces a refresh error and returns to idle", async () => { + const client = { + checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })), + }; + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); + + await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow( + "not a git repository", + ); + expect( + useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), + ).toBe("idle"); + }); + it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => { const client = { checkoutGithubSetAutoMerge: vi.fn(async () => ({ diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 09f80ff3c..a5056a669 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -28,6 +28,7 @@ export type CheckoutGitAsyncActionId = | "pull" | "push" | "pull-and-push" + | "refresh" | "create-pr" | "merge-pr-squash" | "merge-pr-merge" @@ -236,6 +237,7 @@ interface CheckoutGitActionsStoreState { pull: (params: { serverId: string; cwd: string }) => Promise; push: (params: { serverId: string; cwd: string }) => Promise; pullAndPush: (params: { serverId: string; cwd: string }) => Promise; + refresh: (params: { serverId: string; cwd: string }) => Promise; createPr: (params: { serverId: string; cwd: string }) => Promise; mergePr: (params: { serverId: string; @@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create() }); }, + refresh: async ({ serverId, cwd }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: "refresh", + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutRefresh(cwd); + if (payload.error) { + throw new Error(payload.error.message); + } + }, + }); + }, + pullAndPush: async ({ serverId, cwd }) => { await runCheckoutAction({ serverId, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 7639442d2..9e99e3015 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -25,7 +25,8 @@ import { type ViewStyle, type TextStyle, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, @@ -41,6 +42,7 @@ import { ListChevronsUpDown, Pilcrow, RefreshCcw, + RotateCw, Upload, WrapText, } from "lucide-react-native"; @@ -78,6 +80,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { useGitActions } from "@/git/use-actions"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useToast } from "@/contexts/toast-context"; +import { useSessionStore } from "@/stores/session-store"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; @@ -1222,6 +1228,44 @@ function DiffFilesToolbar({ ); } +interface DiffRefreshButtonProps { + isRefreshing: boolean; + toggleStyle: PressableStyleFn; + onPress: () => void; +} + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) { + return ( + + + + + {isRefreshing ? ( + + ) : ( + + )} + + + + + Refresh + + + ); +} + type DiffFlatItem = | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "body"; file: ParsedDiffFile; fileIndex: number }; @@ -1559,6 +1603,29 @@ export function GitDiffPane({ [controlSurfaceColor], ); + const refreshToggleStyle = useMemo( + () => buildExpandAllButtonStyle(controlSurfaceColor), + [controlSurfaceColor], + ); + + const toast = useToast(); + const refreshSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, + ); + const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); + const isRefreshing = + useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === + "pending"; + + const handleRefresh = useCallback(() => { + if (isRefreshing) { + return; + } + void runRefresh({ serverId, cwd }).catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to refresh git state."); + }); + }, [cwd, isRefreshing, runRefresh, serverId, toast]); + const { status, isLoading: isStatusLoading, @@ -2088,6 +2155,13 @@ export function GitDiffPane({ onToggleExpandAll={handleToggleExpandAll} /> ) : null} + {refreshSupported ? ( + + ) : null} @@ -2216,6 +2290,12 @@ const styles = StyleSheet.create((theme) => ({ toggleButtonSelected: { backgroundColor: theme.colors.surface2, }, + refreshIcon: { + width: ICON_SIZE.md, + height: ICON_SIZE.md, + alignItems: "center", + justifyContent: "center", + }, expandAllButton: { flexDirection: "row", alignItems: "center", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e7ed4e057..64560f33b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -31,6 +31,7 @@ import type { CheckoutMergeFromBaseResponse, CheckoutPullResponse, CheckoutPushResponse, + CheckoutRefreshResponse, CheckoutPrCreateResponse, CheckoutPrMergeResponse, CheckoutPrMergeMethod, @@ -288,6 +289,7 @@ type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; +type CheckoutRefreshPayload = CheckoutRefreshResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"]; type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"]; @@ -2950,6 +2952,18 @@ export class DaemonClient { }); } + async checkoutRefresh(cwd: string, requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout.refresh.request", + cwd, + }, + responseType: "checkout.refresh.response", + timeout: 60000, + }); + } + async checkoutPrCreate( cwd: string, input: { title?: string; body?: string; baseRef?: string }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4dac9cfdc..6ac4b0eac 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1382,6 +1382,12 @@ export const CheckoutPushRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutRefreshRequestSchema = z.object({ + type: z.literal("checkout.refresh.request"), + cwd: z.string(), + requestId: z.string(), +}); + export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), cwd: z.string(), @@ -1887,6 +1893,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutMergeFromBaseRequestSchema, CheckoutPullRequestSchema, CheckoutPushRequestSchema, + CheckoutRefreshRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrMergeRequestSchema, CheckoutGithubSetAutoMergeRequestSchema, @@ -2121,6 +2128,8 @@ export const ServerInfoStatusPayloadSchema = z "terminal-restore-modes": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), + // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. + checkoutRefresh: z.boolean().optional(), }) .optional(), }) @@ -3075,6 +3084,16 @@ export const CheckoutPushResponseSchema = z.object({ }), }); +export const CheckoutRefreshResponseSchema = z.object({ + type: z.literal("checkout.refresh.response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPrCreateResponseSchema = z.object({ type: z.literal("checkout_pr_create_response"), payload: z.object({ @@ -3687,6 +3706,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutMergeFromBaseResponseSchema, CheckoutPullResponseSchema, CheckoutPushResponseSchema, + CheckoutRefreshResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrMergeResponseSchema, CheckoutGithubSetAutoMergeResponseSchema, @@ -3949,6 +3969,8 @@ export type CheckoutPullRequest = z.infer; export type CheckoutPullResponse = z.infer; export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; +export type CheckoutRefreshRequest = z.infer; +export type CheckoutRefreshResponse = z.infer; export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrCreateResponse = z.infer; export type CheckoutPrMergeRequest = z.infer; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 72bed301b..a2fbc11f5 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -63,6 +63,7 @@ interface SessionHandlerInternals { handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise; handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; + handleCheckoutRefreshRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; @@ -2554,6 +2555,76 @@ describe("session checkout pull and push handling", () => { }); }); +describe("session checkout refresh handling", () => { + test("forces a git, GitHub, and diff refresh on demand", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; + const session = createSessionForTest({ + github, + workspaceGitService, + checkoutDiffManager, + messages, + }); + + await asSessionInternals(session).handleCheckoutRefreshRequest({ + type: "checkout.refresh.request", + cwd: "/tmp/request-worktree", + requestId: "request-refresh", + }); + + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "manual-refresh", + }); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(messages).toContainEqual({ + type: "checkout.refresh.response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-refresh", + }, + }); + }); + + test("reports an error when the snapshot refresh fails", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockRejectedValue(new Error("not a git repository")), + }; + const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; + const session = createSessionForTest({ + github, + workspaceGitService, + checkoutDiffManager, + messages, + }); + + await asSessionInternals(session).handleCheckoutRefreshRequest({ + type: "checkout.refresh.request", + cwd: "/tmp/request-worktree", + requestId: "request-refresh-error", + }); + + expect(checkoutDiffManager.scheduleRefreshForCwd).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.refresh.response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { code: "UNKNOWN", message: "not a git repository" }, + requestId: "request-refresh-error", + }, + }); + }); +}); + describe("session checkout status handling", () => { test("returns checkout status from the workspace git service snapshot", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 23778fb6f..c149ac8aa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2033,6 +2033,8 @@ export class Session { return this.handleCheckoutPullRequest(msg); case "checkout_push_request": return this.handleCheckoutPushRequest(msg); + case "checkout.refresh.request": + return this.handleCheckoutRefreshRequest(msg); case "checkout_pr_create_request": return this.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": @@ -5297,6 +5299,41 @@ export class Session { } } + private async handleCheckoutRefreshRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + this.github.invalidate({ cwd }); + await this.workspaceGitService.getSnapshot(cwd, { + force: true, + includeGitHub: true, + reason: "manual-refresh", + }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.emit({ + type: "checkout.refresh.response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout.refresh.response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + private async handleCheckoutPrCreateRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index db1c78bbc..ea45becb0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1049,6 +1049,8 @@ export class VoiceAssistantWebSocketServer { "terminal-restore-modes": true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, + // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. + checkoutRefresh: true, }, }; }