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:
@@ -63,6 +63,7 @@ interface SessionHandlerInternals {
|
||||
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutRefreshRequest(params: unknown): Promise<unknown>;
|
||||
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
|
||||
describeWorkspaceRecord(...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", () => {
|
||||
test("returns checkout status from the workspace git service snapshot", async () => {
|
||||
const messages: unknown[] = [];
|
||||
|
||||
@@ -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<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(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user