Add manual refresh button to git diff controls (#1216)

* 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.
This commit is contained in:
Mohamed Boudra
2026-05-29 14:23:16 +08:00
committed by GitHub
parent 04fb0f9b82
commit 14d176a4e2
8 changed files with 284 additions and 1 deletions
@@ -170,6 +170,46 @@ describe("checkout-git-actions-store", () => {
).toBe("idle"); ).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 () => { it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = { const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({ checkoutGithubSetAutoMerge: vi.fn(async () => ({
+17
View File
@@ -28,6 +28,7 @@ export type CheckoutGitAsyncActionId =
| "pull" | "pull"
| "push" | "push"
| "pull-and-push" | "pull-and-push"
| "refresh"
| "create-pr" | "create-pr"
| "merge-pr-squash" | "merge-pr-squash"
| "merge-pr-merge" | "merge-pr-merge"
@@ -236,6 +237,7 @@ interface CheckoutGitActionsStoreState {
pull: (params: { serverId: string; cwd: string }) => Promise<void>; pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>; push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>; pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>; createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: { mergePr: (params: {
serverId: string; serverId: string;
@@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
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 }) => { pullAndPush: async ({ serverId, cwd }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
+81 -1
View File
@@ -25,7 +25,8 @@ import {
type ViewStyle, type ViewStyle,
type TextStyle, type TextStyle,
} from "react-native"; } 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 { useIsCompactFormFactor } from "@/constants/layout";
import { import {
AlignJustify, AlignJustify,
@@ -41,6 +42,7 @@ import {
ListChevronsUpDown, ListChevronsUpDown,
Pilcrow, Pilcrow,
RefreshCcw, RefreshCcw,
RotateCw,
Upload, Upload,
WrapText, WrapText,
} from "lucide-react-native"; } from "lucide-react-native";
@@ -78,6 +80,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button"; import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions"; 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 { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; 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 (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Refresh</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItem = type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number }; | { type: "body"; file: ParsedDiffFile; fileIndex: number };
@@ -1559,6 +1603,29 @@ export function GitDiffPane({
[controlSurfaceColor], [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 { const {
status, status,
isLoading: isStatusLoading, isLoading: isStatusLoading,
@@ -2088,6 +2155,13 @@ export function GitDiffPane({
onToggleExpandAll={handleToggleExpandAll} onToggleExpandAll={handleToggleExpandAll}
/> />
) : null} ) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
</View> </View>
</View> </View>
</View> </View>
@@ -2216,6 +2290,12 @@ const styles = StyleSheet.create((theme) => ({
toggleButtonSelected: { toggleButtonSelected: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: { expandAllButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
+14
View File
@@ -31,6 +31,7 @@ import type {
CheckoutMergeFromBaseResponse, CheckoutMergeFromBaseResponse,
CheckoutPullResponse, CheckoutPullResponse,
CheckoutPushResponse, CheckoutPushResponse,
CheckoutRefreshResponse,
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrMergeResponse, CheckoutPrMergeResponse,
CheckoutPrMergeMethod, CheckoutPrMergeMethod,
@@ -288,6 +289,7 @@ type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutRefreshPayload = CheckoutRefreshResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"]; type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"]; type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"];
@@ -2950,6 +2952,18 @@ export class DaemonClient {
}); });
} }
async checkoutRefresh(cwd: string, requestId?: string): Promise<CheckoutRefreshPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout.refresh.request",
cwd,
},
responseType: "checkout.refresh.response",
timeout: 60000,
});
}
async checkoutPrCreate( async checkoutPrCreate(
cwd: string, cwd: string,
input: { title?: string; body?: string; baseRef?: string }, input: { title?: string; body?: string; baseRef?: string },
+22
View File
@@ -1382,6 +1382,12 @@ export const CheckoutPushRequestSchema = z.object({
requestId: z.string(), 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({ export const CheckoutPrCreateRequestSchema = z.object({
type: z.literal("checkout_pr_create_request"), type: z.literal("checkout_pr_create_request"),
cwd: z.string(), cwd: z.string(),
@@ -1887,6 +1893,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutMergeFromBaseRequestSchema, CheckoutMergeFromBaseRequestSchema,
CheckoutPullRequestSchema, CheckoutPullRequestSchema,
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutRefreshRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema, CheckoutPrMergeRequestSchema,
CheckoutGithubSetAutoMergeRequestSchema, CheckoutGithubSetAutoMergeRequestSchema,
@@ -2121,6 +2128,8 @@ export const ServerInfoStatusPayloadSchema = z
"terminal-restore-modes": z.boolean().optional(), "terminal-restore-modes": z.boolean().optional(),
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: z.boolean().optional(), rewind: z.boolean().optional(),
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
checkoutRefresh: z.boolean().optional(),
}) })
.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({ export const CheckoutPrCreateResponseSchema = z.object({
type: z.literal("checkout_pr_create_response"), type: z.literal("checkout_pr_create_response"),
payload: z.object({ payload: z.object({
@@ -3687,6 +3706,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutMergeFromBaseResponseSchema, CheckoutMergeFromBaseResponseSchema,
CheckoutPullResponseSchema, CheckoutPullResponseSchema,
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutRefreshResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema, CheckoutPrMergeResponseSchema,
CheckoutGithubSetAutoMergeResponseSchema, CheckoutGithubSetAutoMergeResponseSchema,
@@ -3949,6 +3969,8 @@ export type CheckoutPullRequest = z.infer<typeof CheckoutPullRequestSchema>;
export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>; export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>;
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>; export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>; export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutRefreshRequest = z.infer<typeof CheckoutRefreshRequestSchema>;
export type CheckoutRefreshResponse = z.infer<typeof CheckoutRefreshResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>; export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>; export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>; export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
@@ -63,6 +63,7 @@ interface SessionHandlerInternals {
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>; handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>; handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>; handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutRefreshRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>; handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>; describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
@@ -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", () => { describe("session checkout status handling", () => {
test("returns checkout status from the workspace git service snapshot", async () => { test("returns checkout status from the workspace git service snapshot", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
+37
View File
@@ -2033,6 +2033,8 @@ export class Session {
return this.handleCheckoutPullRequest(msg); return this.handleCheckoutPullRequest(msg);
case "checkout_push_request": case "checkout_push_request":
return this.handleCheckoutPushRequest(msg); return this.handleCheckoutPushRequest(msg);
case "checkout.refresh.request":
return this.handleCheckoutRefreshRequest(msg);
case "checkout_pr_create_request": case "checkout_pr_create_request":
return this.handleCheckoutPrCreateRequest(msg); return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request": case "checkout_pr_merge_request":
@@ -5297,6 +5299,41 @@ export class Session {
} }
} }
private async handleCheckoutRefreshRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.refresh.request" }>,
): Promise<void> {
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( private async handleCheckoutPrCreateRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
): Promise<void> { ): Promise<void> {
@@ -1049,6 +1049,8 @@ export class VoiceAssistantWebSocketServer {
"terminal-restore-modes": true, "terminal-restore-modes": true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: true, rewind: true,
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
checkoutRefresh: true,
}, },
}; };
} }