mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Change a worktree workspace base from Changes (#8642)
Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it. Supersedes #6872. Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
This commit is contained in:
co-authored by
Tianyu Yang
parent
e9f897a66a
commit
3ed57e9dd7
@@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
const payload = normalizeCheckoutBaseRef(await client.getCheckoutStatus(cwd));
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
serverId: string;
|
||||
message: CheckoutStatusUpdate;
|
||||
}): void {
|
||||
const { payload } = message;
|
||||
const payload = normalizeCheckoutBaseRef(message.payload);
|
||||
const prStatus = payload.prStatus
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
@@ -88,6 +88,13 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCheckoutBaseRef<T extends CheckoutStatusPayload>(payload: T): T {
|
||||
// COMPAT(workspaceBaseBranch): hosts <= v0.11.2 only send the display ref; remove after 2027-10-14.
|
||||
return !payload.isGit || payload.baseRefExact === undefined
|
||||
? payload
|
||||
: { ...payload, baseRef: payload.baseRefExact };
|
||||
}
|
||||
|
||||
// requestId changes on every emission and carries no PR state.
|
||||
function prStatusWithoutVolatileFields(
|
||||
prStatus: CheckoutPrStatusPayload,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
AlignJustify,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
ExternalLink,
|
||||
@@ -61,6 +62,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import type { GitActions } from "@/git/policy";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { WorkspaceBaseBranchPicker } from "@/git/workspace-base-branch-picker";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
@@ -201,6 +203,7 @@ const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedAlignJustify = withUnistyles(AlignJustify);
|
||||
const ThemedArrowRight = withUnistyles(ArrowRight);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedPilcrow = withUnistyles(Pilcrow);
|
||||
const ThemedWrapText = withUnistyles(WrapText);
|
||||
@@ -470,6 +473,8 @@ interface ChangesPullRequestLinkModel extends Pick<PrHint, "forge" | "number" |
|
||||
}
|
||||
|
||||
interface ChangesRepositoryToolbarModel {
|
||||
baseRef: string | null;
|
||||
isWorktree: boolean;
|
||||
branchName: string | null;
|
||||
cwd: string;
|
||||
gitActions: GitActions | null;
|
||||
@@ -496,6 +501,8 @@ interface ChangesHeaderProps {
|
||||
}
|
||||
|
||||
interface BuildChangesHeaderModelInput {
|
||||
baseRef: string | null;
|
||||
isWorktree: boolean;
|
||||
branchName: string | null;
|
||||
committedDescription?: string;
|
||||
compact: boolean;
|
||||
@@ -519,6 +526,8 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
|
||||
} {
|
||||
return {
|
||||
repository: {
|
||||
baseRef: input.baseRef,
|
||||
isWorktree: input.isWorktree,
|
||||
branchName: input.branchName,
|
||||
cwd: input.cwd,
|
||||
gitActions: input.compact ? input.gitActions : null,
|
||||
@@ -629,6 +638,10 @@ function ChangesToolbarTrailing({ children }: { children: ReactNode }) {
|
||||
return <ToolbarControls style={styles.changesToolbarControls}>{children}</ToolbarControls>;
|
||||
}
|
||||
|
||||
function isManagedWorktree(status: { isPaseoOwnedWorktree?: boolean } | null | undefined): boolean {
|
||||
return status?.isPaseoOwnedWorktree === true;
|
||||
}
|
||||
|
||||
function ChangesRepositoryToolbar({
|
||||
compact,
|
||||
model,
|
||||
@@ -654,6 +667,18 @@ function ChangesRepositoryToolbar({
|
||||
isGitCheckout
|
||||
testID="changes-branch-switcher"
|
||||
/>
|
||||
{model.baseRef ? (
|
||||
<>
|
||||
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||
<WorkspaceBaseBranchPicker
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
cwd={model.cwd}
|
||||
baseRef={model.baseRef}
|
||||
isWorktree={model.isWorktree}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ChangesToolbarLeading>
|
||||
<ChangesToolbarTrailing>
|
||||
{model.pullRequest ? (
|
||||
@@ -1904,6 +1929,8 @@ export function ChangesSurface({
|
||||
const changesHeaderModel = useMemo(
|
||||
() =>
|
||||
buildChangesHeaderModel({
|
||||
baseRef: baseRef ?? null,
|
||||
isWorktree: isManagedWorktree(status),
|
||||
branchName: currentBranchName,
|
||||
committedDescription: committedDiffDescription,
|
||||
compact: isMobile,
|
||||
@@ -1921,6 +1948,8 @@ export function ChangesSurface({
|
||||
workspaceId,
|
||||
}),
|
||||
[
|
||||
baseRef,
|
||||
status,
|
||||
committedDiffDescription,
|
||||
currentBranchName,
|
||||
cwd,
|
||||
|
||||
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import {
|
||||
actionsWithoutPrimary,
|
||||
buildGitActions,
|
||||
isCurrentBranchBase,
|
||||
canMergeIntoLocalBase,
|
||||
type BuildGitActionsInput,
|
||||
} from "./policy";
|
||||
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
|
||||
|
||||
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
@@ -72,6 +78,7 @@ function createInput(
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
canMergeLocally: true,
|
||||
hasUncommittedChanges: false,
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
@@ -208,6 +215,81 @@ describe("git-actions-policy", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("distinguishes exact local and remote base refs and retains legacy names", () => {
|
||||
expect(isCurrentBranchBase("release", "refs/heads/release")).toBe(true);
|
||||
expect(isCurrentBranchBase("release", "refs/remotes/origin/release")).toBe(false);
|
||||
expect(isCurrentBranchBase("release", "release")).toBe(true);
|
||||
expect(isCurrentBranchBase("release", "origin/release")).toBe(true);
|
||||
expect(isCurrentBranchBase(null, "refs/heads/release")).toBe(false);
|
||||
expect(canMergeIntoLocalBase("release", "refs/heads/release")).toBe(false);
|
||||
expect(canMergeIntoLocalBase("feature", "refs/remotes/origin/release")).toBe(true);
|
||||
expect(canMergeIntoLocalBase("release", "refs/remotes/upstream/release")).toBe(false);
|
||||
});
|
||||
|
||||
it("offers local merge when the selected local base starts with origin slash", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: isCurrentBranchBase("release", "refs/heads/origin/release"),
|
||||
canMergeLocally: canMergeIntoLocalBase("release", "refs/heads/origin/release"),
|
||||
baseRefLabel: "origin/release",
|
||||
aheadCount: 1,
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-branch");
|
||||
});
|
||||
|
||||
it("offers update but not local merge for a non-origin remote base", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: false,
|
||||
canMergeLocally: canMergeIntoLocalBase("feature", "refs/remotes/upstream/release"),
|
||||
baseRefLabel: "upstream/release",
|
||||
aheadCount: 1,
|
||||
behindBaseCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-from-base");
|
||||
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
|
||||
});
|
||||
|
||||
it("does not offer merging into the current local branch when the remote base shares its name", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: false,
|
||||
canMergeLocally: canMergeIntoLocalBase("release", "refs/remotes/origin/release"),
|
||||
baseRefLabel: "release",
|
||||
aheadCount: 1,
|
||||
behindBaseCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-from-base");
|
||||
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
|
||||
});
|
||||
|
||||
it("offers update from the same-named remote base when it is ahead", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: isCurrentBranchBase("release", "refs/remotes/origin/release"),
|
||||
baseRefLabel: "release",
|
||||
behindBaseCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-from-base");
|
||||
});
|
||||
|
||||
it("drops a promoted merge action from the remaining menu actions", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
|
||||
@@ -50,6 +50,29 @@ interface GitActionRuntimeState {
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export function isCurrentBranchBase(
|
||||
currentBranch: string | null | undefined,
|
||||
baseRef: string | null | undefined,
|
||||
): boolean {
|
||||
if (!currentBranch || !baseRef) return false;
|
||||
if (baseRef.startsWith("refs/heads/")) return baseRef === `refs/heads/${currentBranch}`;
|
||||
if (baseRef.startsWith("refs/")) return false;
|
||||
// Older hosts send a display name, which cannot distinguish a local ref from its remote.
|
||||
return currentBranch === baseRef.replace(/^origin\//, "");
|
||||
}
|
||||
|
||||
export function canMergeIntoLocalBase(
|
||||
currentBranch: string | null | undefined,
|
||||
baseRef: string | null | undefined,
|
||||
): boolean {
|
||||
if (!currentBranch || !baseRef) return false;
|
||||
if (baseRef.startsWith("refs/remotes/") && !baseRef.startsWith("refs/remotes/origin/")) {
|
||||
return false;
|
||||
}
|
||||
const localTarget = baseRef.replace(/^(?:refs\/heads\/|refs\/remotes\/origin\/|origin\/)/, "");
|
||||
return currentBranch !== localTarget;
|
||||
}
|
||||
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
@@ -68,6 +91,7 @@ export interface BuildGitActionsInput {
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
canMergeLocally: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
@@ -349,7 +373,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
|
||||
return "pr";
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||
if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) {
|
||||
return "merge-branch";
|
||||
}
|
||||
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||
@@ -380,7 +404,7 @@ function getPullRequestActionIds(filter: {
|
||||
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
|
||||
return [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
...(input.canMergeLocally ? (["merge-branch"] as const) : []),
|
||||
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-st
|
||||
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildGitActions,
|
||||
isCurrentBranchBase,
|
||||
canMergeIntoLocalBase,
|
||||
narrowPullRequestState,
|
||||
type BuildGitActionsInput,
|
||||
type GitAction,
|
||||
@@ -116,7 +118,7 @@ interface DeriveGitActionsStateArgs {
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isStatusLoading: boolean;
|
||||
baseRefLabel: string;
|
||||
baseRef: string | undefined;
|
||||
}
|
||||
|
||||
interface DerivedGitActionsState {
|
||||
@@ -129,6 +131,7 @@ interface DerivedGitActionsState {
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
canMergeLocally: boolean;
|
||||
shouldPromoteArchive: boolean;
|
||||
}
|
||||
|
||||
@@ -167,7 +170,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRefLabel,
|
||||
baseRef,
|
||||
} = args;
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
@@ -178,7 +181,8 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
hasPullRequest: Boolean(prStatus?.url),
|
||||
hasRemote: gitStatus?.hasRemote ?? false,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef),
|
||||
canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef),
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
@@ -653,7 +657,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRefLabel,
|
||||
baseRef,
|
||||
});
|
||||
const {
|
||||
actionsDisabled,
|
||||
@@ -665,6 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
shouldPromoteArchive,
|
||||
} = derived;
|
||||
|
||||
@@ -695,6 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
@@ -812,6 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
githubFeaturesEnabled,
|
||||
forge,
|
||||
githubAutoMergeActionsEnabled,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { GitBranch } from "lucide-react-native";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "./query-keys";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
baseRef: string;
|
||||
isWorktree: boolean;
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function refLabel(ref: string): string {
|
||||
return ref.replace(/^refs\/(heads|remotes)\//, "");
|
||||
}
|
||||
|
||||
export function WorkspaceBaseBranchPicker({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
baseRef,
|
||||
isWorktree,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceBaseBranch === true,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useDebouncedValue(search, 200);
|
||||
const branches = useFetchQuery({
|
||||
queryKey: ["workspaceBaseBranches", serverId, cwd, query],
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
const result = await client.getBranchSuggestions({
|
||||
cwd,
|
||||
refMode: "exact",
|
||||
query,
|
||||
limit: 200,
|
||||
});
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.branches;
|
||||
},
|
||||
enabled: open && supported && connected && Boolean(client),
|
||||
retry: false,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (ref: string) => {
|
||||
if (!client || !workspaceId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
},
|
||||
});
|
||||
const options = useMemo(
|
||||
() => (branches.data ?? []).map((ref) => ({ id: ref, label: refLabel(ref) })),
|
||||
[branches.data],
|
||||
);
|
||||
const error = mutation.error ?? branches.error;
|
||||
const title = t("workspace.git.diff.baseBranch.title");
|
||||
const label = refLabel(baseRef).replace(/^origin\//, "");
|
||||
|
||||
const { reset, mutate, isPending } = mutation;
|
||||
const { refetch, isError } = branches;
|
||||
const handleOpen = useCallback(() => {
|
||||
reset();
|
||||
setSearch("");
|
||||
setOpen(true);
|
||||
}, [reset]);
|
||||
const handleSelect = useCallback(
|
||||
(ref: string) => {
|
||||
if (!isPending) mutate(ref);
|
||||
},
|
||||
[isPending, mutate],
|
||||
);
|
||||
const handleRetry = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
const branchIcon = useMemo(() => <ThemedGitBranch size={14} uniProps={mutedColor} />, []);
|
||||
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
|
||||
({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
leadingSlot={branchIcon}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
disabled={isPending}
|
||||
/>
|
||||
),
|
||||
[isPending, branchIcon],
|
||||
);
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
<View style={styles.footer}>
|
||||
{isPending ? <ThemedLoadingSpinner size="small" uniProps={mutedColor} /> : null}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" testID="workspace-base-branch-error" style={styles.error}>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<Button variant="outline" onPress={handleRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[t, isPending, error, isError, handleRetry],
|
||||
);
|
||||
|
||||
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||
<ToolbarLabelSelectTrigger
|
||||
testID="workspace-base-branch-trigger"
|
||||
label={label}
|
||||
open={open}
|
||||
disabled={!connected || mutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={title}
|
||||
onPress={handleOpen}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={baseRef}
|
||||
onSelect={handleSelect}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
onSearchQueryChange={setSearch}
|
||||
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
|
||||
emptyText={branches.isFetching ? t("common.states.loading") : t("branchSwitcher.empty")}
|
||||
title={title}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
desktopMinWidth={300}
|
||||
renderOption={renderOption}
|
||||
footer={error || isPending ? footer : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
anchor: { flexShrink: 1, minWidth: 0 },
|
||||
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
|
||||
}));
|
||||
Reference in New Issue
Block a user