diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 731a3acecef4..24acadfc03f4 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -59,6 +59,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext"; import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; +import { useUiStateStore } from "~/uiStateStore"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -453,7 +454,10 @@ export function PullRequestDetailPanel({ compensationRef.current = null; if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); - const [mergeMethod, setMergeMethod] = useState("merge"); + // The last method the reader picked, remembered across pull requests and sessions. A + // repository that refuses it still falls back below, so nothing here can force a bad merge. + const mergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); + const setMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); const [confirmation, setConfirmation] = useState<{ readonly open: boolean; readonly action: "merge" | "close" | "enable-auto-merge"; @@ -1418,9 +1422,7 @@ export function PullRequestDetailPanel({ {showsDraftToggle ? : null} - setMergeMethod(method as PullRequestMergeMethod) - } + onValueChange={setMergeMethod} > {allowedMergeMethods.map((method) => ( diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 304502873539..8f7c079c69db 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, type PullRequestMergeMethod } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -12,6 +12,7 @@ import { reorderProjects, resolveProjectExpanded, setDefaultAdvertisedEndpointKey, + setPullRequestMergeMethod, setProjectExpanded, setThreadChangedFilesExpanded, type UiState, @@ -24,6 +25,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", ...overrides, }; } @@ -144,6 +146,24 @@ describe("uiStateStore pure functions", () => { defaultAdvertisedEndpointKey: null, }); }); + + it("stores the last merge method a reader picked", () => { + const next = setPullRequestMergeMethod(makeUiState(), "squash"); + + expect(next.pullRequestMergeMethod).toBe("squash"); + expect(setPullRequestMergeMethod(next, "squash")).toBe(next); + expect(setPullRequestMergeMethod(next, "rebase").pullRequestMergeMethod).toBe("rebase"); + }); + + it("ignores unknown merge methods", () => { + const state = makeUiState({ pullRequestMergeMethod: "squash" }); + + expect(setPullRequestMergeMethod(state, "fast-forward" as PullRequestMergeMethod)).toBe(state); + expect(parsePersistedState({ pullRequestMergeMethod: "fast-forward" })).toMatchObject({ + pullRequestMergeMethod: "merge", + }); + expect(parsePersistedState({})).toMatchObject({ pullRequestMergeMethod: "merge" }); + }); }); describe("parsePersistedState", () => { @@ -183,6 +203,7 @@ describe("parsePersistedState", () => { "turn-2": true, }, }, + pullRequestMergeMethod: "merge", }); }); @@ -280,6 +301,7 @@ describe("uiStateStore persistence", () => { }, }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + pullRequestMergeMethod: "squash", }); persistState(state); @@ -303,6 +325,7 @@ describe("uiStateStore persistence", () => { "turn-2": true, }, }, + pullRequestMergeMethod: "squash", }); expect(parsePersistedState(persisted)).toEqual({ ...state, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5d744d540a5a..182f4c5139d2 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,9 +1,15 @@ +import type { PullRequestMergeMethod } from "@t3tools/contracts"; import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1; +const MERGE_METHODS = [ + "merge", + "squash", + "rebase", +] as const satisfies readonly PullRequestMergeMethod[]; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", "t3code:renderer-state:v7", @@ -27,6 +33,7 @@ export interface PersistedUiState { defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; threadChangedFilesExpandedById?: Record>; + pullRequestMergeMethod?: string; } export interface UiProjectState { @@ -43,7 +50,12 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiPullRequestState { + pullRequestMergeMethod: PullRequestMergeMethod; +} + +export interface UiState + extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} const initialState: UiState = { projectExpandedById: {}, @@ -51,6 +63,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -135,6 +148,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, + pullRequestMergeMethod: + MERGE_METHODS.find((method) => method === parsed.pullRequestMergeMethod) ?? + initialState.pullRequestMergeMethod, }; } @@ -207,6 +223,7 @@ export function persistState(state: UiState): void { defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, + pullRequestMergeMethod: state.pullRequestMergeMethod, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -304,6 +321,16 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { + if (!MERGE_METHODS.includes(method) || state.pullRequestMergeMethod === method) { + return state; + } + return { + ...state, + pullRequestMergeMethod: method, + }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -386,6 +413,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -404,6 +432,7 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>