diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx new file mode 100644 index 0000000000000..bc999e3e1cb4c --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -0,0 +1,142 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +type InputProps = { + accessibilityLabel: string; + value: string; + onChangeText: (value: string) => void; +}; +type ActionProps = { label: string; disabled: boolean; onPress: () => void }; +const state = vi.hoisted(() => ({ + refName: null as string | null, + branch: null as string | null, + inputs: new Map(), + actions: new Map(), + worktrees: [] as Array<{ baseBranch: string; newBranch: string }>, +})); + +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({ goBack() {} }) })); +vi.mock("react-native", () => ({ + Platform: { OS: "android" }, + useWindowDimensions: () => ({ height: 800 }), + View: ({ children }: { children: ReactNode }) => children, + ScrollView: ({ children }: { children: ReactNode }) => children, + Pressable: () => null, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock("../../../components/ui/AndroidScreenHeader", () => ({ AndroidSheetHeader: () => null })); +vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); +vi.mock("../../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../../components/ui/AppText", () => ({ + AppText: ({ children }: { children: ReactNode }) => children, + AppTextInput: (props: InputProps) => { + state.inputs.set(props.accessibilityLabel, props); + return null; + }, +})); +vi.mock("../../../state/query", () => ({ + useEnvironmentQuery: () => ({ + data: state.refName === null ? undefined : { refName: state.refName }, + }), +})); +vi.mock("../../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { environmentId: "environment", branch: state.branch }, + }), +})); +vi.mock("../../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ + selectedThreadCwd: "/repo", + selectedThreadWorktreePath: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-state", () => ({ + useSelectedThreadGitState: () => ({ + selectedThreadBranches: [], + selectedThreadBranchesLoading: false, + gitOperationLabel: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-actions", () => ({ + useSelectedThreadGitActions: () => ({ + canChangeThreadBranch: true, + onCreateSelectedThreadWorktree: async (input: { baseBranch: string; newBranch: string }) => { + state.worktrees.push(input); + return null; + }, + }), +})); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("./gitSheetComponents", () => ({ + SheetActionButton: (props: ActionProps) => { + state.actions.set(props.label, props); + return null; + }, +})); + +import { GitBranchesSheet } from "./GitBranchesSheet"; + +function setup(branch: string | null = null) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + state.refName = null; + state.branch = branch; + state.inputs.clear(); + state.actions.clear(); + state.worktrees = []; + const root = createRoot(document.createElement("div")); + const render = () => + act(() => { + root.render( + , + ); + }); + render(); + return { + render, + edit(label: string, value: string) { + act(() => state.inputs.get(label)!.onChangeText(value)); + }, + create() { + act(() => state.actions.get("Create worktree")!.onPress()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it.each([null, "stale-branch"])( + "uses the loaded checkout branch when thread metadata is %s", + (branch) => { + using screen = setup(branch); + screen.edit("Worktree branch name", "feature/new-worktree"); + state.refName = "release/stable"; + screen.render(); + screen.create(); + expect(state.worktrees).toEqual([ + { baseBranch: "release/stable", newBranch: "feature/new-worktree" }, + ]); + }, +); + +it.each(["custom-base", ""])("preserves an edited base %j when status arrives", (base) => { + using screen = setup(); + screen.edit("Worktree base branch", base); + screen.edit("Worktree branch name", "feature/new-worktree"); + state.refName = "release/stable"; + screen.render(); + expect(state.inputs.get("Worktree base branch")!.value).toBe(base); + expect(state.actions.get("Create worktree")!.disabled).toBe(base === ""); + screen.create(); + expect(state.worktrees).toEqual( + base === "" ? [] : [{ baseBranch: base, newBranch: "feature/new-worktree" }], + ); +}); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index dcaf96818bfe0..61c7a7ad6e4ab 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -49,9 +49,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const busy = gitState.gitOperationLabel !== null; const [newBranchName, setNewBranchName] = useState(""); - const [worktreeBaseBranch, setWorktreeBaseBranch] = useState( - currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel, - ); + const [worktreeBaseBranchOverride, setWorktreeBaseBranchOverride] = useState(null); + const worktreeBaseBranch = + worktreeBaseBranchOverride ?? + (currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel); const [worktreeBranchName, setWorktreeBranchName] = useState(""); const disabledExistingBranchNames: Array = []; @@ -141,7 +142,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null}