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:
@@ -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 () => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user