From 8fc0509f6517ca0b7af3d6693390a8a71f93c01f Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:13:44 +0300 Subject: [PATCH] feat(mobile): add branch and pull request picker --- apps/mobile/src/Stack.tsx | 9 + .../threads/NewTaskCheckoutScreen.tsx | 257 ++++++++++++++++++ .../features/threads/NewTaskDraftScreen.tsx | 39 ++- .../threads/new-task-flow-provider.tsx | 41 ++- .../threads/newTaskCheckoutSelection.test.ts | 50 ++++ .../threads/newTaskCheckoutSelection.ts | 14 + .../src/sourceControl/GitHubCli.test.ts | 2 +- .../src/sourceControl/gitHubPullRequests.ts | 16 +- apps/web/src/pullRequestReference.ts | 60 +--- docs/personal-fork-changes.md | 4 + packages/shared/src/sourceControl.ts | 49 ++++ 11 files changed, 465 insertions(+), 76 deletions(-) create mode 100644 apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx create mode 100644 apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts create mode 100644 apps/mobile/src/features/threads/newTaskCheckoutSelection.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4cf787d9ce5..97eeb7595aa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -39,6 +39,7 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; +import { NewTaskCheckoutScreen } from "./features/threads/NewTaskCheckoutScreen"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; @@ -231,6 +232,14 @@ const NewTaskSheetStack = createNativeStackNavigator({ // header also lays the content out below the bar (no manual inset). options: SHEET_SOLID_HEADER_OPTIONS, }), + NewTaskCheckout: createNativeStackScreen({ + screen: NewTaskCheckoutScreen, + linking: "checkout", + options: { + ...SHEET_SOLID_HEADER_OPTIONS, + title: "Branches & PRs", + }, + }), AddProject: createNativeStackScreen({ screen: AddProjectSourceRoute, linking: "add-project", diff --git a/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx b/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx new file mode 100644 index 00000000000..cd01e720833 --- /dev/null +++ b/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx @@ -0,0 +1,257 @@ +import { useNavigation } from "@react-navigation/native"; +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + RefreshControl, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { VcsRef } from "@t3tools/contracts"; +import { + parsePullRequestReference, + resolveChangeRequestPresentation, +} from "@t3tools/shared/sourceControl"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { gitEnvironment } from "../../state/git"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { vcsEnvironment } from "../../state/vcs"; +import { useNewTaskFlow, branchBadgeLabel } from "./new-task-flow-provider"; +import { resolveBranchCheckoutMode } from "./newTaskCheckoutSelection"; + +function sectionLabel(value: string) { + return ( + + {value} + + ); +} + +function CheckoutRow(props: { + readonly icon: "arrow.triangle.branch" | "arrow.triangle.pull"; + readonly title: string; + readonly subtitle: string; + readonly badge?: string | null; + readonly disabled?: boolean; + readonly pending?: boolean; + readonly selected?: boolean; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const subtleIconColor = useThemeColor("--color-icon-subtle"); + + return ( + + + + + + + + {props.title} + + {props.badge ? ( + + {props.badge} + + ) : null} + + + {props.subtitle} + + + {props.pending ? ( + + ) : ( + + )} + + ); +} + +function branchSubtitle(branch: VcsRef): string { + if (branch.current) return "Use the project's current checkout"; + if (branch.worktreePath) return "Reuse the existing worktree checkout"; + if (branch.isRemote) return "Create a new worktree from this remote branch"; + return "Create a new worktree from this branch"; +} + +export function NewTaskCheckoutScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const flow = useNewTaskFlow(); + const [preparingReference, setPreparingReference] = useState(null); + const selectedProject = flow.selectedProject; + const query = flow.branchQuery.trim(); + const normalizedQuery = query.toLowerCase(); + const parsedReference = parsePullRequestReference(query); + const filteredBranches = useMemo(() => { + if (!normalizedQuery) return flow.checkoutBranches; + return flow.checkoutBranches.filter((branch) => + branch.name.toLowerCase().includes(normalizedQuery), + ); + }, [flow.checkoutBranches, normalizedQuery]); + const preparePullRequest = useAtomCommand(gitEnvironment.preparePullRequestThread, { + reportFailure: false, + }); + const gitStatus = useEnvironmentQuery( + selectedProject + ? vcsEnvironment.status({ + environmentId: selectedProject.environmentId, + input: { cwd: selectedProject.workspaceRoot }, + }) + : null, + ); + const currentBranch = + flow.availableBranches.find((branch) => branch.current) ?? + flow.availableBranches.find((branch) => branch.isDefault) ?? + null; + const sourceControlPresentation = resolveChangeRequestPresentation( + gitStatus.data?.sourceControlProvider, + ); + + const selectBranch = (branch: VcsRef) => { + flow.selectBranch(branch, resolveBranchCheckoutMode(branch)); + flow.setBranchQuery(""); + navigation.goBack(); + }; + + const selectPullRequest = async (reference: string) => { + if (!selectedProject || preparingReference !== null) return; + setPreparingReference(reference); + const result = await preparePullRequest({ + environmentId: selectedProject.environmentId, + input: { + cwd: selectedProject.workspaceRoot, + reference, + mode: "worktree", + }, + }); + setPreparingReference(null); + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + `Unable to prepare ${sourceControlPresentation.shortName}`, + error instanceof Error + ? error.message + : `The ${sourceControlPresentation.longName} could not be prepared.`, + ); + } + return; + } + + flow.selectPreparedCheckout({ + branch: result.value.branch, + worktreePath: result.value.worktreePath, + }); + flow.setBranchQuery(""); + navigation.goBack(); + }; + + const refreshing = flow.branchesLoading && flow.checkoutBranches.length === 0; + + return ( + + { + void flow.loadBranches(); + gitStatus.refresh(); + }} + /> + } + > + + + + Existing checkouts are reused. Other branches and pull requests open in a new worktree. + + + + {parsedReference ? ( + + {sectionLabel(`Open ${sourceControlPresentation.shortName}`)} + void selectPullRequest(parsedReference)} + /> + + ) : null} + + + {sectionLabel("Branches")} + {flow.branchesLoading && flow.availableBranches.length === 0 ? ( + + + + ) : null} + {!flow.branchesLoading && filteredBranches.length === 0 ? ( + No matching branches. + ) : null} + {filteredBranches.map((branch) => { + const selected = + flow.selectedBranchName === branch.name && + (flow.selectedWorktreePath ?? null) === + (branch.worktreePath === selectedProject?.workspaceRoot + ? null + : branch.worktreePath); + return ( + selectBranch(branch)} + /> + ); + })} + + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce81db8c486..a7190166c34 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -42,7 +42,7 @@ import { restoreComposerDraftSnapshot, type ComposerDraft, } from "../../state/use-composer-drafts"; -import { useProjects } from "../../state/entities"; +import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -50,13 +50,18 @@ import { useRemoteConnectionStatus } from "../../state/use-remote-environment-re import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { resolveNewTaskWorkspaceControl } from "./newTaskCheckoutSelection"; function formatWorkspaceLabel(input: { readonly workspaceMode: string; readonly currentBranchName: string | null; readonly selectedBranchName: string | null; + readonly selectedWorktreePath: string | null; }): string { const branchName = input.selectedBranchName ?? input.currentBranchName; + if (input.selectedWorktreePath !== null) { + return branchName ? `Worktree · ${branchName}` : "Worktree"; + } if (input.workspaceMode === "worktree") { return branchName ? `New worktree · ${branchName}` : "New worktree"; } @@ -77,6 +82,9 @@ export function NewTaskDraftScreen(props: { const createProjectThread = useCreateProjectThread(); const flow = useNewTaskFlow(); const navigation = useNavigation(); + const selectedEnvironmentConfig = useEnvironmentServerConfig( + flow.selectedProject?.environmentId ?? null, + ); const { consumeShare, getShare, @@ -695,10 +703,16 @@ export function NewTaskDraftScreen(props: { formatWorkspaceLabel({ currentBranchName, selectedBranchName: flow.selectedBranchName, + selectedWorktreePath: flow.selectedWorktreePath, workspaceMode: flow.workspaceMode, }), - [currentBranchName, flow.selectedBranchName, flow.workspaceMode], + [currentBranchName, flow.selectedBranchName, flow.selectedWorktreePath, flow.workspaceMode], ); + const workspaceControl = resolveNewTaskWorkspaceControl({ + checkoutAwareThreadCreationEnabled: + selectedEnvironmentConfig?.settings.enableCheckoutAwareThreadCreation ?? false, + hasProject: flow.selectedProject !== null, + }); function handleModelMenuAction(event: string) { if (isIncomingShareTransferPending || !event.startsWith("model:")) { return; @@ -1014,17 +1028,28 @@ export function NewTaskDraftScreen(props: { label={selectedEnvironmentLabel} /> - handleWorkspaceMenuAction(nativeEvent.event)} - > + {workspaceControl === "checkout-picker" ? ( navigation.dispatch(StackActions.push("NewTaskCheckout"))} + showChevron={false} /> - + ) : ( + handleWorkspaceMenuAction(nativeEvent.event)} + > + + + )} ); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971..64fd25b70d0 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -17,6 +17,7 @@ import { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { TurnCommandMetadata } from "../../lib/commandMetadata"; @@ -122,6 +123,7 @@ type NewTaskFlowContextValue = { readonly branchQuery: string; readonly branchesLoading: boolean; readonly availableBranches: ReadonlyArray; + readonly checkoutBranches: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly expandedProvider: string | null; @@ -141,7 +143,11 @@ type NewTaskFlowContextValue = { readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: (key: string | null) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; - readonly selectBranch: (branch: VcsRef) => void; + readonly selectBranch: (branch: VcsRef, modeOverride?: WorkspaceMode) => void; + readonly selectPreparedCheckout: (input: { + readonly branch: string; + readonly worktreePath: string | null; + }) => void; readonly setStartFromOrigin: (value: boolean) => void; readonly beginEditingPendingTask: (messageId: string) => boolean; readonly finishEditingPendingTask: () => void; @@ -475,13 +481,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; + const checkoutBranches = useMemo( + () => dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []), + [branchState.data?.refs], + ); const availableBranches = useMemo( () => pipe( - branchState.data?.refs ?? [], + checkoutBranches, Arr.filter((branch) => !branch.isRemote), ), - [branchState.data?.refs], + [checkoutBranches], ); const filteredBranches = useMemo(() => { @@ -551,13 +561,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const selectBranch = useCallback( - (branch: VcsRef) => { + (branch: VcsRef, modeOverride?: WorkspaceMode) => { if (!selectedProject || !selectedProjectDraftKey) { return; } updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { - mode: workspaceMode, + mode: modeOverride ?? workspaceMode, branch: branch.name, worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), startFromOrigin, @@ -567,6 +577,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], ); + const selectPreparedCheckout = useCallback( + (input: { readonly branch: string; readonly worktreePath: string | null }) => { + if (!selectedProjectDraftKey) return; + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + // The PR has already been materialized. Reuse that checkout rather + // than asking thread bootstrap to create a second worktree. + mode: "local", + branch: input.branch, + worktreePath: input.worktreePath, + startFromOrigin: false, + }, + }); + }, + [selectedProjectDraftKey], + ); + const setStartFromOrigin = useCallback( (value: boolean) => { if (!selectedProjectDraftKey) { @@ -818,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branchQuery, branchesLoading, availableBranches, + checkoutBranches, runtimeMode, interactionMode, expandedProvider, @@ -835,6 +863,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedModelKey, setWorkspaceMode, selectBranch, + selectPreparedCheckout, setStartFromOrigin, beginEditingPendingTask, finishEditingPendingTask, @@ -861,6 +890,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branchesLoading, buildPendingTaskMessage, cancelEditingPendingTask, + checkoutBranches, editingPendingTask, environments, expandedProvider, @@ -882,6 +912,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModelOption, selectedProjectDraftKey, selectedProviderSkills, + selectPreparedCheckout, setSelectedModelOptions, selectedProject, selectedProjectKey, diff --git a/apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts b/apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts new file mode 100644 index 00000000000..bf7e9377444 --- /dev/null +++ b/apps/mobile/src/features/threads/newTaskCheckoutSelection.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveBranchCheckoutMode, + resolveNewTaskWorkspaceControl, +} from "./newTaskCheckoutSelection"; + +describe("resolveNewTaskWorkspaceControl", () => { + it("uses the checkout picker when the fork flag is enabled", () => { + expect( + resolveNewTaskWorkspaceControl({ + checkoutAwareThreadCreationEnabled: true, + hasProject: true, + }), + ).toBe("checkout-picker"); + }); + + it("preserves the legacy menu when the fork flag is disabled", () => { + expect( + resolveNewTaskWorkspaceControl({ + checkoutAwareThreadCreationEnabled: false, + hasProject: true, + }), + ).toBe("legacy-menu"); + }); +}); + +describe("resolveBranchCheckoutMode", () => { + it("reuses an existing checkout", () => { + expect( + resolveBranchCheckoutMode({ + name: "feature/existing", + current: false, + isDefault: false, + worktreePath: "/repo-worktrees/existing", + }), + ).toBe("local"); + }); + + it("creates a worktree for a branch that is not checked out", () => { + expect( + resolveBranchCheckoutMode({ + name: "feature/remote", + current: false, + isDefault: false, + worktreePath: null, + }), + ).toBe("worktree"); + }); +}); diff --git a/apps/mobile/src/features/threads/newTaskCheckoutSelection.ts b/apps/mobile/src/features/threads/newTaskCheckoutSelection.ts new file mode 100644 index 00000000000..191c4db84fe --- /dev/null +++ b/apps/mobile/src/features/threads/newTaskCheckoutSelection.ts @@ -0,0 +1,14 @@ +import type { VcsRef } from "@t3tools/contracts"; + +export function resolveNewTaskWorkspaceControl(input: { + readonly checkoutAwareThreadCreationEnabled: boolean; + readonly hasProject: boolean; +}): "checkout-picker" | "legacy-menu" { + return input.checkoutAwareThreadCreationEnabled && input.hasProject + ? "checkout-picker" + : "legacy-menu"; +} + +export function resolveBranchCheckoutMode(branch: VcsRef): "local" | "worktree" { + return branch.current || branch.worktreePath !== null ? "local" : "worktree"; +} diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 3d7b5ff76a5..1ffb30d8c1d 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -93,7 +93,7 @@ describe("GitHubCli.layer", () => { mergedAt: null, isCrossRepository: true, headRepository: { - nameWithOwner: "octocat/codething-mvp", + name: "codething-mvp", }, headRepositoryOwner: { login: "octocat", diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index d9dcb7f9ad1..94214ffdcb8 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -33,7 +33,8 @@ const GitHubPullRequestSchema = Schema.Struct({ headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ - nameWithOwner: Schema.String, + name: Schema.optional(Schema.String), + nameWithOwner: Schema.optional(Schema.String), }), ), ), @@ -71,11 +72,18 @@ function normalizeGitHubPullRequestState(input: { function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { - const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const explicitHeadRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); const headRepositoryOwnerLogin = trimOptionalString(raw.headRepositoryOwner?.login) ?? - (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") - ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + (typeof explicitHeadRepositoryNameWithOwner === "string" && + explicitHeadRepositoryNameWithOwner.includes("/") + ? (explicitHeadRepositoryNameWithOwner.split("/")[0] ?? null) + : null); + const headRepositoryName = trimOptionalString(raw.headRepository?.name); + const headRepositoryNameWithOwner = + explicitHeadRepositoryNameWithOwner ?? + (headRepositoryOwnerLogin && headRepositoryName + ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); return { diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index b919e736cc0..b5de1122e04 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,59 +1 @@ -const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; -const GITLAB_MERGE_REQUEST_URL_PATTERN = - /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; -const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = - /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; -const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; -const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; -const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; -const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; - -function parseAzureDevOpsCheckoutReference(args: string): string | null { - const parts = args.trim().split(/\s+/).filter(Boolean); - for (const [index, part] of parts.entries()) { - if (part === "--id" || part === "-i") { - return parts[index + 1] ?? null; - } - if (part.startsWith("--id=")) { - return part.slice("--id=".length) || null; - } - } - return parts.find((part) => !part.startsWith("-")) ?? null; -} - -export function parsePullRequestReference(input: string): string | null { - const trimmed = input.trim(); - if (trimmed.length === 0) { - return null; - } - - const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); - const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); - const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); - const normalizedInput = - ghCliCheckoutMatch?.[1]?.trim() ?? - glabCliCheckoutMatch?.[1]?.trim() ?? - (azureDevOpsCliCheckoutMatch?.[1] - ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) - : null) ?? - trimmed; - if (normalizedInput.length === 0) { - return null; - } - - const urlMatch = - GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); - if (urlMatch?.[1]) { - return normalizedInput; - } - - const numberMatch = PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput); - if (numberMatch?.[1]) { - return numberMatch[1]; - } - - return null; -} +export { parsePullRequestReference } from "@t3tools/shared/sourceControl"; diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index 0e8dcaed51e..cb2ad403e25 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -38,6 +38,10 @@ Upstream PRs integrated into the fork are listed in ## Checkout-aware thread creation +- On mobile, the new-task workspace control opens a searchable checkout picker. Existing checkouts + are reused, branches that are not checked out create a worktree, and a pull request URL/number can + be resolved into an isolated worktree. Turning off the flag restores the compact upstream + workspace menu. - With New worktree selected, creating a chat from an existing worktree seeds the draft from that worktree's branch without reusing its path. Creating a chat for a different project still uses that project's main branch. diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..afbbc74b100 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,5 +1,54 @@ import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; +const GITHUB_PULL_REQUEST_URL_PATTERN = + /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +const GITLAB_MERGE_REQUEST_URL_PATTERN = + /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; +const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = + /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; +const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; +const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; +const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; +const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; + +function parseAzureDevOpsCheckoutReference(args: string): string | null { + const parts = args.trim().split(/\s+/).filter(Boolean); + for (const [index, part] of parts.entries()) { + if (part === "--id" || part === "-i") { + return parts[index + 1] ?? null; + } + if (part.startsWith("--id=")) { + return part.slice("--id=".length) || null; + } + } + return parts.find((part) => !part.startsWith("-")) ?? null; +} + +export function parsePullRequestReference(input: string): string | null { + const trimmed = input.trim(); + if (trimmed.length === 0) return null; + + const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); + const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); + const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); + const normalizedInput = + ghCliCheckoutMatch?.[1]?.trim() ?? + glabCliCheckoutMatch?.[1]?.trim() ?? + (azureDevOpsCliCheckoutMatch?.[1] + ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) + : null) ?? + trimmed; + if (normalizedInput.length === 0) return null; + + const urlMatch = + GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? + GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? + AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); + if (urlMatch?.[1]) return normalizedInput; + + return PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput)?.[1] ?? null; +} + export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; readonly providerName: string;