diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index f3c4b72ed1c..de0b9b96e59 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1895,48 +1895,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -2009,48 +2017,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2512,36 +2528,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -2842,11 +2864,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} - engines: {node: '>=0.4.0'} - hasBin: true - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4821,24 +4838,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -6945,22 +6966,22 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: @@ -6970,7 +6991,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7005,12 +7026,12 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7025,42 +7046,42 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: @@ -8422,7 +8443,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 25.9.3 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8589,7 +8610,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.6.0 + '@types/node': 25.9.3 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9347,24 +9368,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -9379,7 +9400,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.3 '@types/hammerjs@2.0.46': {} @@ -9626,9 +9647,9 @@ snapshots: acorn: 8.15.0 acorn-walk: 8.3.5 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: - acorn: 8.17.0 + acorn: 8.15.0 acorn-walk@8.3.5: dependencies: @@ -9636,8 +9657,6 @@ snapshots: acorn@8.15.0: {} - acorn@8.17.0: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -9792,7 +9811,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 5.2.1 @@ -10465,7 +10484,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: @@ -10761,8 +10780,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -11295,7 +11314,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -11867,7 +11886,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 25.9.3 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12022,7 +12041,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 25.9.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12067,7 +12086,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -13740,7 +13759,7 @@ snapshots: terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml new file mode 100644 index 00000000000..00f6fc47030 --- /dev/null +++ b/mobile/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 67468fd483f..e8a000b3f13 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -17,7 +17,6 @@ import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' -import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' import { @@ -49,13 +48,7 @@ import { resolveMobileNewWorkspaceDialogRepoId } from '../worktree/new-workspace-dialog-repo-selection' import { createBlankWorkspace } from '../tasks/blank-workspace-create' -import { createWorkspaceFromSource } from '../tasks/source-workspace-create' -import { - describeWorkspaceSource, - isRepoBoundSource, - resolveSourceRepoId, - type WorkspaceSource -} from '../tasks/workspace-source-selection' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' import { @@ -63,7 +56,11 @@ import { normalizeVisibleTaskProviders, type TaskProvider } from '../tasks/mobile-task-providers' -import { WorkspaceSourcePickerDrawer } from './WorkspaceSourcePickerDrawer' +import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' type Repo = { @@ -72,6 +69,9 @@ type Repo = { path: string badgeColor?: string connectionId?: string | null + kind?: 'git' | 'folder' + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null } type SetupDecision = 'inherit' | 'run' | 'skip' @@ -183,7 +183,6 @@ function NewWorktreeModalContent({ const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) const [showRepoPicker, setShowRepoPicker] = useState(false) - const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( @@ -193,10 +192,7 @@ function NewWorktreeModalContent({ const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') - const [workspaceSource, setWorkspaceSource] = useState({ kind: 'blank' }) - const [showSourcePicker, setShowSourcePicker] = useState(false) const [availableProviders, setAvailableProviders] = useState([]) const [tasksSupported, setTasksSupported] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) @@ -213,11 +209,15 @@ function NewWorktreeModalContent({ const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) - // Why: matches the desktop UI — the input shows a generic "Workspace name" - // placeholder, not the suggested creature. The creature name is only used - // as a server-bound fallback when the user submits with a blank field, so - // it's recomputed lazily inside handleCreate() to stay fresh against - // existingWorktreePaths at submission time. + // The Smart source picker owns the workspace name AND the linked-source + // selection: typing names the workspace and drives source search, and picking + // a source resolves the base/branch/push metadata (matching desktop). The + // creature-name fallback is only computed lazily at submit for a blank name. + const composer = useMobileComposerSource({ + client, + selectedRepoId: selectedRepo?.id ?? null, + onError: setError + }) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const sshGate = deriveWorkspaceSshGate({ @@ -254,6 +254,25 @@ function NewWorktreeModalContent({ } const selectedAgent = selectedAgentResolution.selectedAgent + const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true + const sourceAvailability: SmartModeAvailabilityInput = { + textOnly: selectedRepo != null && !selectedRepoIsGit, + tasksSupported, + hasRepo: selectedRepo != null, + githubAvailable: availableProviders.includes('github'), + gitlabAvailable: availableProviders.includes('gitlab'), + linearAvailable: availableProviders.includes('linear') + } + const pasteRepos = useMemo( + () => + repos.map((repo) => ({ + id: repo.id, + displayName: repo.displayName, + slug: deriveRepoSlug(repo) + })), + [repos] + ) + useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { return @@ -608,7 +627,7 @@ function NewWorktreeModalContent({ // server invent one. The pre-flight basename dedupe is only a hint; // the authoritative collision is checked server-side against git // branches/remotes/PRs, so we also retry-with-suffix on conflict. - const trimmedName = name.trim() + const trimmedName = composer.name.trim() const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) let setupDecision: SetupDecision = 'inherit' @@ -645,29 +664,30 @@ function NewWorktreeModalContent({ const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined const trimmedNote = note.trim() || undefined - const result = - workspaceSource.kind === 'blank' - ? await createBlankWorkspace({ - client, - repoId: selectedRepo.id, - baseName, - startupCommand: command, - createdWithAgentId, - comment: trimmedNote, - setupDecision - }) - : await createWorkspaceFromSource({ - client, - source: workspaceSource, - targetRepoId: resolveSourceRepoId(workspaceSource) ?? selectedRepo.id, - setupDecision, - agent: { - choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank', - startupCommand: command - }, - workspaceName: name.trim() || undefined, - note: trimmedNote - }) + const createSelection = composer.createSelection + const result = createSelection + ? await createWorkspaceFromComposerSource({ + client, + selection: createSelection, + targetRepoId: selectedRepo.id, + setupDecision, + agent: { + choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank', + startupCommand: command + }, + workspaceName: trimmedName || undefined, + note: trimmedNote, + nameIsAutoManaged: composer.isNameAutoManaged + }) + : await createBlankWorkspace({ + client, + repoId: selectedRepo.id, + baseName, + startupCommand: command, + createdWithAgentId, + comment: trimmedNote, + setupDecision + }) if ('error' in result) { setError(result.error) return @@ -707,22 +727,19 @@ function NewWorktreeModalContent({ ) function prepareSelectionPickerOpen(): void { - // Why: picker taps can beat the delayed name-field focus; suppressing it - // prevents the keyboard from reopening under the picker drawer. - setNameAutoFocusEnabled(false) + // Why: picker taps can beat an open soft keyboard; dismissing it prevents the + // keyboard from reopening under the picker drawer. Keyboard.dismiss() } - function handleSourceSelected(source: WorkspaceSource): void { - // A GitHub/GitLab work item dictates its own repo; pin the repo picker to it. - const pinnedRepoId = resolveSourceRepoId(source) - if (pinnedRepoId) { - const pinnedRepo = repos.find((repo) => repo.id === pinnedRepoId) - if (pinnedRepo) { - setSelectedRepo(pinnedRepo) - } + function handleRepoSelected(repo: Repo): void { + const repoChanged = repo.id !== selectedRepo?.id + setSelectedRepo(repo) + // Branch and GitHub/GitLab sources are tied to the repo they were searched + // from; switching repos invalidates them, so clear the selection. + if (repoChanged) { + composer.handleClearSmartNameSelection() } - setWorkspaceSource(source) } async function approveSetupTrust(alwaysTrust: boolean): Promise { @@ -787,21 +804,24 @@ function NewWorktreeModalContent({ - - Start from - { - prepareSelectionPickerOpen() - setShowSourcePicker(true) - }} - > - - {describeWorkspaceSource(workspaceSource).label} - - - - + { + const nextRepo = repos.find((repo) => repo.id === repoId) + if (nextRepo) { + setSelectedRepo(nextRepo) + } + }} + onBeforeOpen={() => setError('')} + /> {selectedRepoConnectionId ? ( @@ -846,28 +866,6 @@ function NewWorktreeModalContent({ ) : null} - - - Workspace Name [Optional] - - { - setName(t) - setError('') - }} - placeholderTextColor={colors.textMuted} - shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} - returnKeyType="done" - onSubmitEditing={() => { - if (canCreate) { - void handleCreate() - } - }} - /> - - Agent + + Note { - const repoChanged = item.repo.id !== selectedRepo?.id - setSelectedRepo(item.repo) - // Branch and GitHub/GitLab sources are tied to the repo they were - // searched from; switching repos invalidates them, so reset to blank. - // Linear issues are repo-agnostic and survive. - if (repoChanged && isRepoBoundSource(workspaceSource)) { - setWorkspaceSource({ kind: 'blank' }) - } - }} + onSelect={(item) => handleRepoSelected(item.repo)} onClose={() => setShowRepoPicker(false)} renderIcon={(item) => { return @@ -1022,17 +1016,6 @@ function NewWorktreeModalContent({ renderIcon={(agent) => } /> - setShowSourcePicker(false)} - /> - + } + if (icon.name === 'sparkles') { + return + } + if (icon.name === 'git-branch') { + return + } + return +} diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx new file mode 100644 index 00000000000..4af0ad682b5 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -0,0 +1,102 @@ +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + composer: MobileComposerSource + selectedRepoIsGit: boolean +} + +// The Advanced-section source controls: the editable Name appears once a source +// pill is shown (the field itself is no longer the name input); the branch-name +// override and reuse toggle mirror the desktop composer's advanced branch fields. +export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) { + const selection = composer.smartNameSelection + const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch') + return ( + <> + {selection ? ( + + Name + + + ) : null} + + {showBranchOverride ? ( + + Branch name + + + ) : null} + + {composer.reuseEligibleBranch ? ( + + + + Reuse branch “{composer.reuseEligibleBranch}” + + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reuseRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + reuseLabel: { + flex: 1, + fontSize: 13, + color: colors.textSecondary + }, + reuseSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx new file mode 100644 index 00000000000..cd4dcab37da --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { + MR_STATE_FILTER_OPTIONS, + resolveAvailableSmartModes, + resolveDefaultSmartMode, + SMART_MODE_OPTIONS, + type SmartModeAvailabilityInput, + type SmartModeOption +} from '../tasks/mobile-smart-source-modes' +import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types' +import { + lookupGitHubItemByOwnerRepo, + type PasteRepoCandidate +} from '../tasks/smart-source-paste-intent' +import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { SmartSourceModeIcon } from './SmartSourceModeIcon' +import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow' + +type Props = { + visible: boolean + client: RpcClient | null + composer: MobileComposerSource + availability: SmartModeAvailabilityInput + repoId: string | null + repos: readonly PasteRepoCandidate[] + linearWorkspaceId?: string | null + sshReady: boolean + onRepoChange: (repoId: string) => void + onClose: () => void +} + +export function SmartWorkspaceSourceDrawer({ + visible, + client, + composer, + availability, + repoId, + repos, + linearWorkspaceId, + sshReady, + onRepoChange, + onClose +}: Props) { + const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability]) + const [mode, setMode] = useState(() => resolveDefaultSmartMode(availability)) + const [mrStateFilter, setMrStateFilter] = useState('opened') + // Why: read latest availability inside the open effect without making it a + // reactive dep (the object is recreated each render), so re-seeding happens + // only on open, not on every availability recompute. + const availabilityRef = useRef(availability) + availabilityRef.current = availability + + // Reset to the default mode each time the drawer opens. + useEffect(() => { + if (visible) { + setMode(resolveDefaultSmartMode(availabilityRef.current)) + } + }, [visible]) + + // Snap the chosen mode back into the available set if availability changes. + const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text') + + // Linear searches without a repo; every other provider/branch search needs a + // connected repo-backed target. + const searchEnabled = visible && (effectiveMode === 'linear' || sshReady) + + const { + rows, + loading, + error, + needsGitHubRemote, + emptyHint, + crossRepoPrompt, + dismissCrossRepoPrompt + } = useSmartWorkspaceSource({ + client, + enabled: searchEnabled, + mode: effectiveMode, + query: composer.name, + repoId, + githubAvailable: availability.githubAvailable, + gitlabAvailable: availability.gitlabAvailable, + linearAvailable: availability.linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + }) + + function closeSoon(): void { + setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS) + } + + function handleSelectRow(row: SourceRow): void { + switch (row.kind) { + case 'use-name': + composer.setName(row.name) + break + case 'create-branch': + composer.handleSmartCreateBranch(row.name) + break + case 'github': + composer.handleSmartGitHubItemSelect(row.item) + break + case 'gitlab': + composer.handleSmartGitLabItemSelect(row.item) + break + case 'branch': + composer.handleSmartBranchSelect(row.refName, row.localBranchName) + break + case 'linear': + composer.handleSmartLinearIssueSelect(row.issue) + break + } + onClose() + } + + async function handleAcceptCrossRepo(): Promise { + if (!client || !crossRepoPrompt) { + return + } + const { link, matchingRepo } = crossRepoPrompt + try { + const item = await lookupGitHubItemByOwnerRepo( + client, + matchingRepo.id, + link.slug, + link.number, + link.type + ) + if (item) { + onRepoChange(matchingRepo.id) + composer.handleSmartGitHubItemSelect(item) + onClose() + } + } catch { + dismissCrossRepoPrompt() + } + } + + const showEmpty = + !loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0 + + return ( + + + Name or Create From + + Done + + + + + + + {SMART_MODE_OPTIONS.filter((option: SmartModeOption) => + availableModes.includes(option.id) + ).map((option) => { + const selected = option.id === effectiveMode + const tint = selected ? colors.textPrimary : colors.textSecondary + return ( + setMode(option.id)} + > + + + {option.label} + + + ) + })} + + + {effectiveMode === 'gitlab' ? ( + + {MR_STATE_FILTER_OPTIONS.map((option) => { + const selected = option.id === mrStateFilter + return ( + setMrStateFilter(option.id)} + > + + {option.label} + + + ) + })} + + ) : null} + + {crossRepoPrompt ? ( + + + This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}. + + + + Cancel + + void handleAcceptCrossRepo()}> + + Switch to {crossRepoPrompt.matchingRepo.displayName} + + + + + ) : null} + + {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? ( + Connect the repository to search sources. + ) : needsGitHubRemote ? ( + + This SSH repo needs a GitHub remote to list issues and PRs. + + ) : error ? ( + {error} + ) : null} + + row.value} + style={styles.list} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + ListFooterComponent={ + loading ? ( + + + + ) : showEmpty ? ( + {emptyHint || 'No results found.'} + ) : null + } + renderItem={({ item }) => ( + handleSelectRow(item)} /> + )} + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + done: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.accentBlue + }, + search: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.sm + }, + tabRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + tabSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + tabText: { + fontSize: 13, + color: colors.textSecondary + }, + tabTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + gap: spacing.xs, + marginBottom: spacing.sm + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + chipSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + chipText: { + fontSize: 12, + color: colors.textSecondary + }, + chipTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + crossRepo: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.sm + }, + crossRepoText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm + }, + crossRepoDismiss: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + crossRepoDismissText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoSwitch: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.textSecondary + }, + crossRepoSwitchText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + notice: { + fontSize: 12, + color: colors.textMuted, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + errorNotice: { + fontSize: 12, + color: colors.statusRed, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + list: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + maxHeight: 420, + flexGrow: 0 + }, + loading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + empty: { + paddingVertical: spacing.lg, + textAlign: 'center', + color: colors.textMuted, + fontSize: 13 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx new file mode 100644 index 00000000000..675a6db9271 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -0,0 +1,178 @@ +import { useState } from 'react' +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { + CircleDot, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + X +} from 'lucide-react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { SmartNameSelection } from '../tasks/mobile-composer-source-types' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + visible: boolean + composer: MobileComposerSource + client: RpcClient | null + availability: SmartModeAvailabilityInput + repoId: string | null + repos: readonly PasteRepoCandidate[] + linearWorkspaceId?: string | null + sshReady: boolean + label: string + disabled?: boolean + onRepoChange: (repoId: string) => void + onBeforeOpen?: () => void +} + +function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) { + if (kind === 'github-pr') { + return + } + if (kind === 'gitlab-mr') { + return + } + if (kind === 'github-issue' || kind === 'gitlab-issue') { + return + } + if (kind === 'branch') { + return + } + return +} + +export function SmartWorkspaceSourceField({ + visible, + composer, + client, + availability, + repoId, + repos, + linearWorkspaceId, + sshReady, + label, + disabled, + onRepoChange, + onBeforeOpen +}: Props) { + const [showDrawer, setShowDrawer] = useState(false) + const selection = composer.smartNameSelection + + function openDrawer(): void { + if (disabled) { + return + } + onBeforeOpen?.() + setShowDrawer(true) + } + + return ( + + + {label} [Optional] + + {selection ? ( + + + + {selection.label} + + {selection.url ? ( + selection.url && void Linking.openURL(selection.url)} + > + + + ) : null} + + + + + ) : ( + + + {composer.name || 'Type a name or search a source'} + + + )} + + setShowDrawer(false)} + /> + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + disabled: { + opacity: 0.55 + }, + inputText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + inputPlaceholder: { + color: colors.textMuted + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + pillLabel: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx new file mode 100644 index 00000000000..ee601c2ebde --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -0,0 +1,134 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + row: SourceRow + onPress: () => void +} + +type RowContent = { + icon: React.ReactNode + title: string + subtitle?: string + status?: string +} + +function resolveRowContent(row: SourceRow): RowContent { + switch (row.kind) { + case 'use-name': + return { + icon: , + title: `Use "${row.name}"`, + subtitle: 'Name this workspace' + } + case 'create-branch': + return { + icon: , + title: `Create branch "${row.name}"`, + subtitle: 'New branch' + } + case 'github': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'gitlab': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'branch': + return { + icon: , + title: row.localBranchName || row.refName, + subtitle: row.refName + } + case 'linear': + return { + icon: , + title: row.issue.title, + subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`, + status: row.issue.state?.name + } + default: + return { icon: , title: '' } + } +} + +export function SmartWorkspaceSourceRow({ row, onPress }: Props) { + const content = resolveRowContent(row) + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + {content.icon} + + + {content.title} + + {content.subtitle ? ( + + {content.subtitle} + + ) : null} + + {content.status ? ( + + + {content.status} + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + icon: { + width: 18, + alignItems: 'center' + }, + copy: { + flex: 1, + minWidth: 0 + }, + title: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + subtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 1 + }, + pill: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pillText: { + fontSize: 11, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'capitalize' + } +}) diff --git a/mobile/src/components/WorkspaceSourcePickerDrawer.tsx b/mobile/src/components/WorkspaceSourcePickerDrawer.tsx deleted file mode 100644 index 01853cc0450..00000000000 --- a/mobile/src/components/WorkspaceSourcePickerDrawer.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - ActivityIndicator, - FlatList, - Pressable, - StyleSheet, - Text, - TextInput, - View -} from 'react-native' -import { Plus } from 'lucide-react-native' -import type { RpcClient } from '../transport/rpc-client' -import type { TaskProvider } from '../tasks/mobile-task-providers' -import { - buildNewBranchSource, - type WorkspaceSource, - type WorkspaceSourceTab -} from '../tasks/workspace-source-selection' -import { - useWorkspaceSourceSearch, - type WorkspaceSourceSearchResult -} from '../tasks/use-workspace-source-search' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' -import { WorkspaceSourceResultRow } from './WorkspaceSourceResultRow' - -const TAB_LABELS: Record = { - branch: 'Branch', - github: 'GitHub', - gitlab: 'GitLab', - linear: 'Linear' -} - -const SEARCH_PLACEHOLDERS: Record = { - branch: 'Search branches', - github: 'Search issues (type is:pr for PRs)', - gitlab: 'Search issues and MRs', - linear: 'Search Linear issues' -} - -type Props = { - visible: boolean - client: RpcClient | null - tasksSupported: boolean - availableProviders: readonly TaskProvider[] - repoId: string | null - sshReady: boolean - linearWorkspaceId?: string | null - onSelect: (source: WorkspaceSource) => void - onClose: () => void -} - -type Row = - | { kind: 'reset' } - | { kind: 'new-branch'; name: string } - | { kind: 'result'; result: WorkspaceSourceSearchResult } - -export function WorkspaceSourcePickerDrawer({ - visible, - client, - tasksSupported, - availableProviders, - repoId, - sshReady, - linearWorkspaceId, - onSelect, - onClose -}: Props) { - const [query, setQuery] = useState('') - const [activeTab, setActiveTab] = useState('branch') - const [closing, setClosing] = useState(false) - const closeTimerRef = useRef | null>(null) - const drawerVisible = visible && !closing - - const tabs = useMemo( - () => ['branch', ...(tasksSupported ? availableProviders : [])], - [availableProviders, tasksSupported] - ) - const effectiveTab = tabs.includes(activeTab) ? activeTab : (tabs[0] ?? 'branch') - - useEffect(() => { - if (visible) { - setClosing(false) - setQuery('') - setActiveTab('branch') - } - return () => { - if (closeTimerRef.current) { - clearTimeout(closeTimerRef.current) - closeTimerRef.current = null - } - } - }, [visible]) - - // Every provider search (incl. repo.searchRefs) requires the mobile.tasks - // capability, so gate all search on it. Linear is repo/SSH-independent; the - // other tabs need a connected repo. - const searchActive = tasksSupported && (effectiveTab === 'linear' || sshReady) - const { results, loading, error, needsGitHubRemote } = useWorkspaceSourceSearch({ - client, - enabled: visible && searchActive, - activeTab: effectiveTab, - query, - repoId, - linearWorkspaceId - }) - - const finishClose = useCallback(() => { - setClosing(false) - onClose() - }, [onClose]) - - const selectThenClose = useCallback( - (source: WorkspaceSource) => { - if (closeTimerRef.current) { - clearTimeout(closeTimerRef.current) - } - setClosing(true) - closeTimerRef.current = setTimeout(() => { - closeTimerRef.current = null - onClose() - onSelect(source) - }, BOTTOM_DRAWER_HIDE_DURATION_MS) - }, - [onClose, onSelect] - ) - - const rows = useMemo(() => { - const list: Row[] = [{ kind: 'reset' }] - const trimmed = query.trim() - // Gate on !loading so the row is computed from settled results, not the - // previous query's debounce-lagged set. - if (effectiveTab === 'branch' && trimmed && !loading) { - const hasExact = results.some( - (result) => result.source.kind === 'branch' && result.source.localBranchName === trimmed - ) - if (!hasExact) { - list.push({ kind: 'new-branch', name: trimmed }) - } - } - for (const result of results) { - list.push({ kind: 'result', result }) - } - return list - }, [effectiveTab, query, results, loading]) - - const branchHint = - effectiveTab === 'branch' && !query.trim() - ? tasksSupported - ? 'Type to search branches.' - : 'Type a name to create a new branch.' - : null - const showEmpty = !loading && !error && !needsGitHubRemote && searchActive && results.length === 0 - - return ( - - - Start from - - - - - - {tabs.map((tab) => { - const selected = tab === effectiveTab - return ( - setActiveTab(tab)} - > - - {TAB_LABELS[tab]} - - - ) - })} - - - {tasksSupported && !sshReady && effectiveTab !== 'linear' ? ( - Connect the repository to search sources. - ) : needsGitHubRemote ? ( - - This SSH repo needs a GitHub remote to list issues and PRs. - - ) : error ? ( - {error} - ) : null} - - - row.kind === 'result' ? row.result.id : `${row.kind}:${index}` - } - style={styles.list} - keyboardShouldPersistTaps="handled" - nestedScrollEnabled - ListFooterComponent={ - loading ? ( - - - - ) : branchHint ? ( - {branchHint} - ) : showEmpty ? ( - No results found. - ) : null - } - renderItem={({ item }) => { - if (item.kind === 'reset') { - return ( - selectThenClose({ kind: 'blank' })} - /> - ) - } - if (item.kind === 'new-branch') { - return ( - [styles.newBranchRow, pressed && styles.rowPressed]} - onPress={() => selectThenClose(buildNewBranchSource('', item.name))} - > - - - Create new branch “{item.name}” - - - ) - } - return ( - selectThenClose(item.result.source)} - /> - ) - }} - /> - - ) -} - -const styles = StyleSheet.create({ - header: { - paddingHorizontal: spacing.xs, - paddingBottom: spacing.sm - }, - title: { - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary - }, - search: { - backgroundColor: colors.bgRaised, - color: colors.textPrimary, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - fontSize: typography.bodySize, - borderWidth: 1, - borderColor: colors.borderSubtle, - marginBottom: spacing.sm - }, - tabRow: { - flexDirection: 'row', - gap: spacing.xs, - marginBottom: spacing.sm - }, - tab: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs + 2, - borderRadius: radii.button, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - tabSelected: { - backgroundColor: colors.bgPanel, - borderColor: colors.textSecondary - }, - tabText: { - fontSize: 13, - color: colors.textSecondary - }, - tabTextSelected: { - color: colors.textPrimary, - fontWeight: '600' - }, - notice: { - fontSize: 12, - color: colors.textMuted, - paddingHorizontal: spacing.xs, - paddingBottom: spacing.sm - }, - errorNotice: { - fontSize: 12, - color: colors.statusRed, - paddingHorizontal: spacing.xs, - paddingBottom: spacing.sm - }, - list: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden', - maxHeight: 420, - flexGrow: 0 - }, - loading: { - paddingVertical: spacing.lg, - alignItems: 'center' - }, - empty: { - paddingVertical: spacing.lg, - textAlign: 'center', - color: colors.textMuted, - fontSize: 13 - }, - newBranchRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - newBranchText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.accentBlue, - fontWeight: '600' - } -}) diff --git a/mobile/src/components/WorkspaceSourceResultRow.tsx b/mobile/src/components/WorkspaceSourceResultRow.tsx deleted file mode 100644 index 565a4c8adf0..00000000000 --- a/mobile/src/components/WorkspaceSourceResultRow.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Pressable, StyleSheet, Text, View } from 'react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' - -type Props = { - title: string - subtitle?: string - status?: string - onPress: () => void -} - -export function WorkspaceSourceResultRow({ title, subtitle, status, onPress }: Props) { - return ( - [styles.row, pressed && styles.rowPressed]} - onPress={onPress} - > - - - {title} - - {subtitle ? ( - - {subtitle} - - ) : null} - - {status ? ( - - - {status} - - - ) : null} - - ) -} - -const styles = StyleSheet.create({ - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - copy: { - flex: 1, - minWidth: 0 - }, - title: { - fontSize: typography.bodySize, - color: colors.textPrimary - }, - subtitle: { - fontSize: 12, - color: colors.textMuted, - marginTop: 1 - }, - pill: { - backgroundColor: colors.bgRaised, - borderRadius: radii.button, - paddingHorizontal: spacing.sm, - paddingVertical: 2 - }, - pillText: { - fontSize: 11, - fontWeight: '600', - color: colors.textSecondary, - textTransform: 'capitalize' - } -}) diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts new file mode 100644 index 00000000000..79e125f4783 --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' + +describe('linked work item builders', () => { + it('maps a GitHub PR into a linked work item', () => { + const linked = buildGitHubLinkedWorkItem({ + type: 'pr', + number: 42, + title: 'Fix bug', + url: 'https://github.com/o/r/pull/42', + repoId: 'repo-1' + }) + expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' }) + }) + + it('maps a GitLab MR into a linked work item', () => { + const linked = buildGitLabLinkedWorkItem({ + type: 'mr', + number: 7, + title: 'Add feature', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + repoId: 'repo-2' + }) + expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' }) + }) + + it('maps a Linear issue with identifier, workspace, and org key', () => { + const linked = buildLinearLinkedWorkItem({ + identifier: 'ENG-9', + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + workspaceId: 'ws-1' + }) + expect(linked).toMatchObject({ + provider: 'linear', + type: 'issue', + number: 0, + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + }) + }) +}) + +describe('shouldApplyAutoName', () => { + it('applies when the name is empty or the previous auto-name', () => { + expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true) + }) + + it('applies when the name is a lookup query (URL / #N / linear id)', () => { + expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(true) + }) + + it('keeps a deliberately typed name', () => { + expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe( + false + ) + }) +}) + +describe('resolveWorkItemAutoName', () => { + it('slugifies the title subject', () => { + expect( + resolveWorkItemAutoName({ + type: 'issue', + number: 3, + title: 'Fix the Login Bug', + provider: 'github' + }) + ).toBe('fix-the-login-bug') + }) +}) + +describe('buildSmartNameSelection', () => { + const base = (over: Record) => ({ + provider: 'github' as const, + type: 'pr' as const, + number: 12, + title: 'T', + url: 'u', + ...over + }) + + it('maps GitHub PR / issue kinds and numbers the label', () => { + expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({ + kind: 'github-pr', + label: '#12 T', + url: 'u' + }) + expect( + buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined }) + ).toMatchObject({ kind: 'github-issue' }) + }) + + it('maps GitLab MR / issue kinds', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-mr' }) + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-issue' }) + }) + + it('maps Linear with a bare title label', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }), + baseBranch: undefined + }) + ).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' }) + }) + + it('falls back to a branch pill', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({ + kind: 'branch', + label: 'main' + }) + }) + + it('returns null when nothing is selected', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull() + }) +}) + +describe('resolveComposerCreateSelection', () => { + const baseCreateArgs = { + branch: null, + reuseEligibleBranch: null, + reuseSelectedBranch: false, + branchCreateIntent: false, + name: '' + } + + it('prefers a linked work item and passes resolved base fields', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 5, + title: 'T', + url: 'u', + repoId: 'repo-1' + }, + base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' } + }) + expect(selection).toMatchObject({ + kind: 'work-item', + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'pr-5' + }) + }) + + it('marks reuse when the eligible branch is toggled on', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: { baseBranch: 'feature', branchNameOverride: 'feature' }, + branch: { refName: 'feature', localBranchName: 'feature' }, + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true + }) + expect(selection).toEqual({ + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + }) + }) + + it('returns a new-branch selection when create-branch intent is set', () => { + expect( + resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: {}, + branchCreateIntent: true, + name: 'feature/login' + }) + ).toEqual({ kind: 'new-branch', branchName: 'feature/login' }) + }) + + it('returns null with no work item, no branch base, and no intent', () => { + expect( + resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} }) + ).toBeNull() + }) +}) + +describe('resolveComposerBranchPick', () => { + it('auto-names and enables reuse for an unused local branch', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: [] + }) + expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' }) + expect(pick).toMatchObject({ + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true, + name: 'feature' + }) + }) + + it('does not reuse a branch already checked out elsewhere', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: ['refs/heads/feature'] + }) + expect(pick.reuseEligibleBranch).toBeNull() + expect(pick.reuseSelectedBranch).toBe(false) + expect(pick.base.branchNameOverride).toBeUndefined() + }) +}) + +// Keep the exported type aliases referenced so the module surface stays covered. +export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue] diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts new file mode 100644 index 00000000000..a99b9ad9f72 --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -0,0 +1,227 @@ +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { + getLinearIssueWorkspaceName, + getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName +} from '../../../src/shared/workspace-name' +import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../src/shared/linear-links' +import { isWorkItemLookupText } from './work-item-lookup-text' +import { + isBranchCheckedOutInWorktrees, + resolveComposerBranchReuse, + resolveComposerBranchSelection, + resolveComposerReuseOverride +} from '../../../src/shared/composer-branch-selection' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export function buildGitHubLinkedWorkItem(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return { + provider: 'github', + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + } +} + +export function buildGitLabLinkedWorkItem(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return { + provider: 'gitlab', + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + } +} + +export function buildLinearLinkedWorkItem(issue: { + identifier: string + title: string + url: string + workspaceId?: string +}): MobileLinkedWorkItem { + const orgUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) + return { + provider: 'linear', + type: 'issue', + number: 0, + title: issue.title, + url: issue.url, + linearIdentifier: issue.identifier, + ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), + ...(orgUrlKey ? { linearOrganizationUrlKey: orgUrlKey } : {}) + } +} + +// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name +// replaces the current field only when it's empty, still the last auto-name, or +// a lookup query — never a name the user deliberately typed. +export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { + return ( + !args.currentName.trim() || + args.currentName === args.lastAutoName || + isWorkItemLookupText(args.currentName) + ) +} + +export function resolveWorkItemAutoName(item: { + type: 'issue' | 'pr' | 'mr' + number: number + title: string + provider: 'github' | 'gitlab' | 'linear' + linearIdentifier?: string +}): string { + return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) +} + +export function resolveLinearAutoName(issue: { identifier: string; title: string }): string { + return getLinearIssueWorkspaceName(issue) +} + +// Derives the pill descriptor from the linked item (or a plain branch base), +// mirroring desktop's smartNameSelection memo. +export function buildSmartNameSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + baseBranch: string | undefined +}): SmartNameSelection | null { + const { linkedWorkItem, baseBranch } = args + if (linkedWorkItem) { + const isLinear = linkedWorkItem.provider === 'linear' + const kind: SmartNameSelection['kind'] = isLinear + ? 'linear' + : linkedWorkItem.provider === 'gitlab' + ? linkedWorkItem.type === 'mr' + ? 'gitlab-mr' + : 'gitlab-issue' + : linkedWorkItem.type === 'pr' + ? 'github-pr' + : 'github-issue' + return { + kind, + label: + isLinear || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + url: linkedWorkItem.url + } + } + if (baseBranch) { + return { kind: 'branch', label: baseBranch } + } + return null +} + +// Derives the create-time selection from composer state: a linked work item wins +// (carrying its resolved base/push fields), else a picked branch, else null (a +// name-only/blank create). +export function resolveComposerCreateSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + base: { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + branch: { refName: string; localBranchName: string } | null + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + branchCreateIntent: boolean + name: string +}): MobileComposerCreateSelection | null { + const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args + if (linkedWorkItem) { + return { + kind: 'work-item', + item: linkedWorkItem, + baseBranch: base.baseBranch, + compareBaseRef: base.compareBaseRef, + pushTarget: base.pushTarget, + branchNameOverride: base.branchNameOverride + } + } + if (branch && base.baseBranch) { + return { + kind: 'branch', + baseBranch: base.baseBranch, + refName: branch.refName, + localBranchName: branch.localBranchName, + reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName, + branchNameOverride: base.branchNameOverride + } + } + if (args.branchCreateIntent && args.name.trim()) { + return { kind: 'new-branch', branchName: args.name.trim() } + } + return null +} + +export type ComposerBranchPick = { + base: { baseBranch: string; branchNameOverride?: string } + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + name?: string + lastAutoName?: string +} + +// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse +// eligibility/default + the auto-name to apply, from the shared branch helpers. +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const { refName, localBranchName } = args + const selection = resolveComposerBranchSelection({ + refName, + localBranchName, + currentName: args.currentName, + lastAutoName: args.lastAutoName + }) + const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( + localBranchName, + args.worktreeBranches + ) + const { reuseEligibleBranch, defaultReuse } = resolveComposerBranchReuse({ + refName, + localBranchName, + selectionProducedOverride: selection.branchNameOverride !== undefined, + branchCheckedOutElsewhere + }) + const effectiveOverride = resolveComposerReuseOverride({ + refName, + localBranchName, + branchNameOverride: selection.branchNameOverride, + branchCheckedOutElsewhere + }) + return { + base: { baseBranch: selection.baseBranch, branchNameOverride: effectiveOverride }, + reuseEligibleBranch, + reuseSelectedBranch: defaultReuse, + ...(selection.name !== undefined && selection.lastAutoName !== undefined + ? { name: selection.name, lastAutoName: selection.lastAutoName } + : {}) + } +} + +export type { GitHubWorkItem, GitLabWorkItem, LinearIssue } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts new file mode 100644 index 00000000000..caf53497d6a --- /dev/null +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -0,0 +1,78 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +// The resolved start point for a linked PR/MR: the base branch to create from +// plus the optional review-compare ref, push target, and exact branch name. +export type ComposerHostedBase = { + baseBranch: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +type HostedBaseResult = ComposerHostedBase | { error: string } + +// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's +// select-time resolution. The runtime returns a soft { error } payload rather +// than an RPC error for provider failures. +export async function resolveComposerPrBase(args: { + client: RpcClient + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${repoId}`, + prNumber, + ...(headRefName ? { headRefName } : {}), + ...(baseRefName ? { baseRefName } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as HostedBaseResult + if ('error' in result) { + throw new Error(result.error) + } + return result +} + +// Resolves a GitLab MR's base via worktree.resolveMrBase. +export async function resolveComposerMrBase(args: { + client: RpcClient + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${repoId}`, + mrIid, + ...(sourceBranch ? { sourceBranch } : {}), + ...(targetBranch ? { targetBranch } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as HostedBaseResult + if ('error' in result) { + throw new Error(result.error) + } + return result +} diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts new file mode 100644 index 00000000000..8fca3f90b31 --- /dev/null +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -0,0 +1,63 @@ +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export type { SmartNameMode } + +// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask +// superset): the one work item a Smart selection pins the workspace to. Linear +// items carry the workspace/org routing the runtime needs to relink the issue. +export type MobileLinkedWorkItem = { + provider: 'github' | 'gitlab' | 'linear' + type: 'issue' | 'pr' | 'mr' + number: number + title: string + url: string + repoId?: string + linearIdentifier?: string + linearWorkspaceId?: string + linearOrganizationUrlKey?: string +} + +export type SmartNameSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + +// The pill descriptor the field renders once a source is selected. Same shape +// as desktop's `SmartWorkspaceNameSelection`. +export type SmartNameSelection = { + kind: SmartNameSelectionKind + label: string + url?: string +} + +// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default +// is 'opened' (Open). +export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' + +// The resolved selection the create flow consumes. Work-item selections carry +// the base/push fields the composer resolved at select time; branch selections +// carry the ref + reuse intent. +export type MobileComposerCreateSelection = + | { + kind: 'work-item' + item: MobileLinkedWorkItem + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + | { + kind: 'branch' + baseBranch: string + refName: string + localBranchName: string + reuse: boolean + branchNameOverride?: string + } + // A brand-new branch created by name (no ref picked); the branch is created off + // the repo's default base and the typed name is kept verbatim as the branch. + | { kind: 'new-branch'; branchName: string } diff --git a/mobile/src/tasks/mobile-smart-source-modes.test.ts b/mobile/src/tasks/mobile-smart-source-modes.test.ts new file mode 100644 index 00000000000..dc726569095 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MR_STATE_FILTER, + MR_STATE_FILTER_OPTIONS, + normalizeSmartMode, + resolveAvailableSmartModes, + resolveDefaultSmartMode +} from './mobile-smart-source-modes' + +const fullyAvailable = { + textOnly: false, + tasksSupported: true, + hasRepo: true, + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true +} + +describe('resolveAvailableSmartModes', () => { + it('lists every mode in desktop order when all are available', () => { + expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([ + 'smart', + 'github', + 'linear', + 'gitlab', + 'branches', + 'text' + ]) + }) + + it('collapses to Name for a non-git (text-only) repo', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text']) + }) + + it('drops provider + smart modes when the tasks RPC surface is missing', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([ + 'branches', + 'text' + ]) + }) + + it('gates provider tabs on their availability and a selected repo', () => { + expect( + resolveAvailableSmartModes({ + ...fullyAvailable, + githubAvailable: false, + gitlabAvailable: false + }) + ).toEqual(['smart', 'linear', 'branches', 'text']) + expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([ + 'smart', + 'linear', + 'text' + ]) + }) +}) + +describe('resolveDefaultSmartMode', () => { + it('defaults to smart for a git repo with search', () => { + expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart') + }) + + it('defaults to text for a non-git repo', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text') + }) + + it('defaults to branches for a git repo without the tasks surface', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches') + }) +}) + +describe('normalizeSmartMode', () => { + it('keeps a valid mode and snaps an unavailable one back to default', () => { + expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab') + expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe( + 'smart' + ) + }) +}) + +describe('MR state filters', () => { + it('exposes Open/Merged/Closed/All with an Open default', () => { + expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all']) + expect(DEFAULT_MR_STATE_FILTER).toBe('opened') + }) +}) diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts new file mode 100644 index 00000000000..911219d28c4 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.ts @@ -0,0 +1,93 @@ +import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types' + +// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand +// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand +// icons. +export type SmartModeIcon = + | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' } + | { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' } + +export type SmartModeOption = { + id: SmartNameMode + label: string + icon: SmartModeIcon +} + +// Order + labels + icons mirror desktop getSmartWorkspaceNameModes(): +// Smart · GitHub · Linear · GitLab · Branch · Name. +export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [ + { id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } }, + { id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } }, + { id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } }, + { id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } }, + { id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } }, + { id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } } +] + +export type SmartModeAvailabilityInput = { + textOnly: boolean + tasksSupported: boolean + hasRepo: boolean + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean +} + +// Faithful port of the desktop availableModes filter. Non-git repos collapse to +// the Name tab; provider tabs gate on availability + a selected repo + the tasks +// RPC surface; branches only need a git repo (new-branch-by-name works without +// the search capability). +export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] { + if (input.textOnly) { + return ['text'] + } + return SMART_MODE_OPTIONS.filter((option) => { + switch (option.id) { + case 'smart': + return input.tasksSupported + case 'github': + return input.tasksSupported && input.hasRepo && input.githubAvailable + case 'gitlab': + return input.tasksSupported && input.hasRepo && input.gitlabAvailable + case 'linear': + return input.tasksSupported && input.linearAvailable + case 'branches': + return input.hasRepo + case 'text': + return true + } + }).map((option) => option.id) +} + +// Default mode when the picker opens: 'smart' for a git repo when search is +// available, else the first available mode (branches for git without tasks, +// 'text' for non-git). +export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode { + const available = resolveAvailableSmartModes(input) + if (available.includes('smart')) { + return 'smart' + } + return available[0] ?? 'text' +} + +// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a +// non-git folder), mirroring desktop's snap-to-available effect. +export function normalizeSmartMode( + mode: SmartNameMode, + input: SmartModeAvailabilityInput +): SmartNameMode { + const available = resolveAvailableSmartModes(input) + return available.includes(mode) ? mode : resolveDefaultSmartMode(input) +} + +export type MrStateFilterOption = { id: MrStateFilter; label: string } + +// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'. +export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [ + { id: 'opened', label: 'Open' }, + { id: 'merged', label: 'Merged' }, + { id: 'closed', label: 'Closed' }, + { id: 'all', label: 'All' } +] + +export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened' diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts new file mode 100644 index 00000000000..1842b892d67 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch } from './smart-source-fan-out' + +type Call = { method: string; params: Record } + +function fakeClient(byMethod: Record, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = byMethod[method] + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const smartArgs = { + mode: 'smart' as const, + query: 'bug', + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true, + mrStateFilter: 'opened' as const, + linearWorkspaceId: null +} + +describe('fanOutSmartSearch', () => { + it('fans out to every provider in smart mode and stamps repoId', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] }, + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] }, + 'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(calls.map((c) => c.method).sort()).toEqual([ + 'github.listWorkItems', + 'gitlab.listWorkItems', + 'linear.searchIssues', + 'repo.searchRefs' + ]) + expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' }) + expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' }) + expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' }) + expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }]) + expect(result.error).toBe('') + }) + + it('swallows a single provider failure in smart mode (best-effort)', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': new Error('gh down'), + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [] }, + 'repo.searchRefs': { refDetails: [] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(result.error).toBe('') + expect(result.gitlabItems).toHaveLength(1) + }) + + it('surfaces the error for a single-provider mode', async () => { + const calls: Call[] = [] + const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls) + const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client }) + expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems']) + expect(result.error).toBe('gl boom') + }) + + it('only searches branches in smart mode when the query is non-empty', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, query: '', client }) + expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs') + }) + + it('skips GitHub in smart mode when GitHub is unavailable', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client }) + expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems') + }) +}) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts new file mode 100644 index 00000000000..bb98c022741 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -0,0 +1,133 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + searchBranches, + searchGitHubItems, + searchGitLabItems, + searchLinearIssues +} from './smart-source-search-requests' + +export type SmartFanOutResult = { + githubItems: GitHubWorkItem[] + gitlabItems: GitLabWorkItem[] + linearIssues: LinearIssue[] + branches: BaseRefSearchResult[] + needsGitHubRemote: boolean + error: string +} + +const EMPTY: Omit = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] +} + +function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean { + return githubAvailable && (mode === 'smart' || mode === 'github') +} + +function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean { + return gitlabAvailable && (mode === 'smart' || mode === 'gitlab') +} + +function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean { + return linearAvailable && (mode === 'smart' || mode === 'linear') +} + +function shouldSearchBranches(mode: SmartNameMode, query: string): boolean { + return mode === 'branches' || (mode === 'smart' && query.trim().length > 0) +} + +type FanOutArgs = { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined +} + +// Runs every provider search the active mode needs, concurrently. Smart mode is +// best-effort (a single provider failure never blocks the others); single-provider +// modes surface the failure. No cross-provider ranking/dedup — the shared row +// builder concatenates in provider order. +export async function fanOutSmartSearch(args: FanOutArgs): Promise { + const { + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter + } = args + const isSmart = mode === 'smart' + const tasks = { + github: + shouldSearchGitHub(mode, githubAvailable) && repoId + ? searchGitHubItems(client, repoId, query) + : null, + gitlab: + shouldSearchGitLab(mode, gitlabAvailable) && repoId + ? searchGitLabItems(client, repoId, query, mrStateFilter) + : null, + linear: shouldSearchLinear(mode, linearAvailable) + ? searchLinearIssues(client, query, args.linearWorkspaceId) + : null, + branches: + shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null + } + const [github, gitlab, linear, branches] = await Promise.allSettled([ + tasks.github ?? Promise.resolve([]), + tasks.gitlab ?? Promise.resolve([]), + tasks.linear ?? Promise.resolve([]), + tasks.branches ?? Promise.resolve([]) + ]) + + let needsGitHubRemote = false + let error = '' + const fail = (reason: unknown) => { + if (!isSmart) { + error = reason instanceof Error ? reason.message : 'Search failed' + } + } + if (github.status === 'rejected') { + if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) { + needsGitHubRemote = true + } else { + fail(github.reason) + } + } + if (gitlab.status === 'rejected') { + fail(gitlab.reason) + } + if (linear.status === 'rejected') { + fail(linear.reason) + } + if (branches.status === 'rejected') { + fail(branches.reason) + } + + return { + ...EMPTY, + githubItems: github.status === 'fulfilled' ? github.value : [], + gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [], + linearIssues: linear.status === 'fulfilled' ? linear.value : [], + branches: branches.status === 'fulfilled' ? branches.value : [], + needsGitHubRemote, + error + } +} diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts new file mode 100644 index 00000000000..0130e2cad53 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + deriveRepoSlug, + findRepoMatchingSlug, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' + +describe('resolvePasteIntent', () => { + it('classifies a GitHub issue/PR URL as a github-link', () => { + expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ + kind: 'github-link', + link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + }) + }) + + it('classifies a bare #number as a github-number', () => { + expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 }) + }) + + it('classifies a GitLab MR URL as a gitlab-link', () => { + const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8') + expect(intent?.kind).toBe('gitlab-link') + if (intent?.kind === 'gitlab-link') { + expect(intent.link).toMatchObject({ number: 8, type: 'mr' }) + } + }) + + it('returns null for plain search text', () => { + expect(resolvePasteIntent('login bug')).toBeNull() + }) +}) + +describe('deriveRepoSlug', () => { + it('prefers the upstream identity', () => { + expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({ + owner: 'up', + repo: 'stream' + }) + }) + + it('parses an SSH remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('parses an HTTPS remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('returns null when no slug can be derived', () => { + expect(deriveRepoSlug({})).toBeNull() + }) +}) + +describe('findRepoMatchingSlug', () => { + const repos: PasteRepoCandidate[] = [ + { id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } }, + { id: 'b', displayName: 'B', slug: null } + ] + + it('matches case-insensitively', () => { + expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a') + }) + + it('returns null when no repo matches', () => { + expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() + }) +}) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts new file mode 100644 index 00000000000..c6fa9cdd331 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -0,0 +1,138 @@ +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + normalizeGitHubLinkQuery, + parseGitHubIssueOrPRLink, + type GitHubIssueOrPRLink, + type RepoSlug +} from '../../../src/shared/new-workspace/github-links' +import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' + +// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived +// best-effort from the repo's remote metadata. +export type PasteRepoCandidate = { + id: string + displayName: string + slug: RepoSlug | null +} + +export type GitHubPasteIntent = + | { kind: 'github-link'; link: GitHubIssueOrPRLink } + | { kind: 'github-number'; number: number } + +export type GitLabPasteIntent = { + kind: 'gitlab-link' + link: NonNullable> +} + +export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null + +// Pure: classify pasted text into a work-item lookup intent. A slug-bearing +// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number'; +// a GitLab issue/MR URL becomes 'gitlab-link'. +export function resolvePasteIntent(query: string): PasteIntent { + const trimmed = query.trim() + if (!trimmed) { + return null + } + const ghLink = parseGitHubIssueOrPRLink(trimmed) + if (ghLink) { + return { kind: 'github-link', link: ghLink } + } + const normalizedGh = normalizeGitHubLinkQuery(trimmed) + if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) { + return { kind: 'github-number', number: normalizedGh.directNumber } + } + const glLink = parseGitLabIssueOrMRLink(trimmed) + if (glLink) { + return { kind: 'gitlab-link', link: glLink } + } + return null +} + +// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted +// cross-repo URL can be matched to a locally known repo. +export function deriveRepoSlug(repo: { + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null +}): RepoSlug | null { + if (repo.upstream?.owner && repo.upstream.repo) { + return { owner: repo.upstream.owner, repo: repo.upstream.repo } + } + const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' + const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) + if (match) { + return { owner: match[1], repo: match[2] } + } + return null +} + +function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { + if (!a || !b) { + return false + } + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +export function findRepoMatchingSlug( + repos: readonly PasteRepoCandidate[], + slug: RepoSlug +): PasteRepoCandidate | null { + return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null +} + +export async function lookupGitHubItemByNumber( + client: RpcClient, + repoId: string, + number: number +): Promise { + const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitHubItemByOwnerRepo( + client: RpcClient, + repoId: string, + slug: RepoSlug, + number: number, + type: 'issue' | 'pr' +): Promise { + const response = await client.sendRequest('github.workItemByOwnerRepo', { + repo: `id:${repoId}`, + owner: slug.owner, + ownerRepo: slug.repo, + number, + type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitLabItemByPath( + client: RpcClient, + repoId: string, + link: NonNullable> +): Promise { + const response = await client.sendRequest('gitlab.workItemByPath', { + repo: `id:${repoId}`, + host: link.slug.host, + path: link.slug.path, + iid: link.number, + type: link.type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitLabWorkItem | null + return item ? { ...item, repoId } : null +} diff --git a/mobile/src/tasks/smart-source-search-requests.test.ts b/mobile/src/tasks/smart-source-search-requests.test.ts new file mode 100644 index 00000000000..ecf029feb70 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests' + +type Call = { method: string; params: Record } + +function fakeClient(result: unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('scopeGitHubQuery', () => { + it('passes the raw trimmed query so BOTH issues and PRs are returned', () => { + // Empty stays empty (runtime lists recent issues + PRs); no forced is:issue. + expect(scopeGitHubQuery('')).toBe('') + expect(scopeGitHubQuery(' login bug ')).toBe('login bug') + }) + + it('preserves an explicit is:pr / is:issue scope the user typed', () => { + expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth') + expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth') + }) +}) + +describe('searchLinearIssues', () => { + it('lists assigned issues for an empty query (desktop default)', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, '', null) + expect(calls[0]!.method).toBe('linear.listIssues') + expect(calls[0]!.params).toMatchObject({ filter: 'assigned' }) + }) + + it('searches when a query is present', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, 'bug', 'ws-1') + expect(calls[0]!.method).toBe('linear.searchIssues') + expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' }) + }) +}) diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts new file mode 100644 index 00000000000..5875cd4d642 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -0,0 +1,119 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { PER_REPO_FETCH_LIMIT } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' + +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +const BRANCH_LIMIT = 20 + +// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's +// parseTaskQuery defaults scope 'all', and an empty query lists recent items of +// both types. So pass the raw trimmed query straight through (an explicit +// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty +// so the runtime lists recent issues + PRs. +export function scopeGitHubQuery(query: string): string { + return query.trim() +} + +export async function searchGitHubItems( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest('github.listWorkItems', { + repo: `id:${repoId}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubQuery(query) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] } + // Stamp repoId so the shared row builder + create flow can attribute each item + // to the searched repo (the runtime omits it, like the desktop fetcher). + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchGitLabItems( + client: RpcClient, + repoId: string, + query: string, + state: MrStateFilter +): Promise { + const response = await client.sendRequest('gitlab.listWorkItems', { + repo: `id:${repoId}`, + state, + page: 1, + perPage: GITLAB_PER_PAGE, + query: query.trim() || undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { + items: GitLabWorkItem[] + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + throw new Error(envelope.error.message) + } + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchLinearIssues( + client: RpcClient, + query: string, + linearWorkspaceId: string | null | undefined +): Promise { + const trimmed = query.trim() + const response = trimmed + ? await client.sendRequest('linear.searchIssues', { + query: trimmed, + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + : await client.sendRequest('linear.listIssues', { + // Empty query lists the viewer's assigned issues, matching desktop's + // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). + filter: 'assigned', + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + // extractLinearIssueReadItems yields the mobile issue-read shape; the fields the + // row builder/create flow read (id/identifier/title/url/state/team) are a subset. + return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[] +} + +export async function searchBranches( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest( + 'repo.searchRefs', + { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + return ( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) +} diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index 5895e7ccfea..d23e0c66317 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -1,21 +1,15 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { createWorkspaceFromSource } from './source-workspace-create' -import { - buildBranchSource, - buildGitHubTaskSource, - buildGitLabTaskSource, - buildLinearTaskSource, - buildNewBranchSource -} from './workspace-source-selection' +import { createWorkspaceFromComposerSource } from './source-workspace-create' +import type { MobileComposerCreateSelection } from './mobile-composer-source-types' -type Call = { method: string; params: unknown } +type Call = { method: string; params: Record } -function fakeClient(script: (method: string) => unknown, calls: Call[]): RpcClient { +function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient { return { sendRequest: async (method: string, params?: unknown) => { - calls.push({ method, params }) - const result = script(method) + calls.push({ method, params: (params ?? {}) as Record }) + const result = handle(method, calls.length) if (result instanceof Error) { return { id: '1', @@ -29,192 +23,198 @@ function fakeClient(script: (method: string) => unknown, calls: Call[]): RpcClie } as unknown as RpcClient } -describe('createWorkspaceFromSource', () => { - it('resolves the PR base then creates a workspace linked to the PR', async () => { +const agent = { choice: 'blank' as const, startupCommand: undefined } + +const baseArgs = { + targetRepoId: 'repo-1', + setupDecision: 'inherit' as const, + agent, + workspaceName: undefined, + note: undefined +} + +describe('createWorkspaceFromComposerSource', () => { + it('creates a GitHub issue workspace linking the issue to its own repo', async () => { const calls: Call[] = [] - const client = fakeClient((method) => { - if (method === 'worktree.resolvePrBase') { - return { baseBranch: 'main', pushTarget: { remoteName: 'origin', branchName: 'feat' } } - } - return { worktree: { id: 'wt-1' } } - }, calls) - - const result = await createWorkspaceFromSource({ - client, - source: buildGitHubTaskSource('repo-1', { - type: 'pr', - number: 42, - title: 'Fix login', - url: 'https://github.com/acme/app/pull/42', - branchName: 'feat', - isCrossRepository: false - }) as never, - targetRepoId: 'repo-1', - setupDecision: 'inherit', - agent: { choice: 'codex', startupCommand: undefined }, - workspaceName: undefined, - note: undefined - }) - - expect(result).toEqual({ worktreeId: 'wt-1', name: 'pr-42' }) - expect(calls[0]).toEqual({ - method: 'worktree.resolvePrBase', - params: { repo: 'id:repo-1', prNumber: 42, headRefName: 'feat', isCrossRepository: false } - }) - const createParams = calls[1]?.params as Record - expect(calls[1]?.method).toBe('worktree.create') - expect(createParams.linkedPR).toBe(42) - expect(createParams.baseBranch).toBe('main') - expect(createParams.createdWithAgent).toBe('codex') - expect(createParams.startupDraft).toBe('https://github.com/acme/app/pull/42') - expect(createParams.pushTarget).toEqual({ remoteName: 'origin', branchName: 'feat' }) - }) - - it('resolves the MR base then creates a workspace linked to the GitLab MR', async () => { - const calls: Call[] = [] - const client = fakeClient((method) => { - if (method === 'worktree.resolveMrBase') { - return { baseBranch: 'develop', pushTarget: { remoteName: 'origin', branchName: 'fix' } } - } - return { worktree: { id: 'wt-mr' } } - }, calls) - - const result = await createWorkspaceFromSource({ - client, - source: buildGitLabTaskSource('repo-2', { - type: 'mr', + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', number: 7, - title: 'Fix logout', - url: 'https://gitlab.com/acme/app/-/merge_requests/7', - branchName: 'fix', - isCrossRepository: true - }) as never, - targetRepoId: 'repo-2', - setupDecision: 'inherit', - agent: { choice: 'blank', startupCommand: undefined }, - workspaceName: undefined, - note: undefined + title: 'Bug', + url: 'u', + repoId: 'repo-9' + } + } + // The composer supplies the title-derived name as workspaceName; with none, + // buildTaskWorkspaceCreateParams falls back to the "-" slug. + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.method).toBe('worktree.create') + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-9', + linkedIssue: 7, + displayName: 'Bug' }) - - expect(result).toEqual({ worktreeId: 'wt-mr', name: 'mr-7' }) - expect(calls[0]).toEqual({ - method: 'worktree.resolveMrBase', - params: { repo: 'id:repo-2', mrIid: 7, sourceBranch: 'fix', isCrossRepository: true } - }) - expect(calls[1]?.method).toBe('worktree.create') - const createParams = calls[1]?.params as Record - expect(createParams.linkedGitLabMR).toBe(7) - expect(createParams.baseBranch).toBe('develop') - expect(createParams.pushTarget).toEqual({ remoteName: 'origin', branchName: 'fix' }) }) - it('creates a branch workspace with baseBranch + branchNameOverride and no resolve call', async () => { + it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => { const calls: Call[] = [] const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) - - const result = await createWorkspaceFromSource({ - client, - source: buildBranchSource('origin/main', 'main') as never, - targetRepoId: 'repo-9', - setupDecision: 'run', - agent: { choice: 'blank', startupCommand: undefined }, - workspaceName: undefined, - note: undefined + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'pr', + number: 3, + title: 'Feat', + url: 'u', + repoId: 'repo-1' + }, + baseBranch: 'main', + compareBaseRef: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' }, + branchNameOverride: 'feat-3' + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.create']) + expect(calls[0]!.params).toMatchObject({ + linkedPR: 3, + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feat-3', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' } }) - - expect(result).toEqual({ worktreeId: 'wt-2', name: 'main' }) - expect(calls).toHaveLength(1) - expect(calls[0]?.method).toBe('worktree.create') - const params = calls[0]?.params as Record - expect(params).toMatchObject({ - repo: 'id:repo-9', - name: 'main', - baseBranch: 'origin/main', - branchNameOverride: 'main', - setupDecision: 'run' - }) - expect(params.createdWithAgent).toBeUndefined() }) - it('links a Linear issue to the target repo without a resolve call', async () => { + it('resolves a PR base as a fallback when the selection carries none', async () => { const calls: Call[] = [] - const client = fakeClient(() => ({ worktree: { id: 'wt-3' } }), calls) - - await createWorkspaceFromSource({ - client, - source: buildLinearTaskSource({ - identifier: 'ENG-42', - title: 'Onboarding', - url: 'https://linear.app/acme/issue/ENG-42' - }) as never, - targetRepoId: 'repo-5', - setupDecision: 'inherit', - agent: { choice: 'blank', startupCommand: undefined }, - workspaceName: undefined, - note: undefined - }) - - expect(calls).toHaveLength(1) - const params = calls[0]?.params as Record - expect(params.repo).toBe('id:repo-5') - expect(params.linkedLinearIssue).toBe('ENG-42') + const client = fakeClient( + (method) => + method === 'worktree.resolvePrBase' + ? { baseBranch: 'develop' } + : { worktree: { id: 'wt-3' } }, + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create']) + expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 }) }) - it('bumps the branch name (not just display) when a new branch collides', async () => { + it('creates a Linear workspace with workspace + org routing', async () => { const calls: Call[] = [] - let attempt = 0 - const client = fakeClient((method) => { - if (method === 'worktree.create') { - attempt += 1 - if (attempt === 1) { - return new Error('Branch feature already exists locally') - } + const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' } - return { worktree: { id: 'wt-nb' } } - }, calls) - - const result = await createWorkspaceFromSource({ - client, - source: buildNewBranchSource('main', 'feature') as never, - targetRepoId: 'repo-1', - setupDecision: 'inherit', - agent: { choice: 'blank', startupCommand: undefined }, - workspaceName: undefined, - note: undefined + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + linkedLinearIssue: 'ENG-9', + linkedLinearIssueWorkspaceId: 'ws-1', + linkedLinearIssueOrganizationUrlKey: 'acme' }) - - expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature-2' }) - const creates = calls.filter((call) => call.method === 'worktree.create') - expect(creates).toHaveLength(2) - const first = creates[0]?.params as Record - const second = creates[1]?.params as Record - expect(first.branchNameOverride).toBe('feature') - expect(second.branchNameOverride).toBe('feature-2') - expect(second.name).toBe('feature-2') - expect(second.baseBranch).toBe('main') }) - it('fails fast (no retry) when reusing an existing branch that collides', async () => { + it('reuses an existing branch with a single attempt (no suffix retry)', async () => { const calls: Call[] = [] - const client = fakeClient((method) => { - if (method === 'worktree.create') { - return new Error('Branch "main" already exists locally.') - } - return { worktree: { id: 'wt-4' } } - }, calls) - - const result = await createWorkspaceFromSource({ - client, - source: buildBranchSource('origin/main', 'main') as never, - targetRepoId: 'repo-1', - setupDecision: 'inherit', - agent: { choice: 'blank', startupCommand: undefined }, - workspaceName: undefined, - note: undefined + const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect('error' in result).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.params).toMatchObject({ + baseBranch: 'feature', + branchNameOverride: 'feature' }) + }) - // branchNameOverride is fixed for reuse, so suffixing can't help — one attempt only. - expect(result).toEqual({ error: 'Branch "main" already exists locally.' }) - expect(calls.filter((call) => call.method === 'worktree.create')).toHaveLength(1) + it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'feature/login' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + name: 'feature/login', + branchNameOverride: 'feature/login' + }) + }) + + it('suppresses displayName when the name is user-edited (not auto-managed)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-1' + } + } + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: 'my-name', + nameIsAutoManaged: false + }) + expect(calls[0]!.params.displayName).toBeUndefined() + expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 }) + }) + + it('creates a new branch off a ref, bumping the branch on collision', async () => { + const calls: Call[] = [] + const client = fakeClient( + (_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }), + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' }) + expect(calls).toHaveLength(2) + expect(calls[1]!.params).toMatchObject({ + baseBranch: 'main', + branchNameOverride: 'topic-2', + name: 'topic-2' + }) }) }) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index fb190334dd3..d16cc174142 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -1,72 +1,137 @@ import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { shouldResolveHostedReviewStartPoint } from './hosted-review-start-point' +import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem +} from './mobile-composer-source-types' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' +import type { WorkspaceAgentChoice } from './workspace-agent-selection' import { buildTaskWorkspaceCreateParams, - type WorkspaceCreateHostedStartPoint, - type WorkspaceCreateSetupDecision + type WorkspaceCreateSetupDecision, + type WorkspaceCreateTaskItem } from './workspace-create-params' -import type { WorkspaceAgentChoice } from './workspace-agent-selection' -import type { WorkspaceSource } from './workspace-source-selection' import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' -// The agent bundle the modal already resolved: the choice drives buildTaskWorkspaceCreateParams -// for task sources; the explicit launch command is used for branch sources (which -// have no work-item URL to seed the agent draft). +// The agent bundle the modal already resolved: the choice drives +// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch +// command is used for branch sources (which have no work-item URL to seed the draft). export type WorkspaceCreateAgentBundle = { choice: WorkspaceAgentChoice startupCommand: string | undefined } -type NonBlankSource = Exclude - -export async function createWorkspaceFromSource(args: { +export type CreateWorkspaceFromComposerArgs = { client: RpcClient - source: NonBlankSource + selection: MobileComposerCreateSelection targetRepoId: string setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined -}): Promise { - const { source } = args - if (source.kind === 'branch' || source.kind === 'new-branch') { - return createBranchWorkspace({ ...args, source }) - } - return createTaskWorkspace({ ...args, source }) + nameIsAutoManaged?: boolean } -async function createTaskWorkspace(args: { +export async function createWorkspaceFromComposerSource( + args: CreateWorkspaceFromComposerArgs +): Promise { + if (args.selection.kind === 'branch') { + return createBranchWorkspace({ ...args, selection: args.selection }) + } + if (args.selection.kind === 'new-branch') { + return createNewBranchWorkspace({ ...args, selection: args.selection }) + } + return createWorkItemWorkspace({ ...args, selection: args.selection }) +} + +function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem { + if (item.provider === 'github') { + return { + provider: 'github', + source: { + type: item.type === 'pr' ? 'pr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + if (item.provider === 'gitlab') { + return { + provider: 'gitlab', + source: { + type: item.type === 'mr' ? 'mr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + return { + provider: 'linear', + source: { + identifier: item.linearIdentifier ?? '', + title: item.title, + url: item.url, + ...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}), + ...(item.linearOrganizationUrlKey + ? { organizationUrlKey: item.linearOrganizationUrlKey } + : {}) + } + } +} + +async function createWorkItemWorkspace(args: { client: RpcClient - source: Extract + selection: Extract targetRepoId: string setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined + nameIsAutoManaged?: boolean }): Promise { - const { client, source, targetRepoId, setupDecision, agent, workspaceName, note } = args + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const item = selection.item + const taskItem = toTaskItem(item, targetRepoId) - let hostedStartPoint: WorkspaceCreateHostedStartPoint | undefined - if ( - (source.hostedType === 'pr' || source.hostedType === 'mr') && - shouldResolveHostedReviewStartPoint({ type: source.hostedType }) - ) { - hostedStartPoint = await resolveHostedStartPoint(client, source) + // The composer resolves PR/MR base at select time; only re-resolve as a + // fallback when a linked PR/MR reached create without one. + let baseBranch = selection.baseBranch + let compareBaseRef = selection.compareBaseRef + let pushTarget = selection.pushTarget + let branchNameOverride = selection.branchNameOverride + if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) { + const repoId = item.repoId ?? targetRepoId + const resolved = + item.type === 'pr' + ? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null) + : await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null) + if (resolved) { + baseBranch = resolved.baseBranch + compareBaseRef = resolved.compareBaseRef + pushTarget = resolved.pushTarget + branchNameOverride = resolved.branchNameOverride ?? branchNameOverride + } } const params = buildTaskWorkspaceCreateParams({ - item: source.item, + item: taskItem, targetRepoId, setupDecision, agent: agent.choice, workspaceName, note, - hostedStartPoint + baseBranch, + compareBaseRef, + branchNameOverride, + pushTarget, + nameIsAutoManaged: args.nameIsAutoManaged }) - // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base so - // collisions still append -2, -3, ... like the blank path does. + // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base + // so collisions still append -2, -3, ... like the blank path does. const baseName = String(params.name) return createWorktreeWithNameRetry({ client, @@ -75,51 +140,16 @@ async function createTaskWorkspace(args: { }) } -// Faithful port of the PR/MR base-resolve in mobile/app/h/[hostId]/tasks.tsx: the -// runtime returns either a start point or a soft { error } payload (not an RPC error). -async function resolveHostedStartPoint( - client: RpcClient, - source: Extract -): Promise { - const item = source.item - if (item.provider === 'linear') { - // Unreachable: only github/gitlab pr/mr sources resolve a hosted base. - throw new Error('Linear sources do not resolve a hosted base branch') - } - const method = item.provider === 'github' ? 'worktree.resolvePrBase' : 'worktree.resolveMrBase' - const numberKey = item.provider === 'github' ? 'prNumber' : 'mrIid' - const branchKey = item.provider === 'github' ? 'headRefName' : 'sourceBranch' - const params: Record = { - repo: `id:${item.source.repoId}`, - [numberKey]: item.source.number, - ...(source.branchName ? { [branchKey]: source.branchName } : {}), - ...(source.isCrossRepository !== undefined - ? { isCrossRepository: source.isCrossRepository } - : {}) - } - const response = await client.sendRequest(method, params, { timeoutMs: 30_000 }) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as - | WorkspaceCreateHostedStartPoint - | { error: string } - if ('error' in result) { - throw new Error(result.error) - } - return result -} - async function createBranchWorkspace(args: { client: RpcClient - source: Extract + selection: Extract targetRepoId: string setupDecision: WorkspaceCreateSetupDecision agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined }): Promise { - const { client, source, targetRepoId, setupDecision, agent, workspaceName, note } = args + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice const comment = note?.trim() const applyCommon = (params: Record): Record => { @@ -132,51 +162,89 @@ async function createBranchWorkspace(args: { return params } - if (source.kind === 'new-branch') { - // New branch: the retry base is the branch name so a collision bumps the - // branch itself (the colliding constraint), not just the display name. An - // empty baseRef leaves baseBranch unset so the runtime picks the default base. - const baseBranch = source.baseRefName - const displayNameOverride = workspaceName?.trim() + if (selection.reuse) { + // Reusing a fixed existing branch: branchNameOverride is pinned to the reused + // branch, so a branch collision can't be cleared by suffixing the display + // name — fail fast instead of burning the retry budget. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.localBranchName + }) return createWorktreeWithNameRetry({ client, - baseName: source.branchName, - buildParams: (candidateBranch) => { - const params: Record = { + baseName, + maxAttempts: 1, + buildParams: (name) => + applyCommon({ repo: `id:${targetRepoId}`, - name: displayNameOverride || candidateBranch, + name, setupDecision, - branchNameOverride: candidateBranch, + baseBranch: selection.refName, + branchNameOverride: selection.localBranchName, startupCommand: agent.startupCommand - } - if (baseBranch) { - params.baseBranch = baseBranch - } - return applyCommon(params) - } + }) }) } - // Existing branch: reuse the branch off its ref. branchNameOverride is fixed to - // the reused branch, so a branch collision (e.g. it's checked out elsewhere) - // can't be cleared by suffixing the display name — fail fast instead of - // retrying 25 identical creates. + // New branch off the selected ref. The retry base is the branch name so a + // collision bumps the branch itself. const baseName = resolveMobileWorkspaceCreateName({ draft: workspaceName, - fallback: source.localBranchName + fallback: selection.branchNameOverride || selection.localBranchName }) return createWorktreeWithNameRetry({ client, baseName, - maxAttempts: 1, - buildParams: (name) => - applyCommon({ + buildParams: (candidate) => { + const params: Record = { repo: `id:${targetRepoId}`, - name, + name: candidate, setupDecision, - baseBranch: source.refName, - branchNameOverride: source.localBranchName, + baseBranch: selection.baseBranch, startupCommand: agent.startupCommand - }) + } + if (selection.branchNameOverride) { + params.branchNameOverride = candidate + } + return applyCommon(params) + } + }) +} + +async function createNewBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + // A brand-new branch off the repo's default base. The typed name is kept as the + // git branch (via branchNameOverride) so a slash like `feature/login` survives; + // the runtime sanitizes the worktree folder from the same name. The retry base is + // the branch name so a collision bumps the branch (and folder) together. + return createWorktreeWithNameRetry({ + client, + baseName: selection.branchName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + branchNameOverride: candidate, + startupCommand: agent.startupCommand + } + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } }) } diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts new file mode 100644 index 00000000000..6fce6d14e4b --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -0,0 +1,334 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' +import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' +import type { RpcClient } from '../transport/rpc-client' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveLinearAutoName, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' +import { + resolveComposerMrBase, + resolveComposerPrBase, + type ComposerHostedBase +} from './composer-source-base-resolve' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +const EMPTY_BASE: ComposerBaseState = {} + +export type UseMobileComposerSourceArgs = { + client: RpcClient | null + selectedRepoId: string | null + worktreeBranches?: readonly string[] + onError?: (message: string) => void +} + +export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { + const { client, selectedRepoId, worktreeBranches = [], onError } = args + const [name, setNameState] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [base, setBase] = useState(EMPTY_BASE) + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) + const [forkPushWarning, setForkPushWarning] = useState(null) + const [resolvingBase, setResolvingBase] = useState(false) + // Set when the "Create branch " row is picked, so the typed name (which + // may contain slashes) is kept verbatim as the git branch (folder is sanitized). + const [branchCreateIntent, setBranchCreateIntent] = useState(false) + + const lastAutoNameRef = useRef('') + const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) + // Guards async base resolution: only the latest selection applies its result. + const resolveTokenRef = useRef(0) + + const setName = useCallback((value: string) => setNameState(value), []) + + const applyAutoName = useCallback((suggested: string, currentName: string) => { + if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, []) + + const clearBaseAndBranch = useCallback(() => { + branchSelectionRef.current = null + setBranchCreateIntent(false) + setBase(EMPTY_BASE) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + setForkPushWarning(null) + // Why: a superseding selection bumps the resolve token, so an in-flight base + // resolve's token-gated finally can no longer clear this — reset it here so + // resolvingBase never sticks true after switching sources. + setResolvingBase(false) + }, []) + + // Applies an async PR/MR base resolution guarded by the current token so only + // the latest selection wins; failures clear the base and surface the error. + const runBaseResolve = useCallback( + (token: number, resolve: Promise) => { + setResolvingBase(true) + void resolve + .then((result) => { + if (resolveTokenRef.current !== token) { + return + } + setBase({ + baseBranch: result.baseBranch, + compareBaseRef: result.compareBaseRef, + pushTarget: result.pushTarget, + branchNameOverride: result.branchNameOverride + }) + }) + .catch((error: unknown) => { + if (resolveTokenRef.current !== token) { + return + } + setBase(EMPTY_BASE) + onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.') + }) + .finally(() => { + if (resolveTokenRef.current === token) { + setResolvingBase(false) + } + }) + }, + [onError] + ) + + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem) => { + const token = (resolveTokenRef.current += 1) + const identity = resolveGitHubWorkItemIdentity(item) + // Resolve the PR base against the item's OWN repo — a cross-repo accept + // switches repos then selects synchronously, so selectedRepoId is stale. + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitHubLinkedWorkItem({ + type: identity.type, + number: identity.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }), + name + ) + clearBaseAndBranch() + if (identity.type !== 'pr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerPrBase({ + client, + repoId, + prNumber: identity.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartGitLabItemSelect = useCallback( + (item: GitLabWorkItem) => { + const token = (resolveTokenRef.current += 1) + // Resolve the MR base against the item's OWN repo (see the GitHub handler). + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitLabLinkedWorkItem({ + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ + type: item.type, + number: item.number, + title: item.title, + provider: 'gitlab' + }), + name + ) + clearBaseAndBranch() + if (item.type !== 'mr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerMrBase({ + client, + repoId, + mrIid: item.number, + ...(item.branchName ? { sourceBranch: item.branchName } : {}), + ...(item.baseRefName ? { targetBranch: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) + const suggested = resolveLinearAutoName(issue) + const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + if ( + suggested && + (identifierTyped || + shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + clearBaseAndBranch() + }, + [clearBaseAndBranch, name] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string, localBranchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + setForkPushWarning(null) + setBranchCreateIntent(false) + setResolvingBase(false) + const pick = resolveComposerBranchPick({ + refName, + localBranchName, + currentName: name, + lastAutoName: lastAutoNameRef.current, + worktreeBranches + }) + setReuseEligibleBranch(pick.reuseEligibleBranch) + setReuseSelectedBranch(pick.reuseSelectedBranch) + setBase(pick.base) + branchSelectionRef.current = { refName, localBranchName } + if (pick.name !== undefined) { + setNameState(pick.name) + lastAutoNameRef.current = pick.lastAutoName ?? '' + } + }, + [name, worktreeBranches] + ) + + // Picking "Create branch ": name the workspace and mark a new-branch + // intent so the typed (possibly slashy) name is kept verbatim as the git branch. + const handleSmartCreateBranch = useCallback( + (branchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setNameState(branchName) + lastAutoNameRef.current = branchName + setBranchCreateIntent(true) + }, + [clearBaseAndBranch] + ) + + const handleClearSmartNameSelection = useCallback(() => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setResolvingBase(false) + if (name === lastAutoNameRef.current) { + setNameState('') + lastAutoNameRef.current = '' + } + }, [clearBaseAndBranch, name]) + + const handleBranchNameOverrideChange = useCallback((value: string) => { + setBase((prev) => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget: prev.pushTarget, + forkPushWarning: null + }) + return { + ...prev, + branchNameOverride: next.branchNameOverride, + pushTarget: next.pushTarget + } + }) + }, []) + + const smartNameSelection = useMemo( + () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), + [base.baseBranch, linkedWorkItem] + ) + + const createSelection = useMemo( + () => + resolveComposerCreateSelection({ + linkedWorkItem, + base, + branch: branchSelectionRef.current, + reuseEligibleBranch, + reuseSelectedBranch, + branchCreateIntent, + name + }), + [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] + ) + + // Auto-managed until the user edits the name away from the last derived value; + // desktop suppresses the workspace displayName once the name is user-edited. + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + + return { + name, + setName, + linkedWorkItem, + branchNameOverride: base.branchNameOverride, + handleBranchNameOverrideChange, + reuseEligibleBranch, + reuseSelectedBranch, + setReuseSelectedBranch, + forkPushWarning, + resolvingBase, + isNameAutoManaged, + smartNameSelection, + createSelection, + handleSmartGitHubItemSelect, + handleSmartGitLabItemSelect, + handleSmartLinearIssueSelect, + handleSmartBranchSelect, + handleSmartCreateBranch, + handleClearSmartNameSelection + } +} + +export type MobileComposerSource = ReturnType diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts new file mode 100644 index 00000000000..70ec04b2b73 --- /dev/null +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + buildSmartWorkspaceSourceRows, + getSmartWorkspaceEmptyHint, + type SmartNameMode, + type SmartWorkspaceSourceRow +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + findRepoMatchingSlug, + lookupGitHubItemByNumber, + lookupGitHubItemByOwnerRepo, + lookupGitLabItemByPath, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' + +const DEBOUNCE_MS = 200 +const RESULT_LIMIT = 36 + +export type SmartCrossRepoPrompt = { + link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + matchingRepo: PasteRepoCandidate +} + +export type UseSmartWorkspaceSourceArgs = { + client: RpcClient | null + enabled: boolean + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId?: string | null + repos: readonly PasteRepoCandidate[] +} + +const EMPTY_FAN: SmartFanOutResult = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [], + needsGitHubRemote: false, + error: '' +} + +type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null } + +export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { + const { + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + } = args + const [fan, setFan] = useState(EMPTY_FAN) + const [paste, setPaste] = useState({ github: null, gitlab: null }) + const [loading, setLoading] = useState(false) + const [crossRepoPrompt, setCrossRepoPrompt] = useState(null) + // Why: preserve results across keystrokes (debounce) but drop them the moment + // the mode/repo changes so one provider's rows never render under another tab. + const scopeRef = useRef('') + const dismissedPasteRef = useRef('') + + useEffect(() => { + if (!client || !enabled || mode === 'text') { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setLoading(false) + setCrossRepoPrompt(null) + return + } + const scope = `${mode}:${repoId ?? ''}` + const scopeChanged = scopeRef.current !== scope + scopeRef.current = scope + if (scopeChanged) { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setCrossRepoPrompt(null) + } + setLoading(true) + let stale = false + const timer = setTimeout(() => { + void runSmartSearch({ + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos, + dismissedPasteRef + }) + .then((result) => { + if (stale) { + return + } + setFan(result.fan) + setPaste(result.paste) + setCrossRepoPrompt(result.crossRepoPrompt) + setLoading(false) + }) + .catch(() => { + if (!stale) { + setLoading(false) + } + }) + }, DEBOUNCE_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [ + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + ]) + + const rows = useMemo( + () => + buildSmartWorkspaceSourceRows({ + branches: fan.branches, + githubItems: paste.github ? [paste.github] : fan.githubItems, + gitlabAvailable, + gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems, + linearAvailable, + linearIssues: fan.linearIssues, + mode, + resultLimit: RESULT_LIMIT, + value: query + }), + [fan, gitlabAvailable, linearAvailable, mode, paste, query] + ) + + const dismissCrossRepoPrompt = useCallback(() => { + dismissedPasteRef.current = query.trim() + setCrossRepoPrompt(null) + }, [query]) + + return { + rows, + loading, + error: fan.error, + needsGitHubRemote: fan.needsGitHubRemote, + emptyHint: getSmartWorkspaceEmptyHint(mode), + crossRepoPrompt, + dismissCrossRepoPrompt + } +} + +async function runSmartSearch(args: { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined + repos: readonly PasteRepoCandidate[] + dismissedPasteRef: { current: string } +}): Promise<{ + fan: SmartFanOutResult + paste: PasteResolved + crossRepoPrompt: SmartCrossRepoPrompt | null +}> { + const { client, mode, query, repoId, repos, dismissedPasteRef } = args + const fan = await fanOutSmartSearch(args) + const paste: PasteResolved = { github: null, gitlab: null } + let crossRepoPrompt: SmartCrossRepoPrompt | null = null + + const intent = + mode === 'branches' || dismissedPasteRef.current === query.trim() + ? null + : resolvePasteIntent(query) + if (intent && repoId) { + try { + if (intent.kind === 'github-number') { + paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) + } else if (intent.kind === 'github-link') { + const matchingRepo = findRepoMatchingSlug(repos, intent.link.slug) + if (matchingRepo && matchingRepo.id !== repoId) { + crossRepoPrompt = { link: intent.link, matchingRepo } + } else { + paste.github = await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ) + } + } else if (intent.kind === 'gitlab-link') { + paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) + } + } catch { + // Best-effort paste resolution; fall back to the fan-out results. + } + } + return { fan, paste, crossRepoPrompt } +} diff --git a/mobile/src/tasks/use-workspace-source-search.ts b/mobile/src/tasks/use-workspace-source-search.ts deleted file mode 100644 index 9a045b33507..00000000000 --- a/mobile/src/tasks/use-workspace-source-search.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import type { BaseRefSearchResult } from '../../../src/shared/types' -import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { extractLinearIssueReadItems } from './linear-mobile-issue-read' -import { isGitHubWorkItemsSshRemoteRequiredError, PER_REPO_FETCH_LIMIT } from './mobile-work-items' -import { - buildBranchSource, - buildGitHubTaskSource, - buildGitLabTaskSource, - buildLinearTaskSource, - type WorkspaceSource, - type WorkspaceSourceTab -} from './workspace-source-selection' - -const DEBOUNCE_MS = 250 -const GITLAB_PER_PAGE = 50 -const LINEAR_LIMIT = 50 -const BRANCH_LIMIT = 20 - -export type WorkspaceSourceSearchResult = { - id: string - title: string - subtitle: string - status?: string - source: WorkspaceSource -} - -export type WorkspaceSourceSearchState = { - results: WorkspaceSourceSearchResult[] - loading: boolean - error: string - needsGitHubRemote: boolean -} - -type GitHubSearchItem = { - type: 'issue' | 'pr' - number: number - title: string - url: string - state?: string - branchName?: string - isCrossRepository?: boolean -} - -type GitLabSearchItem = { - type: 'issue' | 'mr' - number: number - title: string - url: string - state?: string - branchName?: string - isCrossRepository?: boolean -} - -// Mirrors mobile Tasks' default issue scoping; a user can type `is:pr` to search -// pull requests instead. -function scopeGitHubQuery(query: string): string { - const trimmed = query.trim() - if (!trimmed) { - return 'is:issue is:open' - } - if (/\bis:(?:issue|pr)\b/i.test(trimmed)) { - return trimmed - } - return `is:issue ${trimmed}` -} - -async function searchGitHub( - client: RpcClient, - repoId: string, - query: string -): Promise { - const response = await client.sendRequest('github.listWorkItems', { - repo: `id:${repoId}`, - limit: PER_REPO_FETCH_LIMIT, - query: scopeGitHubQuery(query) - }) - if (!response.ok) { - throw new Error(response.error.message) - } - const envelope = (response as RpcSuccess).result as { items: GitHubSearchItem[] } - return envelope.items.map((item) => ({ - id: `github:${item.type}:${item.number}`, - title: item.title, - subtitle: `${item.type === 'pr' ? 'PR' : 'Issue'} #${item.number}`, - status: item.state, - source: buildGitHubTaskSource(repoId, { - type: item.type, - number: item.number, - title: item.title, - url: item.url, - branchName: item.branchName, - isCrossRepository: item.isCrossRepository - }) - })) -} - -async function searchGitLab( - client: RpcClient, - repoId: string, - query: string -): Promise { - const response = await client.sendRequest('gitlab.listWorkItems', { - repo: `id:${repoId}`, - state: 'opened', - page: 1, - perPage: GITLAB_PER_PAGE, - query: query.trim() || undefined - }) - if (!response.ok) { - throw new Error(response.error.message) - } - const envelope = (response as RpcSuccess).result as { - items: GitLabSearchItem[] - error?: { type?: string; message: string } - } - if (envelope.error?.type && envelope.error.type !== 'not_found') { - throw new Error(envelope.error.message) - } - return envelope.items.map((item) => ({ - id: `gitlab:${item.type}:${item.number}`, - title: item.title, - subtitle: `${item.type === 'mr' ? 'MR !' : 'Issue #'}${item.number}`, - status: item.state, - source: buildGitLabTaskSource(repoId, { - type: item.type, - number: item.number, - title: item.title, - url: item.url, - branchName: item.branchName, - isCrossRepository: item.isCrossRepository - }) - })) -} - -async function searchLinear( - client: RpcClient, - query: string, - linearWorkspaceId: string | null | undefined -): Promise { - const trimmed = query.trim() - const response = trimmed - ? await client.sendRequest('linear.searchIssues', { - query: trimmed, - limit: LINEAR_LIMIT, - workspaceId: linearWorkspaceId ?? undefined - }) - : await client.sendRequest('linear.listIssues', { - filter: 'all', - limit: LINEAR_LIMIT, - workspaceId: linearWorkspaceId ?? undefined - }) - if (!response.ok) { - throw new Error(response.error.message) - } - const issues = extractLinearIssueReadItems((response as RpcSuccess).result) - return issues.map((issue) => ({ - id: `linear:${issue.identifier}`, - title: issue.title, - subtitle: `${issue.identifier} · ${issue.team?.key ?? 'Linear'}`, - status: issue.state?.name, - source: buildLinearTaskSource({ - identifier: issue.identifier, - title: issue.title, - url: issue.url - }) - })) -} - -async function searchBranches( - client: RpcClient, - repoId: string, - query: string -): Promise { - const response = await client.sendRequest( - 'repo.searchRefs', - { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, - { timeoutMs: 30_000 } - ) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as { - refDetails?: BaseRefSearchResult[] - refs?: string[] - } - const refs = - result.refDetails ?? - (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) - return refs.map((ref) => ({ - id: `branch:${ref.refName}`, - title: ref.localBranchName || ref.refName, - subtitle: ref.refName, - source: buildBranchSource(ref.refName, ref.localBranchName) - })) -} - -function runSearch(args: { - client: RpcClient - activeTab: WorkspaceSourceTab - query: string - repoId: string | null - linearWorkspaceId: string | null | undefined -}): Promise { - const { client, activeTab, query, repoId, linearWorkspaceId } = args - if (activeTab === 'linear') { - return searchLinear(client, query, linearWorkspaceId) - } - if (!repoId) { - return Promise.resolve([]) - } - if (activeTab === 'github') { - return searchGitHub(client, repoId, query) - } - if (activeTab === 'gitlab') { - return searchGitLab(client, repoId, query) - } - return searchBranches(client, repoId, query) -} - -// Runs a debounced, single-repo provider search for the workspace source picker. -// A stale-closure guard drops out-of-order results when the tab/query changes. -export function useWorkspaceSourceSearch(args: { - client: RpcClient | null - enabled: boolean - activeTab: WorkspaceSourceTab - query: string - repoId: string | null - linearWorkspaceId?: string | null -}): WorkspaceSourceSearchState { - const { client, enabled, activeTab, query, repoId, linearWorkspaceId } = args - const [state, setState] = useState({ - results: [], - loading: false, - error: '', - needsGitHubRemote: false - }) - // Why: preserve results across keystrokes (avoids flicker under debounce) but - // drop them the moment the tab or repo changes, so one provider's rows never - // render under another provider's tab. - const scopeRef = useRef('') - - useEffect(() => { - if (!client || !enabled) { - setState({ results: [], loading: false, error: '', needsGitHubRemote: false }) - return - } - // Repo-scoped tabs can't search without a repo; branch search needs a query. - if (activeTab !== 'linear' && !repoId) { - setState({ results: [], loading: false, error: '', needsGitHubRemote: false }) - return - } - if (activeTab === 'branch' && !query.trim()) { - setState({ results: [], loading: false, error: '', needsGitHubRemote: false }) - return - } - - const scope = `${activeTab}:${repoId ?? ''}` - const scopeChanged = scopeRef.current !== scope - scopeRef.current = scope - - let stale = false - setState((prev) => ({ - results: scopeChanged ? [] : prev.results, - loading: true, - error: '', - needsGitHubRemote: false - })) - const timer = setTimeout(() => { - void runSearch({ client, activeTab, query, repoId, linearWorkspaceId }) - .then((results) => { - if (!stale) { - setState({ results, loading: false, error: '', needsGitHubRemote: false }) - } - }) - .catch((err) => { - if (stale) { - return - } - if (activeTab === 'github' && isGitHubWorkItemsSshRemoteRequiredError(err)) { - setState({ results: [], loading: false, error: '', needsGitHubRemote: true }) - return - } - setState({ - results: [], - loading: false, - error: err instanceof Error ? err.message : 'Search failed', - needsGitHubRemote: false - }) - }) - }, DEBOUNCE_MS) - - return () => { - stale = true - clearTimeout(timer) - } - }, [client, enabled, activeTab, query, repoId, linearWorkspaceId]) - - return state -} diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts new file mode 100644 index 00000000000..0d005820da3 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { isWorkItemLookupText } from './work-item-lookup-text' + +describe('isWorkItemLookupText', () => { + it('treats references as lookup text, not names', () => { + expect(isWorkItemLookupText('#42')).toBe(true) + expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true) + expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true) + expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true) + expect(isWorkItemLookupText('ENG-9')).toBe(true) + }) + + it('treats plain names as non-lookup text', () => { + expect(isWorkItemLookupText('')).toBe(false) + expect(isWorkItemLookupText('fix the login bug')).toBe(false) + }) +}) diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts new file mode 100644 index 00000000000..f434fcbd015 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -0,0 +1,24 @@ +import { parseGitHubIssueOrPRNumber } from '../../../src/shared/new-workspace/github-links' +import { parseGitLabIssueOrMRNumber } from '../../../src/shared/new-workspace/gitlab-links' + +const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i +const LINEAR_IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/ + +// Why: text typed to *find* a work item — a GitHub/GitLab URL, "#123", or a +// Linear link/identifier — is a lookup query, never a deliberate name. Selection +// handlers use this to decide the resolved item's title-derived name may replace +// the field content; otherwise the pasted reference silently survives behind the +// pill and the workspace gets a slugified-URL name. Mirrors desktop's +// isWorkItemLookupText using the shared link parsers. +export function isWorkItemLookupText(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) { + return false + } + return ( + parseGitHubIssueOrPRNumber(trimmed) !== null || + parseGitLabIssueOrMRNumber(trimmed) !== null || + LINEAR_ISSUE_URL_RE.test(trimmed) || + LINEAR_IDENTIFIER_RE.test(trimmed) + ) +} diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index ec945a11828..bd1518b8308 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -48,6 +48,8 @@ type WorkspaceCreateLinearItem = { identifier: string title: string url: string + workspaceId?: string + organizationUrlKey?: string } } @@ -66,9 +68,12 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName?: string note?: string baseBranch?: string + compareBaseRef?: string branchNameOverride?: string + pushTarget?: WorkspaceCreateGitPushTarget sparseCheckout?: WorkspaceCreateSparseCheckout hostedStartPoint?: WorkspaceCreateHostedStartPoint + nameIsAutoManaged?: boolean }): WorkspaceCreateParams { const { item, @@ -78,22 +83,30 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName, note, baseBranch, + compareBaseRef, branchNameOverride, + pushTarget, sparseCheckout, - hostedStartPoint + hostedStartPoint, + nameIsAutoManaged = true } = args const shouldLaunchAgent = agent !== 'blank' const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch + const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget + // Why: desktop only sends displayName while the name is still auto-derived; a + // user-edited name suppresses it so the runtime keeps the user's chosen name. + const displayName = nameIsAutoManaged ? { displayName: item.source.title } : {} const common = { setupDecision, activate: true, ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), + ...(compareBaseRef ? { compareBaseRef } : {}), ...(branchNameOverride ? { branchNameOverride } : {}), - ...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}), + ...(selectedPushTarget ? { pushTarget: selectedPushTarget } : {}), ...(sparseCheckout ? { sparseCheckout } : {}), ...(comment ? { comment } : {}) } @@ -103,7 +116,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedIssue: item.source.number } @@ -116,7 +129,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedGitLabIssue: item.source.number } @@ -130,8 +143,12 @@ export function buildTaskWorkspaceCreateParams(args: { draft: workspaceName, fallback: item.source.identifier.toLowerCase() }), - displayName: item.source.title, + ...displayName, linkedLinearIssue: item.source.identifier, + ...(item.source.workspaceId ? { linkedLinearIssueWorkspaceId: item.source.workspaceId } : {}), + ...(item.source.organizationUrlKey + ? { linkedLinearIssueOrganizationUrlKey: item.source.organizationUrlKey } + : {}), ...common } } diff --git a/mobile/src/tasks/workspace-source-selection.test.ts b/mobile/src/tasks/workspace-source-selection.test.ts deleted file mode 100644 index dc83c60cd47..00000000000 --- a/mobile/src/tasks/workspace-source-selection.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildBranchSource, - buildGitHubTaskSource, - buildGitLabTaskSource, - buildLinearTaskSource, - buildNewBranchSource, - describeWorkspaceSource, - isRepoBoundSource, - resolveSourceRepoId -} from './workspace-source-selection' - -describe('workspace source selection', () => { - it('builds a GitHub PR task source pinned to its repo', () => { - const source = buildGitHubTaskSource('repo-1', { - type: 'pr', - number: 42, - title: 'Fix login', - url: 'https://github.com/acme/app/pull/42', - branchName: 'feature/login', - isCrossRepository: true - }) - expect(source).toEqual({ - kind: 'task', - hostedType: 'pr', - branchName: 'feature/login', - isCrossRepository: true, - item: { - provider: 'github', - source: { - type: 'pr', - repoId: 'repo-1', - number: 42, - title: 'Fix login', - url: 'https://github.com/acme/app/pull/42' - } - } - }) - expect(resolveSourceRepoId(source)).toBe('repo-1') - }) - - it('omits optional resolve fields when absent on a GitHub issue', () => { - const source = buildGitHubTaskSource('repo-1', { - type: 'issue', - number: 7, - title: 'Bug', - url: 'https://github.com/acme/app/issues/7' - }) - if (source.kind !== 'task') { - throw new Error('expected task source') - } - expect(source.hostedType).toBe('issue') - expect('branchName' in source).toBe(false) - expect('isCrossRepository' in source).toBe(false) - }) - - it('builds a GitLab MR task source with mr hostedType', () => { - const source = buildGitLabTaskSource('repo-2', { - type: 'mr', - number: 9, - title: 'Refactor', - url: 'https://gitlab.com/acme/app/-/merge_requests/9' - }) - if (source.kind !== 'task') { - throw new Error('expected task source') - } - expect(source.hostedType).toBe('mr') - expect(source.item.provider).toBe('gitlab') - expect(resolveSourceRepoId(source)).toBe('repo-2') - }) - - it('builds a Linear task source that defers repo to the modal', () => { - const source = buildLinearTaskSource({ - identifier: 'ENG-42', - title: 'Improve onboarding', - url: 'https://linear.app/acme/issue/ENG-42' - }) - if (source.kind !== 'task') { - throw new Error('expected task source') - } - expect(source.hostedType).toBe('linear') - expect(resolveSourceRepoId(source)).toBeNull() - }) - - it('builds branch and new-branch sources deferring repo to the modal', () => { - expect(resolveSourceRepoId(buildBranchSource('origin/main', 'main'))).toBeNull() - expect(resolveSourceRepoId(buildNewBranchSource('main', 'feature-x'))).toBeNull() - }) - - it('treats branch/new-branch and GitHub/GitLab tasks as repo-bound, not Linear or blank', () => { - expect(isRepoBoundSource({ kind: 'blank' })).toBe(false) - expect(isRepoBoundSource(buildBranchSource('origin/main', 'main'))).toBe(true) - expect(isRepoBoundSource(buildNewBranchSource('', 'feature-x'))).toBe(true) - expect( - isRepoBoundSource( - buildGitHubTaskSource('r', { type: 'issue', number: 1, title: 't', url: 'u' }) - ) - ).toBe(true) - expect( - isRepoBoundSource(buildGitLabTaskSource('r', { type: 'mr', number: 1, title: 't', url: 'u' })) - ).toBe(true) - expect( - isRepoBoundSource(buildLinearTaskSource({ identifier: 'ENG-1', title: 't', url: 'u' })) - ).toBe(false) - }) - - it('describes each source kind for the collapsed field', () => { - expect(describeWorkspaceSource({ kind: 'blank' })).toEqual({ label: 'Blank workspace' }) - expect(describeWorkspaceSource(buildBranchSource('origin/main', 'main'))).toEqual({ - label: 'Branch: main', - providerIconId: 'branch' - }) - expect(describeWorkspaceSource(buildNewBranchSource('main', 'feature-x'))).toEqual({ - label: 'New branch: feature-x', - providerIconId: 'branch' - }) - expect( - describeWorkspaceSource( - buildGitHubTaskSource('r', { - type: 'issue', - number: 3, - title: 'Crash', - url: 'u' - }) - ) - ).toEqual({ label: '#3 Crash', providerIconId: 'github' }) - expect( - describeWorkspaceSource( - buildGitLabTaskSource('r', { type: 'mr', number: 5, title: 'Speed', url: 'u' }) - ) - ).toEqual({ label: '!5 Speed', providerIconId: 'gitlab' }) - expect( - describeWorkspaceSource( - buildLinearTaskSource({ identifier: 'ENG-1', title: 'Ship', url: 'u' }) - ) - ).toEqual({ label: 'ENG-1 Ship', providerIconId: 'linear' }) - }) -}) diff --git a/mobile/src/tasks/workspace-source-selection.ts b/mobile/src/tasks/workspace-source-selection.ts deleted file mode 100644 index 58eb6d8d5ee..00000000000 --- a/mobile/src/tasks/workspace-source-selection.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { WorkspaceCreateTaskItem } from './workspace-create-params' - -// The tabs a user can start a workspace from, mirroring the desktop composer's -// smart-name source modes (branch / GitHub / GitLab / Linear). "Blank" is the -// implicit default and isn't a tab. -export type WorkspaceSourceTab = 'branch' | 'github' | 'gitlab' | 'linear' - -// Slim inputs: only the fields the create flow needs from each provider's work -// item. The picker builds these from the richer search-result envelopes. -export type GitHubWorkItemInput = { - type: 'issue' | 'pr' - number: number - title: string - url: string - branchName?: string - isCrossRepository?: boolean -} - -export type GitLabWorkItemInput = { - type: 'issue' | 'mr' - number: number - title: string - url: string - branchName?: string - isCrossRepository?: boolean -} - -export type LinearIssueInput = { - identifier: string - title: string - url: string -} - -// Why: branchName/isCrossRepository ride alongside the trimmed -// WorkspaceCreateTaskItem because worktree.resolvePrBase/resolveMrBase need them -// but they aren't part of the create-item source shape. -export type WorkspaceSource = - | { kind: 'blank' } - | { kind: 'branch'; refName: string; localBranchName: string } - | { kind: 'new-branch'; baseRefName: string; branchName: string } - | { - kind: 'task' - item: WorkspaceCreateTaskItem - hostedType: 'issue' | 'pr' | 'mr' | 'linear' - branchName?: string - isCrossRepository?: boolean - } - -export type WorkspaceSourceDescription = { - label: string - providerIconId?: WorkspaceSourceTab -} - -export function buildGitHubTaskSource(repoId: string, item: GitHubWorkItemInput): WorkspaceSource { - return { - kind: 'task', - hostedType: item.type, - ...(item.branchName ? { branchName: item.branchName } : {}), - ...(item.isCrossRepository !== undefined ? { isCrossRepository: item.isCrossRepository } : {}), - item: { - provider: 'github', - source: { - type: item.type, - repoId, - number: item.number, - title: item.title, - url: item.url - } - } - } -} - -export function buildGitLabTaskSource(repoId: string, item: GitLabWorkItemInput): WorkspaceSource { - return { - kind: 'task', - hostedType: item.type, - ...(item.branchName ? { branchName: item.branchName } : {}), - ...(item.isCrossRepository !== undefined ? { isCrossRepository: item.isCrossRepository } : {}), - item: { - provider: 'gitlab', - source: { - type: item.type, - repoId, - number: item.number, - title: item.title, - url: item.url - } - } - } -} - -export function buildLinearTaskSource(item: LinearIssueInput): WorkspaceSource { - return { - kind: 'task', - hostedType: 'linear', - item: { - provider: 'linear', - source: { - identifier: item.identifier, - title: item.title, - url: item.url - } - } - } -} - -export function buildBranchSource(refName: string, localBranchName: string): WorkspaceSource { - return { kind: 'branch', refName, localBranchName } -} - -export function buildNewBranchSource(baseRefName: string, branchName: string): WorkspaceSource { - return { kind: 'new-branch', baseRefName, branchName } -} - -export function describeWorkspaceSource(source: WorkspaceSource): WorkspaceSourceDescription { - switch (source.kind) { - case 'blank': - return { label: 'Blank workspace' } - case 'branch': - return { - label: `Branch: ${source.localBranchName || source.refName}`, - providerIconId: 'branch' - } - case 'new-branch': - return { label: `New branch: ${source.branchName}`, providerIconId: 'branch' } - case 'task': { - if (source.item.provider === 'github') { - const item = source.item.source - return { label: `#${item.number} ${item.title}`, providerIconId: 'github' } - } - if (source.item.provider === 'gitlab') { - const item = source.item.source - const prefix = item.type === 'mr' ? '!' : '#' - return { label: `${prefix}${item.number} ${item.title}`, providerIconId: 'gitlab' } - } - const item = source.item.source - return { label: `${item.identifier} ${item.title}`, providerIconId: 'linear' } - } - } -} - -// The repo a source pins to: GitHub/GitLab work items dictate their own repo; -// Linear/branch/new-branch/blank defer to the modal's selected repo (null). -export function resolveSourceRepoId(source: WorkspaceSource): string | null { - if ( - source.kind === 'task' && - (source.item.provider === 'github' || source.item.provider === 'gitlab') - ) { - return source.item.source.repoId - } - return null -} - -// Whether a source is tied to a specific repo, so switching the modal's repo -// invalidates it. Branches are searched per-repo; GitHub/GitLab items pin a repo. -// Linear issues are repo-agnostic (the target repo is chosen separately), and -// blank has no repo dependency. -export function isRepoBoundSource(source: WorkspaceSource): boolean { - if (source.kind === 'branch' || source.kind === 'new-branch') { - return true - } - if (source.kind === 'task') { - return source.item.provider !== 'linear' - } - return false -} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8e05aaeec02..34392977fc5 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -230,11 +230,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.updatePRState', 'github.repoSlug', 'github.workItem', + // Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart + // picker resolve a pasted github.com URL that points at a different repo. + 'github.workItemByOwnerRepo', 'github.workItemDetails', 'gitlab.createIssue', 'gitlab.addIssueComment', 'gitlab.addMRComment', 'gitlab.listWorkItems', + // Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact + // issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.) + 'gitlab.workItemByPath', 'gitlab.mergeMR', 'gitlab.resolveMRDiscussion', 'gitlab.todos', diff --git a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts index 58d0e4289d3..273c92b8dbe 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts @@ -1,54 +1,2 @@ -export type SmartWorkspaceCommandRowKind = - | 'use-name' - | 'create-branch' - | 'github' - | 'gitlab' - | 'branch' - | 'linear' - -export type SmartWorkspaceCommandRow = { - kind: SmartWorkspaceCommandRowKind - value: string -} - -export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null - -export function resolveSmartWorkspaceCommandValue({ - currentValue, - rows, - isQueryStale, - sourceIntent -}: { - currentValue: string - rows: readonly SmartWorkspaceCommandRow[] - isQueryStale: boolean - sourceIntent: SmartWorkspaceSourceIntent -}): string { - if (rows.length === 0) { - return currentValue - } - - if (isQueryStale) { - const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch') - return typedTextRow?.value ?? '' - } - - if (sourceIntent === 'github') { - const githubRow = rows.find((row) => row.kind === 'github') - if (githubRow) { - return githubRow.value - } - } else if (sourceIntent === 'gitlab') { - const gitlabRow = rows.find((row) => row.kind === 'gitlab') - if (gitlabRow) { - return gitlabRow.value - } - } else if (sourceIntent === 'linear') { - const linearRow = rows.find((row) => row.kind === 'linear') - if (linearRow) { - return linearRow.value - } - } - - return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../../shared/new-workspace/smart-workspace-command-value' diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts index abbbfb04062..afa2d10651f 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts @@ -1,190 +1,2 @@ -import type { - BaseRefSearchResult, - GitHubWorkItem, - GitLabWorkItem, - LinearCollectionResult, - LinearIssue -} from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' - -export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text' - -export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048 - -export type SmartWorkspaceSourceRow = - | { kind: 'use-name'; value: string; name: string } - | { kind: 'create-branch'; value: string; name: string } - | { kind: 'github'; value: string; item: GitHubWorkItem } - | { kind: 'gitlab'; value: string; item: GitLabWorkItem } - | { kind: 'branch'; value: string; refName: string; localBranchName: string } - | { kind: 'linear'; value: string; issue: LinearIssue } - -type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult | null | undefined - -const EMPTY_HINT_BY_MODE: Record = { - smart: 'Start typing to create a name or find a source.', - github: 'Start typing to search GitHub PRs and issues.', - gitlab: 'Start typing to search GitLab MRs and issues.', - branches: 'No matching branches.', - linear: 'Start typing to search Linear issues.', - text: '' -} - -export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string { - return EMPTY_HINT_BY_MODE[mode] -} - -export function isSmartWorkspaceSourceQueryWithinLimit( - query: string, - maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES -): boolean { - return !isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function getBranchSearchRequest({ - branchesEnabled, - disabled, - textOnly, - mode, - selectedRepoId, - query, - limit -}: { - branchesEnabled?: boolean - disabled: boolean - textOnly: boolean - mode: SmartNameMode - selectedRepoId: string | null - query: string - limit: number -}): { repoId: string; query: string; limit: number } | null { - if ( - branchesEnabled === false || - disabled || - textOnly || - !isSmartWorkspaceSourceQueryWithinLimit(query) || - !selectedRepoId - ) { - return null - } - const trimmedQuery = query.trim() - const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0) - if (!shouldSearchBranches) { - return null - } - return { repoId: selectedRepoId, query: trimmedQuery, limit } -} - -export function getVisibleBranchResults({ - branches, - mode, - resultRepoId, - resultQuery, - selectedRepoId, - value -}: { - branches: BaseRefSearchResult[] - mode: SmartNameMode - resultRepoId: string | null - resultQuery: string | null - selectedRepoId: string | null - value: string -}): BaseRefSearchResult[] { - if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { - return [] - } - const currentQuery = value.trim() - if (mode !== 'branches' && mode !== 'smart') { - return [] - } - if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { - return [] - } - return branches -} - -export function buildSmartWorkspaceSourceRows({ - branches, - githubItems, - gitlabAvailable, - gitlabItems, - linearAvailable, - linearIssues, - mode, - resultLimit, - value -}: { - branches: BaseRefSearchResult[] - githubItems: GitHubWorkItem[] - gitlabAvailable: boolean - gitlabItems: GitLabWorkItem[] - linearAvailable: boolean - linearIssues: LinearIssueSourceInput - mode: SmartNameMode - resultLimit: number - value: string -}): SmartWorkspaceSourceRow[] { - if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { - return [] - } - const trimmed = value.trim() - const nextRows: SmartWorkspaceSourceRow[] = [] - if (trimmed && mode === 'smart') { - nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) - } - if (mode === 'text') { - return nextRows - } - if (mode === 'smart' || mode === 'github') { - nextRows.push( - ...githubItems.map((item) => ({ - kind: 'github' as const, - value: `github-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) - } - if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { - nextRows.push( - ...gitlabItems.map((item) => ({ - kind: 'gitlab' as const, - value: `gitlab-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) - } - const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0) - if (shouldShowBranches) { - const branchExactMatch = branches.some( - (branch) => branch.refName === trimmed || branch.localBranchName === trimmed - ) - if (trimmed && mode === 'branches' && !branchExactMatch) { - nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) - } - nextRows.push( - ...branches.map((branch) => ({ - kind: 'branch' as const, - value: `branch-${branch.refName}`, - refName: branch.refName, - localBranchName: branch.localBranchName - })) - ) - } - if (linearAvailable && (mode === 'smart' || mode === 'linear')) { - // Why: mixed-version runtime responses may briefly carry the paginated - // collection shape into this render path; rendering must stay recoverable. - const resolvedLinearIssues = Array.isArray(linearIssues) - ? linearIssues - : Array.isArray(linearIssues?.items) - ? linearIssues.items - : [] - nextRows.push( - ...resolvedLinearIssues.map((issue) => ({ - kind: 'linear' as const, - value: `linear-${issue.id}`, - issue - })) - ) - } - return nextRows.slice(0, resultLimit + 1) -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../../shared/new-workspace/smart-workspace-source-results' diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 05ea8c812f5..7691868ccf7 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,142 +1,2 @@ -import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' - -const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i -const HTTP_URL_PREFIX_RE = /^https?:\/\//i - -export type RepoSlug = { - owner: string - repo: string -} - -export type GitHubIssueOrPRLink = { - slug: RepoSlug - number: number - type: 'issue' | 'pr' -} - -export type GitHubLinkQuery = { - query: string - directNumber: number | null - directLink?: GitHubIssueOrPRLink - tooLarge?: boolean -} - -export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { - if (!slug?.owner || !slug.repo) { - return null - } - return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` -} - -function matchGitHubItemPath(url: URL): RegExpExecArray | null { - return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) -} - -function parseGitHubItemNumber(value: string): number | null { - const parsed = Number.parseInt(value, 10) - return parsed > 0 ? parsed : null -} - -/** - * Parses a GitHub issue/PR reference from plain input. - * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. - */ -export function parseGitHubIssueOrPRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return parseGitHubItemNumber(numeric) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - - return parseGitHubItemNumber(match[4]) -} - -/** - * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns - * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. - */ -export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - const number = parseGitHubItemNumber(match[4]) - if (number === null) { - return null - } - - return { - slug: { owner: match[1], repo: match[2] }, - type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', - number - } -} - -/** - * Normalizes link-picker input so both raw issue/PR numbers and full GitHub - * URLs resolve to a usable query + direct-number lookup. - */ -export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { - if (isWorkItemLinkQueryTooLarge(raw)) { - return { query: '', directNumber: null, tooLarge: true } - } - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', directNumber: null } - } - - const direct = parseGitHubIssueOrPRNumber(trimmed) - if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) { - return { query: trimmed, directNumber: direct } - } - - const link = parseGitHubIssueOrPRLink(trimmed) - if (!link) { - return { query: trimmed, directNumber: null } - } - - // Why: any GitHub-shaped issue/pull URL is accepted by number regardless of - // slug, since fork checkouts can legitimately target upstream issues whose - // slug differs from the origin remote. - return { - query: trimmed, - directNumber: link.number, - directLink: link - } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/github-links' diff --git a/src/renderer/src/lib/github-work-item-identity.ts b/src/renderer/src/lib/github-work-item-identity.ts index c9b24e316ff..3f964159cd2 100644 --- a/src/renderer/src/lib/github-work-item-identity.ts +++ b/src/renderer/src/lib/github-work-item-identity.ts @@ -1,20 +1,2 @@ -import { parseGitHubIssueOrPRLink } from '@/lib/github-links' - -export type GitHubWorkItemIdentity = { - type: 'issue' | 'pr' - number: number -} - -export function resolveGitHubWorkItemIdentity(item: { - type: 'issue' | 'pr' - number: number - url?: string | null -}): GitHubWorkItemIdentity { - const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null - if (link) { - // Why: stale cached work-item payloads can disagree with a pasted URL. The - // URL path is the user-visible intent, so it decides issue-vs-PR launches. - return { type: link.type, number: link.number } - } - return { type: item.type, number: item.number } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/github-work-item-identity' diff --git a/src/renderer/src/lib/gitlab-links.ts b/src/renderer/src/lib/gitlab-links.ts index 1bd36c13712..d2e537d7a85 100644 --- a/src/renderer/src/lib/gitlab-links.ts +++ b/src/renderer/src/lib/gitlab-links.ts @@ -1,138 +1,2 @@ -import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' - -// Why: GitLab project paths can include nested groups, and the host may -// be self-hosted (gitlab.example.com), so the URL pattern uses the -// project-internal `/-/` separator as the GitLab-specific signal rather -// than locking to gitlab.com. Anything matching `//-/(issues| -// work_items|merge_requests)/` is treated as a GitLab item URL -// regardless of host. Modern GitLab emits issue URLs as -// `/-/work_items/`; treat that as an issue work item, same as the -// legacy `/-/issues/` form. -const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i -const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i - -export type ProjectSlug = { - /** GitLab hostname, preserving self-hosted instances from pasted URLs. */ - host: string - /** Full GitLab project path including any nested groups. */ - path: string -} - -export type GitLabLinkQuery = { - query: string - directNumber: number | null - tooLarge?: boolean -} - -/** - * Parse a GitLab issue or MR reference from plain input. Accepts: - * - bare numbers ("42") - * - hash-prefixed numbers ("#42") - * - exclamation-prefixed numbers ("!42") — GitLab convention for MRs - * - full GitLab URLs (any host) for issues or merge_requests - */ -export function parseGitLabIssueOrMRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - // Why: GitLab references issues with `#` and MRs with `!` in markdown - // and copy-paste contexts. Accept both prefixes so users can drop in - // either form. - const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return Number.parseInt(numeric, 10) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - const match = GL_ITEM_PATH_RE.exec(url.pathname) - if (!match) { - return null - } - // Why: the basic pattern matches plain GitHub URLs too (e.g. - // /owner/repo/issues/123). Require the `/-/` separator that's - // unique to GitLab to avoid mis-classifying a GitHub URL. - if (!url.pathname.includes('/-/')) { - return null - } - return Number.parseInt(match[1], 10) -} - -/** - * Parse a GitLab URL into project path + iid + type. Returns null for - * anything that isn't a recognizable GitLab issue or merge-request URL. - */ -export function parseGitLabIssueOrMRLink(input: string): { - slug: ProjectSlug - number: number - type: 'issue' | 'mr' -} | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname) - if (!match) { - return null - } - - const path = match[1] - // Why: a project path needs at least one slash (group/project). A - // single-segment path is the user/group root, not a project. - if (!path.includes('/')) { - return null - } - - return { - slug: { host: url.host, path }, - type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue', - number: Number.parseInt(match[3], 10) - } -} - -/** - * Normalize link-picker input so both raw issue/MR numbers and full - * GitLab URLs resolve to a usable query + direct-number lookup. - */ -export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery { - if (isWorkItemLinkQueryTooLarge(raw)) { - return { query: '', directNumber: null, tooLarge: true } - } - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', directNumber: null } - } - - const direct = parseGitLabIssueOrMRNumber(trimmed) - if (direct !== null && !trimmed.startsWith('http')) { - return { query: trimmed, directNumber: direct } - } - - const link = parseGitLabIssueOrMRLink(trimmed) - if (!link) { - return { query: trimmed, directNumber: null } - } - - // Why: any GitLab issue/MR URL is accepted by number regardless of - // project slug, mirroring the GitHub-side behavior — fork checkouts - // can legitimately target an upstream's issue numbers. - return { - query: trimmed, - directNumber: link.number - } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/gitlab-links' diff --git a/src/renderer/src/lib/work-item-link-query-bounds.ts b/src/renderer/src/lib/work-item-link-query-bounds.ts index 6cbb407da84..831c942ef0c 100644 --- a/src/renderer/src/lib/work-item-link-query-bounds.ts +++ b/src/renderer/src/lib/work-item-link-query-bounds.ts @@ -1,10 +1,2 @@ -import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' - -export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024 - -export function isWorkItemLinkQueryTooLarge( - query: string, - maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/work-item-link-query-bounds' diff --git a/src/shared/new-workspace/github-links.ts b/src/shared/new-workspace/github-links.ts new file mode 100644 index 00000000000..05ea8c812f5 --- /dev/null +++ b/src/shared/new-workspace/github-links.ts @@ -0,0 +1,142 @@ +import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' + +const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i +const HTTP_URL_PREFIX_RE = /^https?:\/\//i + +export type RepoSlug = { + owner: string + repo: string +} + +export type GitHubIssueOrPRLink = { + slug: RepoSlug + number: number + type: 'issue' | 'pr' +} + +export type GitHubLinkQuery = { + query: string + directNumber: number | null + directLink?: GitHubIssueOrPRLink + tooLarge?: boolean +} + +export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { + if (!slug?.owner || !slug.repo) { + return null + } + return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` +} + +function matchGitHubItemPath(url: URL): RegExpExecArray | null { + return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) +} + +function parseGitHubItemNumber(value: string): number | null { + const parsed = Number.parseInt(value, 10) + return parsed > 0 ? parsed : null +} + +/** + * Parses a GitHub issue/PR reference from plain input. + * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. + */ +export function parseGitHubIssueOrPRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return parseGitHubItemNumber(numeric) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + + return parseGitHubItemNumber(match[4]) +} + +/** + * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns + * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. + */ +export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + const number = parseGitHubItemNumber(match[4]) + if (number === null) { + return null + } + + return { + slug: { owner: match[1], repo: match[2] }, + type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', + number + } +} + +/** + * Normalizes link-picker input so both raw issue/PR numbers and full GitHub + * URLs resolve to a usable query + direct-number lookup. + */ +export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { + if (isWorkItemLinkQueryTooLarge(raw)) { + return { query: '', directNumber: null, tooLarge: true } + } + const trimmed = raw.trim() + if (!trimmed) { + return { query: '', directNumber: null } + } + + const direct = parseGitHubIssueOrPRNumber(trimmed) + if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) { + return { query: trimmed, directNumber: direct } + } + + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link) { + return { query: trimmed, directNumber: null } + } + + // Why: any GitHub-shaped issue/pull URL is accepted by number regardless of + // slug, since fork checkouts can legitimately target upstream issues whose + // slug differs from the origin remote. + return { + query: trimmed, + directNumber: link.number, + directLink: link + } +} diff --git a/src/shared/new-workspace/github-work-item-identity.ts b/src/shared/new-workspace/github-work-item-identity.ts new file mode 100644 index 00000000000..933a436daa4 --- /dev/null +++ b/src/shared/new-workspace/github-work-item-identity.ts @@ -0,0 +1,20 @@ +import { parseGitHubIssueOrPRLink } from './github-links' + +export type GitHubWorkItemIdentity = { + type: 'issue' | 'pr' + number: number +} + +export function resolveGitHubWorkItemIdentity(item: { + type: 'issue' | 'pr' + number: number + url?: string | null +}): GitHubWorkItemIdentity { + const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null + if (link) { + // Why: stale cached work-item payloads can disagree with a pasted URL. The + // URL path is the user-visible intent, so it decides issue-vs-PR launches. + return { type: link.type, number: link.number } + } + return { type: item.type, number: item.number } +} diff --git a/src/shared/new-workspace/gitlab-links.ts b/src/shared/new-workspace/gitlab-links.ts new file mode 100644 index 00000000000..1bd36c13712 --- /dev/null +++ b/src/shared/new-workspace/gitlab-links.ts @@ -0,0 +1,138 @@ +import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' + +// Why: GitLab project paths can include nested groups, and the host may +// be self-hosted (gitlab.example.com), so the URL pattern uses the +// project-internal `/-/` separator as the GitLab-specific signal rather +// than locking to gitlab.com. Anything matching `//-/(issues| +// work_items|merge_requests)/` is treated as a GitLab item URL +// regardless of host. Modern GitLab emits issue URLs as +// `/-/work_items/`; treat that as an issue work item, same as the +// legacy `/-/issues/` form. +const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i +const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i + +export type ProjectSlug = { + /** GitLab hostname, preserving self-hosted instances from pasted URLs. */ + host: string + /** Full GitLab project path including any nested groups. */ + path: string +} + +export type GitLabLinkQuery = { + query: string + directNumber: number | null + tooLarge?: boolean +} + +/** + * Parse a GitLab issue or MR reference from plain input. Accepts: + * - bare numbers ("42") + * - hash-prefixed numbers ("#42") + * - exclamation-prefixed numbers ("!42") — GitLab convention for MRs + * - full GitLab URLs (any host) for issues or merge_requests + */ +export function parseGitLabIssueOrMRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + // Why: GitLab references issues with `#` and MRs with `!` in markdown + // and copy-paste contexts. Accept both prefixes so users can drop in + // either form. + const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return Number.parseInt(numeric, 10) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + const match = GL_ITEM_PATH_RE.exec(url.pathname) + if (!match) { + return null + } + // Why: the basic pattern matches plain GitHub URLs too (e.g. + // /owner/repo/issues/123). Require the `/-/` separator that's + // unique to GitLab to avoid mis-classifying a GitHub URL. + if (!url.pathname.includes('/-/')) { + return null + } + return Number.parseInt(match[1], 10) +} + +/** + * Parse a GitLab URL into project path + iid + type. Returns null for + * anything that isn't a recognizable GitLab issue or merge-request URL. + */ +export function parseGitLabIssueOrMRLink(input: string): { + slug: ProjectSlug + number: number + type: 'issue' | 'mr' +} | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname) + if (!match) { + return null + } + + const path = match[1] + // Why: a project path needs at least one slash (group/project). A + // single-segment path is the user/group root, not a project. + if (!path.includes('/')) { + return null + } + + return { + slug: { host: url.host, path }, + type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue', + number: Number.parseInt(match[3], 10) + } +} + +/** + * Normalize link-picker input so both raw issue/MR numbers and full + * GitLab URLs resolve to a usable query + direct-number lookup. + */ +export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery { + if (isWorkItemLinkQueryTooLarge(raw)) { + return { query: '', directNumber: null, tooLarge: true } + } + const trimmed = raw.trim() + if (!trimmed) { + return { query: '', directNumber: null } + } + + const direct = parseGitLabIssueOrMRNumber(trimmed) + if (direct !== null && !trimmed.startsWith('http')) { + return { query: trimmed, directNumber: direct } + } + + const link = parseGitLabIssueOrMRLink(trimmed) + if (!link) { + return { query: trimmed, directNumber: null } + } + + // Why: any GitLab issue/MR URL is accepted by number regardless of + // project slug, mirroring the GitHub-side behavior — fork checkouts + // can legitimately target an upstream's issue numbers. + return { + query: trimmed, + directNumber: link.number + } +} diff --git a/src/shared/new-workspace/smart-workspace-command-value.ts b/src/shared/new-workspace/smart-workspace-command-value.ts new file mode 100644 index 00000000000..58d0e4289d3 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-command-value.ts @@ -0,0 +1,54 @@ +export type SmartWorkspaceCommandRowKind = + | 'use-name' + | 'create-branch' + | 'github' + | 'gitlab' + | 'branch' + | 'linear' + +export type SmartWorkspaceCommandRow = { + kind: SmartWorkspaceCommandRowKind + value: string +} + +export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null + +export function resolveSmartWorkspaceCommandValue({ + currentValue, + rows, + isQueryStale, + sourceIntent +}: { + currentValue: string + rows: readonly SmartWorkspaceCommandRow[] + isQueryStale: boolean + sourceIntent: SmartWorkspaceSourceIntent +}): string { + if (rows.length === 0) { + return currentValue + } + + if (isQueryStale) { + const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch') + return typedTextRow?.value ?? '' + } + + if (sourceIntent === 'github') { + const githubRow = rows.find((row) => row.kind === 'github') + if (githubRow) { + return githubRow.value + } + } else if (sourceIntent === 'gitlab') { + const gitlabRow = rows.find((row) => row.kind === 'gitlab') + if (gitlabRow) { + return gitlabRow.value + } + } else if (sourceIntent === 'linear') { + const linearRow = rows.find((row) => row.kind === 'linear') + if (linearRow) { + return linearRow.value + } + } + + return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value +} diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts new file mode 100644 index 00000000000..68041990910 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -0,0 +1,190 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearCollectionResult, + LinearIssue +} from '../types' +import { isClipboardTextByteLengthOverLimit } from '../clipboard-text' + +export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text' + +export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048 + +export type SmartWorkspaceSourceRow = + | { kind: 'use-name'; value: string; name: string } + | { kind: 'create-branch'; value: string; name: string } + | { kind: 'github'; value: string; item: GitHubWorkItem } + | { kind: 'gitlab'; value: string; item: GitLabWorkItem } + | { kind: 'branch'; value: string; refName: string; localBranchName: string } + | { kind: 'linear'; value: string; issue: LinearIssue } + +type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult | null | undefined + +const EMPTY_HINT_BY_MODE: Record = { + smart: 'Start typing to create a name or find a source.', + github: 'Start typing to search GitHub PRs and issues.', + gitlab: 'Start typing to search GitLab MRs and issues.', + branches: 'No matching branches.', + linear: 'Start typing to search Linear issues.', + text: '' +} + +export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string { + return EMPTY_HINT_BY_MODE[mode] +} + +export function isSmartWorkspaceSourceQueryWithinLimit( + query: string, + maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES +): boolean { + return !isClipboardTextByteLengthOverLimit(query, maxBytes) +} + +export function getBranchSearchRequest({ + branchesEnabled, + disabled, + textOnly, + mode, + selectedRepoId, + query, + limit +}: { + branchesEnabled?: boolean + disabled: boolean + textOnly: boolean + mode: SmartNameMode + selectedRepoId: string | null + query: string + limit: number +}): { repoId: string; query: string; limit: number } | null { + if ( + branchesEnabled === false || + disabled || + textOnly || + !isSmartWorkspaceSourceQueryWithinLimit(query) || + !selectedRepoId + ) { + return null + } + const trimmedQuery = query.trim() + const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0) + if (!shouldSearchBranches) { + return null + } + return { repoId: selectedRepoId, query: trimmedQuery, limit } +} + +export function getVisibleBranchResults({ + branches, + mode, + resultRepoId, + resultQuery, + selectedRepoId, + value +}: { + branches: BaseRefSearchResult[] + mode: SmartNameMode + resultRepoId: string | null + resultQuery: string | null + selectedRepoId: string | null + value: string +}): BaseRefSearchResult[] { + if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { + return [] + } + const currentQuery = value.trim() + if (mode !== 'branches' && mode !== 'smart') { + return [] + } + if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { + return [] + } + return branches +} + +export function buildSmartWorkspaceSourceRows({ + branches, + githubItems, + gitlabAvailable, + gitlabItems, + linearAvailable, + linearIssues, + mode, + resultLimit, + value +}: { + branches: BaseRefSearchResult[] + githubItems: GitHubWorkItem[] + gitlabAvailable: boolean + gitlabItems: GitLabWorkItem[] + linearAvailable: boolean + linearIssues: LinearIssueSourceInput + mode: SmartNameMode + resultLimit: number + value: string +}): SmartWorkspaceSourceRow[] { + if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { + return [] + } + const trimmed = value.trim() + const nextRows: SmartWorkspaceSourceRow[] = [] + if (trimmed && mode === 'smart') { + nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) + } + if (mode === 'text') { + return nextRows + } + if (mode === 'smart' || mode === 'github') { + nextRows.push( + ...githubItems.map((item) => ({ + kind: 'github' as const, + value: `github-${item.repoId}-${item.type}-${item.number}`, + item + })) + ) + } + if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { + nextRows.push( + ...gitlabItems.map((item) => ({ + kind: 'gitlab' as const, + value: `gitlab-${item.repoId}-${item.type}-${item.number}`, + item + })) + ) + } + const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0) + if (shouldShowBranches) { + const branchExactMatch = branches.some( + (branch) => branch.refName === trimmed || branch.localBranchName === trimmed + ) + if (trimmed && mode === 'branches' && !branchExactMatch) { + nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) + } + nextRows.push( + ...branches.map((branch) => ({ + kind: 'branch' as const, + value: `branch-${branch.refName}`, + refName: branch.refName, + localBranchName: branch.localBranchName + })) + ) + } + if (linearAvailable && (mode === 'smart' || mode === 'linear')) { + // Why: mixed-version runtime responses may briefly carry the paginated + // collection shape into this render path; rendering must stay recoverable. + const resolvedLinearIssues = Array.isArray(linearIssues) + ? linearIssues + : Array.isArray(linearIssues?.items) + ? linearIssues.items + : [] + nextRows.push( + ...resolvedLinearIssues.map((issue) => ({ + kind: 'linear' as const, + value: `linear-${issue.id}`, + issue + })) + ) + } + return nextRows.slice(0, resultLimit + 1) +} diff --git a/src/shared/new-workspace/work-item-link-query-bounds.ts b/src/shared/new-workspace/work-item-link-query-bounds.ts new file mode 100644 index 00000000000..1e16d95ce0e --- /dev/null +++ b/src/shared/new-workspace/work-item-link-query-bounds.ts @@ -0,0 +1,10 @@ +import { isClipboardTextByteLengthOverLimit } from '../clipboard-text' + +export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024 + +export function isWorkItemLinkQueryTooLarge( + query: string, + maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(query, maxBytes) +}