feat(mobile): match desktop's Smart workspace source picker exactly (#7985)
* feat(mobile): start a workspace from a branch, issue/PR, or Linear ticket Unify mobile workspace creation with desktop. The "+" Create Workspace modal now has a primary "Start from" field that opens a tabbed search drawer (Branch · GitHub · GitLab · Linear), letting a user start a workspace from an existing/new git branch, a GitHub issue/PR, a GitLab issue/MR, or a Linear ticket — in addition to the default blank workspace. No new backend is required: the search RPCs (github.listWorkItems, gitlab.listWorkItems, linear.searchIssues/listIssues, repo.searchRefs) and the worktree.create linked-item params were already used by the mobile Tasks screen. This surfaces them in the create flow, reusing the existing pure modules (buildTaskWorkspaceCreateParams, shouldResolveHostedReviewStartPoint, filterAvailableTaskProviders). Details: - New pure modules: workspace-source-selection, use-workspace-source-search, source-workspace-create, worktree-create-retry, blank-workspace-create (the blank/retry path extracted from the modal for reuse + line budget). - New UI: WorkspaceSourcePickerDrawer (+ row) and SetupHookTrustDrawer (extracted from the modal). - Older paired desktops (missing the mobile.tasks.v1 capability) degrade to Branch + Blank only; GitLab/Linear tabs appear only when available. - GitHub/GitLab sources pin their repo; switching repos resets the source. PR/MR sources resolve their base branch at create time; SSH repos gate search until connected (Linear search is repo/SSH-independent). * fix(mobile): hydrate settings/trust before availability probes settle Review fixes for #7985: setTrustedOrcaHooks/setRuntimeSettings no longer wait on status.get/preflight.check/linear.status (a first-open preflight.check can take seconds, widening the spurious setup-trust re-prompt window). Also adds param-parity tests for createBlankWorkspace and a GitLab MR base-resolve test. * feat(mobile): match desktop's Smart source picker exactly Rework the mobile create-workspace source picker to be a faithful port of desktop's Smart picker instead of the earlier divergent "Start from" drawer. The mobile field is now the workspace-name input AND the source search, with the exact desktop tabs — Smart · GitHub · Linear · GitLab · Branch · Name. "Smart" fans out across GitHub + GitLab + Linear + branches, prepends a "Use '<name>'" row, and resolves pasted URLs / #123 / STA-42 to exact items (with a cross-repo switch prompt). Selecting a source shows a pill and moves the editable name into Advanced. The invented "Blank workspace" concept is removed — the neutral state is just a typed/empty name (blank submit still yields a creature name). DRY: the pure desktop logic (smart-workspace-source-results, -command-value, github-links, gitlab-links, work-item-link-query-bounds, github-work-item-identity) moves to src/shared/new-workspace/ with re-export shims at the old renderer paths, so both renderer and mobile share one implementation. composer-branch-selection and workspace-name were already shared and are reused directly. Two read-only lookup RPCs are allowlisted for mobile so pasted GitLab URLs and cross-repo GitHub URLs resolve to exact items (github.workItemByOwnerRepo, gitlab.workItemByPath). New mobile modules are split for max-lines: use-mobile-composer-source (selection state + desktop-parity handlers, PR/MR base resolve), use-smart-workspace-source + smart-source-fan-out/-search-requests/-paste-intent (RPC orchestration), composer-linked-work-item / work-item-lookup-text / mobile-smart-source-modes (pure logic), and SmartWorkspaceSourceField/Drawer/Row + SmartWorkspaceAdvancedFields. Replaces WorkspaceSourcePickerDrawer/Row, workspace-source-selection, use-workspace-source-search, and MobileWorkspaceNameInput. Reviewed by three adversarial agents + re-reviewed after fixes: GitHub search now returns issues AND PRs (not issues-only), Linear defaults to assigned, create-branch preserves slashy names, cross-repo PR base resolves against the item's own repo, displayName is suppressed for user-edited names, and the smart-mode GitHub fan-out respects availability. tsc/oxlint/max-lines-ratchet clean; 1328 mobile tests pass. * fix(mobile): keep smart source drawer fully visible * refactor: share workspace creation behavior across clients * fix: address workspace creation review findings --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
parent
280103aa04
commit
53a09afbef
|
|
@ -1428,6 +1428,7 @@ export function HostScreen({
|
|||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={worktrees}
|
||||
onVisibleChange={(visible) => {
|
||||
newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import {
|
|||
} from '../../../src/session/mobile-file-syntax'
|
||||
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
|
||||
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
|
||||
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
|
||||
import {
|
||||
filterWorkspaceAgents,
|
||||
isWorkspaceAgentEnabled,
|
||||
|
|
@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3
|
|||
const MAX_RENDERED_PR_DIFF_LINES = 400
|
||||
const GITLAB_PER_PAGE = 50
|
||||
const LINEAR_LIMIT = 50
|
||||
const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1'
|
||||
// Why: task detail drawers can launch child sheets; children must layer above
|
||||
// the still-mounted parent while its dismissal animation/state remains alive.
|
||||
const TASK_SECONDARY_DRAWER_Z_INDEX = 1100
|
||||
|
|
|
|||
|
|
@ -1935,48 +1935,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==}
|
||||
|
|
@ -2049,48 +2057,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==}
|
||||
|
|
@ -2552,36 +2568,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==}
|
||||
|
|
@ -4881,24 +4903,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==}
|
||||
|
|
@ -7025,22 +7051,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:
|
||||
|
|
@ -7050,7 +7076,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:
|
||||
|
|
@ -7085,12 +7111,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:
|
||||
|
|
@ -7105,42 +7131,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:
|
||||
|
|
@ -8604,7 +8630,7 @@ snapshots:
|
|||
'@jest/console@29.7.0':
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
chalk: 4.1.2
|
||||
jest-message-util: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
|
|
@ -8771,7 +8797,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': 26.1.1
|
||||
'@types/yargs': 17.0.35
|
||||
chalk: 4.1.2
|
||||
|
||||
|
|
@ -9529,24 +9555,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:
|
||||
|
|
@ -9561,7 +9587,7 @@ snapshots:
|
|||
|
||||
'@types/graceful-fs@4.1.9':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
|
||||
'@types/hammerjs@2.0.46': {}
|
||||
|
||||
|
|
@ -9639,7 +9665,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
|
|
@ -9659,7 +9685,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.59.2
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4
|
||||
|
|
@ -9676,6 +9702,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
debug: 4.4.3
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.59.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
|
|
@ -9685,6 +9720,10 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
|
|
@ -9714,6 +9753,21 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
|
|
@ -9972,7 +10026,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
|
||||
|
|
@ -10273,7 +10327,7 @@ snapshots:
|
|||
|
||||
chrome-launcher@0.15.2:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
escape-string-regexp: 4.0.0
|
||||
is-wsl: 2.2.0
|
||||
lighthouse-logger: 1.4.2
|
||||
|
|
@ -10282,7 +10336,7 @@ snapshots:
|
|||
|
||||
chromium-edge-launcher@0.2.0:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
escape-string-regexp: 4.0.0
|
||||
is-wsl: 2.2.0
|
||||
lighthouse-logger: 1.4.2
|
||||
|
|
@ -10678,7 +10732,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:
|
||||
|
|
@ -10776,11 +10830,11 @@ snapshots:
|
|||
|
||||
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
eslint: 9.39.4
|
||||
eslint-config-prettier: 9.1.2(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
|
||||
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
eslint-plugin-node: 11.1.0(eslint@9.39.4)
|
||||
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
|
||||
|
|
@ -10804,7 +10858,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
|
|
@ -10827,7 +10881,7 @@ snapshots:
|
|||
eslint-utils: 2.1.0
|
||||
regexpp: 3.2.0
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -10838,7 +10892,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
hasown: 2.0.3
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -11515,7 +11569,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: {}
|
||||
|
|
@ -12087,7 +12141,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/graceful-fs': 4.1.9
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
anymatch: 3.1.3
|
||||
fb-watchman: 2.0.2
|
||||
graceful-fs: 4.2.11
|
||||
|
|
@ -12242,7 +12296,7 @@ snapshots:
|
|||
jest-util@29.7.0:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
graceful-fs: 4.2.11
|
||||
|
|
@ -12287,7 +12341,7 @@ snapshots:
|
|||
|
||||
jest-worker@29.7.0:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
'@types/node': 26.1.1
|
||||
jest-util: 29.7.0
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
|
@ -14008,6 +14062,10 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
ts-declaration-location@1.0.7(typescript@5.9.3):
|
||||
dependencies:
|
||||
picomatch: 4.0.4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
overrides:
|
||||
xcode>uuid: 11.1.1
|
||||
|
|
@ -10,21 +10,19 @@ import {
|
|||
ActivityIndicator,
|
||||
Keyboard
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronUp, Check } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } 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 { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
|
||||
import {
|
||||
isSetupHookTrusted,
|
||||
normalizeSetupHookTrust,
|
||||
trustedOrcaHooksWithSetupApproval,
|
||||
persistSetupHookTrustApproval,
|
||||
wasSetupHookPreviouslyApproved,
|
||||
type SetupHookTrust
|
||||
} from '../tasks/setup-hook-trust'
|
||||
|
|
@ -49,6 +47,24 @@ import {
|
|||
refreshMobileNewWorkspaceDialogSelectedRepo,
|
||||
resolveMobileNewWorkspaceDialogRepoId
|
||||
} from '../worktree/new-workspace-dialog-repo-selection'
|
||||
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
|
||||
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
|
||||
import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability'
|
||||
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
type TaskProvider
|
||||
} from '../tasks/mobile-task-providers'
|
||||
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 { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
|
||||
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
|
||||
type Repo = {
|
||||
id: string
|
||||
|
|
@ -56,6 +72,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'
|
||||
|
|
@ -91,13 +110,11 @@ type CreateOptions = {
|
|||
approvedSetupContentHash?: string
|
||||
}
|
||||
|
||||
type SetupTrustPrompt = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
scriptContent: string
|
||||
contentHash: string
|
||||
previouslyApproved: boolean
|
||||
}
|
||||
type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
|
||||
|
||||
// Why: iOS cannot reliably present a second native modal until the first drawer's
|
||||
// exit commits; one extra frame keeps transitions sequential on slower devices.
|
||||
const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
|
||||
|
||||
function repoColor(name: string): string {
|
||||
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
||||
|
|
@ -124,6 +141,7 @@ type Props = {
|
|||
// on the on-disk directory basename, so paths (not displayNames) are
|
||||
// what the suggestion logic must dedupe against.
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
|
@ -133,6 +151,7 @@ export function NewWorktreeModal({
|
|||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
onCreated,
|
||||
onClose
|
||||
}: Props) {
|
||||
|
|
@ -157,6 +176,7 @@ export function NewWorktreeModal({
|
|||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={existingWorktrees}
|
||||
onCreated={onCreated}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
|
@ -168,25 +188,28 @@ function NewWorktreeModalContent({
|
|||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
onCreated,
|
||||
onClose
|
||||
}: Props) {
|
||||
const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
|
||||
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
|
||||
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
||||
const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true)
|
||||
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
|
||||
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const createInFlightRef = useRef(false)
|
||||
const setupTrustActionInFlightRef = useRef(false)
|
||||
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
|
||||
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
|
||||
null
|
||||
)
|
||||
const [agentOverriddenState, setAgentOverridden] = useState(false)
|
||||
const [showAgentPicker, setShowAgentPicker] = useState(false)
|
||||
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
|
||||
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
|
||||
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
||||
|
|
@ -200,12 +223,40 @@ function NewWorktreeModalContent({
|
|||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(initialRepos == null)
|
||||
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
|
||||
const selectedRepoWorktreeBranches = useMemo(
|
||||
() => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null),
|
||||
[existingWorktrees, selectedRepo]
|
||||
)
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (drawerTransitionTimerRef.current) {
|
||||
clearTimeout(drawerTransitionTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
function transitionDrawer(nextView: Exclude<NewWorktreeDrawerView, 'transition'>): void {
|
||||
if (drawerTransitionTimerRef.current) {
|
||||
clearTimeout(drawerTransitionTimerRef.current)
|
||||
}
|
||||
setDrawerView('transition')
|
||||
drawerTransitionTimerRef.current = setTimeout(() => {
|
||||
drawerTransitionTimerRef.current = null
|
||||
setDrawerView(nextView)
|
||||
}, NEW_WORKTREE_DRAWER_TRANSITION_MS)
|
||||
}
|
||||
|
||||
// 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,
|
||||
worktreeBranches: selectedRepoWorktreeBranches,
|
||||
onError: setError
|
||||
})
|
||||
|
||||
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
|
||||
const sshGate = deriveWorkspaceSshGate({
|
||||
|
|
@ -242,6 +293,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<PasteRepoCandidate[]>(
|
||||
() =>
|
||||
repos.map((repo) => ({
|
||||
id: repo.id,
|
||||
displayName: repo.displayName,
|
||||
slug: deriveRepoSlug(repo)
|
||||
})),
|
||||
[repos]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
|
||||
return
|
||||
|
|
@ -298,27 +368,68 @@ function NewWorktreeModalContent({
|
|||
})
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [settingsResponse, uiResponse] = await Promise.all([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('ui.get')
|
||||
])
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (settingsResponse.ok) {
|
||||
const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings }
|
||||
setRuntimeSettings(result.settings)
|
||||
}
|
||||
if (uiResponse.ok) {
|
||||
const result = (uiResponse as RpcSuccess).result as {
|
||||
ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks }
|
||||
}
|
||||
setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {})
|
||||
}
|
||||
} catch {
|
||||
// Non-critical; repo.list owns the visible loading state.
|
||||
// Why: settle each RPC independently so a flaky availability probe (e.g. a
|
||||
// linear.status timeout, which rejects rather than resolving {ok:false})
|
||||
// can't discard the already-resolved critical settings/ui results.
|
||||
const probes = Promise.allSettled([
|
||||
client.sendRequest('status.get'),
|
||||
client.sendRequest('preflight.check'),
|
||||
client.sendRequest('linear.status')
|
||||
])
|
||||
const okResult = (entry: PromiseSettledResult<RpcResponse>): RpcSuccess | null =>
|
||||
entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null
|
||||
// Why: hydrate settings/trust the moment their own RPCs settle — gating them
|
||||
// on the probes (a first-open preflight.check can take seconds) widens the
|
||||
// window where an already-trusted setup hook spuriously re-prompts on create.
|
||||
const [settingsRes, uiRes] = await Promise.allSettled([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('ui.get')
|
||||
])
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
|
||||
const settingsResult = okResult(settingsRes)
|
||||
const settingsValue = settingsResult
|
||||
? (
|
||||
settingsResult.result as {
|
||||
settings: RuntimeSettings & { visibleTaskProviders?: unknown }
|
||||
}
|
||||
).settings
|
||||
: null
|
||||
if (settingsValue) {
|
||||
setRuntimeSettings(settingsValue)
|
||||
}
|
||||
const uiResult = okResult(uiRes)
|
||||
if (uiResult) {
|
||||
const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui
|
||||
setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
|
||||
}
|
||||
|
||||
const [statusRes, preflightRes, linearRes] = await probes
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
// Tasks is an additive RPC surface, so older paired desktops without the
|
||||
// capability fall back to branch + blank sources only.
|
||||
const statusResult = okResult(statusRes)
|
||||
const capabilities =
|
||||
(statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? []
|
||||
setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY))
|
||||
const glabInstalled =
|
||||
(okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab
|
||||
?.installed === true
|
||||
const linearConnected =
|
||||
(okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true
|
||||
const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders)
|
||||
setAvailableProviders(
|
||||
// Drop filterAvailableTaskProviders' forced 'github' fallback when the user
|
||||
// hid GitHub; the Branch tab always guarantees at least one tab remains.
|
||||
filterAvailableTaskProviders(visibleProviders, {
|
||||
gitlabInstalled: glabInstalled,
|
||||
linearConnected
|
||||
}).filter((provider) => visibleProviders.includes(provider))
|
||||
)
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
|
|
@ -486,31 +597,11 @@ function NewWorktreeModalContent({
|
|||
}
|
||||
}
|
||||
|
||||
async function persistSetupHookTrust(
|
||||
repoId: string,
|
||||
contentHash: string,
|
||||
alwaysTrust: boolean
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
const next = trustedOrcaHooksWithSetupApproval({
|
||||
trust: trustedOrcaHooks,
|
||||
repoId,
|
||||
contentHash,
|
||||
alwaysTrust
|
||||
})
|
||||
const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
setTrustedOrcaHooks(next)
|
||||
}
|
||||
|
||||
async function handleCreate(options: CreateOptions = {}) {
|
||||
if (!client || !selectedRepo) {
|
||||
if (!client || !selectedRepo || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
createInFlightRef.current = true
|
||||
setCreating(true)
|
||||
setError('')
|
||||
|
||||
|
|
@ -555,24 +646,9 @@ 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 ?? [])
|
||||
|
||||
// Why: mirrors src/renderer/src/store/slices/worktrees.ts
|
||||
// (createWorktree retry loop). Server-side checks (Branch X already
|
||||
// exists locally / on a remote / already has PR #N) can fire even
|
||||
// after the pre-flight basename dedupe — branches outlive worktrees
|
||||
// in git, and remote branches/PRs aren't visible from worktree.ps.
|
||||
// Retry up to 25 times by appending -2, -3, ... before surfacing
|
||||
// the error. The desktop applies this to user-typed names too, so
|
||||
// mobile follows suit for parity.
|
||||
const retryablePatterns = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
/already has pr #\d+/i
|
||||
]
|
||||
const candidateFor = (attempt: number): string =>
|
||||
attempt === 0 ? baseName : `${baseName}-${attempt + 1}`
|
||||
let setupDecision: SetupDecision = 'inherit'
|
||||
if (setupCommand) {
|
||||
if (options.setupOverride) {
|
||||
|
|
@ -602,44 +678,46 @@ function NewWorktreeModalContent({
|
|||
contentHash: setupTrust.contentHash,
|
||||
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
|
||||
})
|
||||
transitionDrawer('trust')
|
||||
return
|
||||
}
|
||||
|
||||
let lastError: string | null = null
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const candidateName = candidateFor(attempt)
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${selectedRepo.id}`,
|
||||
startupCommand: command,
|
||||
setupDecision,
|
||||
name: candidateName
|
||||
}
|
||||
if (selectedAgent.id !== '__blank__') {
|
||||
params.createdWithAgent = selectedAgent.id
|
||||
}
|
||||
if (note.trim()) {
|
||||
params.comment = note.trim()
|
||||
}
|
||||
|
||||
const response = await client.sendRequest('worktree.create', params, {
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
onClose()
|
||||
onCreated(result.worktree.id, candidateName)
|
||||
return
|
||||
}
|
||||
|
||||
lastError = response.error.message
|
||||
if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) {
|
||||
break
|
||||
}
|
||||
const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined
|
||||
const trimmedNote = note.trim() || undefined
|
||||
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
|
||||
}
|
||||
setError(lastError ?? 'Failed to create workspace')
|
||||
onClose()
|
||||
onCreated(result.worktreeId, result.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create workspace')
|
||||
} finally {
|
||||
createInFlightRef.current = false
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -670,15 +748,74 @@ 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 handleRepoSelected(repo: Repo): void {
|
||||
const repoChanged = repo.id !== selectedRepo?.id
|
||||
setSelectedRepo(repo)
|
||||
// Branch and provider-backed sources are repo-scoped; Linear/Jira are global
|
||||
// work context and survive choosing a different implementation repo.
|
||||
if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) {
|
||||
composer.handleClearSmartNameSelection()
|
||||
}
|
||||
}
|
||||
|
||||
async function approveSetupTrust(alwaysTrust: boolean): Promise<void> {
|
||||
if (
|
||||
!client ||
|
||||
!setupTrustPrompt ||
|
||||
setupTrustActionInFlightRef.current ||
|
||||
createInFlightRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
setupTrustActionInFlightRef.current = true
|
||||
setCreating(true)
|
||||
try {
|
||||
const nextTrust = await persistSetupHookTrustApproval({
|
||||
client,
|
||||
trust: trustedOrcaHooks,
|
||||
repoId: setupTrustPrompt.repoId,
|
||||
contentHash: setupTrustPrompt.contentHash,
|
||||
alwaysTrust
|
||||
})
|
||||
setTrustedOrcaHooks(nextTrust)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
transitionDrawer('form')
|
||||
await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
||||
} finally {
|
||||
setupTrustActionInFlightRef.current = false
|
||||
if (!createInFlightRef.current) {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeSetupTrust(): void {
|
||||
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
setSetupTrustPrompt(null)
|
||||
transitionDrawer('form')
|
||||
}
|
||||
|
||||
function skipSetupTrust(): void {
|
||||
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
closeSetupTrust()
|
||||
void handleCreate({ setupOverride: 'skip' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
|
|
@ -702,7 +839,7 @@ function NewWorktreeModalContent({
|
|||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
setShowRepoPicker(true)
|
||||
transitionDrawer('repo')
|
||||
}}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
|
|
@ -720,6 +857,18 @@ function NewWorktreeModalContent({
|
|||
</Pressable>
|
||||
</View>
|
||||
|
||||
<SmartWorkspaceSourceField
|
||||
composer={composer}
|
||||
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
|
||||
disabled={sshGate.requiresConnection}
|
||||
onBeforeOpen={() => setError('')}
|
||||
onOpenDrawer={() => transitionDrawer('source')}
|
||||
/>
|
||||
|
||||
{composer.forkPushWarning ? (
|
||||
<Text style={styles.sourceWarning}>{composer.forkPushWarning}</Text>
|
||||
) : null}
|
||||
|
||||
{selectedRepoConnectionId ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>SSH Connection</Text>
|
||||
|
|
@ -763,28 +912,6 @@ function NewWorktreeModalContent({
|
|||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
|
||||
</Text>
|
||||
<MobileWorkspaceNameInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={(t) => {
|
||||
setName(t)
|
||||
setError('')
|
||||
}}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (canCreate) {
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Pressable
|
||||
|
|
@ -792,7 +919,7 @@ function NewWorktreeModalContent({
|
|||
disabled={sshGate.requiresConnection}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
setShowAgentPicker(true)
|
||||
transitionDrawer('agent')
|
||||
}}
|
||||
>
|
||||
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
||||
|
|
@ -814,6 +941,11 @@ function NewWorktreeModalContent({
|
|||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<SmartWorkspaceAdvancedFields
|
||||
composer={composer}
|
||||
selectedRepoIsGit={selectedRepoIsGit}
|
||||
/>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Note</Text>
|
||||
<TextInput
|
||||
|
|
@ -903,22 +1035,39 @@ function NewWorktreeModalContent({
|
|||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Sub-modals for pickers — rendered outside the main modal so they
|
||||
layer on top and scroll without touch conflicts. */}
|
||||
{/* Why: list drawers stay outside the form's ScrollView, and the transition
|
||||
state prevents overlapping native modals from swallowing iOS taps. */}
|
||||
<SmartWorkspaceSourceDrawer
|
||||
visible={visible && drawerView === 'source'}
|
||||
client={client}
|
||||
composer={composer}
|
||||
availability={sourceAvailability}
|
||||
repoId={selectedRepo?.id ?? null}
|
||||
repos={pasteRepos}
|
||||
sshReady={!sshGate.requiresConnection}
|
||||
onRepoChange={(repoId) => {
|
||||
const nextRepo = repos.find((repo) => repo.id === repoId)
|
||||
if (nextRepo) {
|
||||
setSelectedRepo(nextRepo)
|
||||
}
|
||||
}}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && showRepoPicker}
|
||||
visible={visible && drawerView === 'repo'}
|
||||
title="Repository"
|
||||
items={repoPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => setSelectedRepo(item.repo)}
|
||||
onClose={() => setShowRepoPicker(false)}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(item) => {
|
||||
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && showAgentPicker}
|
||||
visible={visible && drawerView === 'agent'}
|
||||
title="Agent"
|
||||
items={pickerAgentOptions}
|
||||
selectedId={selectedAgent.id}
|
||||
|
|
@ -926,104 +1075,19 @@ function NewWorktreeModalContent({
|
|||
setAgentOverridden(true)
|
||||
setSelectedAgent(agent)
|
||||
}}
|
||||
onClose={() => setShowAgentPicker(false)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
|
||||
/>
|
||||
|
||||
<BottomDrawer
|
||||
visible={visible && setupTrustPrompt != null}
|
||||
onClose={() => setSetupTrustPrompt(null)}
|
||||
>
|
||||
{setupTrustPrompt ? (
|
||||
<View>
|
||||
<View style={styles.trustHeader}>
|
||||
<Text style={styles.title}>
|
||||
{setupTrustPrompt.previouslyApproved
|
||||
? `${setupTrustPrompt.repoName}'s setup script changed`
|
||||
: `Run setup from ${setupTrustPrompt.repoName}?`}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This repository's orca.yaml runs before the workspace starts. Only run it if you
|
||||
trust this repository.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.trustScriptBox}>
|
||||
<Text style={styles.trustScriptLabel}>
|
||||
{setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'}
|
||||
</Text>
|
||||
<Text style={styles.trustScriptText}>{setupTrustPrompt.scriptContent}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.trustActionGroup}>
|
||||
<Pressable
|
||||
style={styles.trustActionRow}
|
||||
disabled={creating}
|
||||
onPress={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await persistSetupHookTrust(
|
||||
setupTrustPrompt.repoId,
|
||||
setupTrustPrompt.contentHash,
|
||||
false
|
||||
)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
await handleCreate({
|
||||
setupOverride: 'run',
|
||||
approvedSetupContentHash: approvedHash
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
||||
}
|
||||
})()
|
||||
}
|
||||
>
|
||||
<Check size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.trustActionText}>Run hooks</Text>
|
||||
</Pressable>
|
||||
<View style={styles.trustActionSeparator} />
|
||||
<Pressable
|
||||
style={styles.trustActionRow}
|
||||
disabled={creating}
|
||||
onPress={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await persistSetupHookTrust(
|
||||
setupTrustPrompt.repoId,
|
||||
setupTrustPrompt.contentHash,
|
||||
true
|
||||
)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
await handleCreate({
|
||||
setupOverride: 'run',
|
||||
approvedSetupContentHash: approvedHash
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
||||
}
|
||||
})()
|
||||
}
|
||||
>
|
||||
<Check size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.trustActionText}>Always trust and run</Text>
|
||||
</Pressable>
|
||||
<View style={styles.trustActionSeparator} />
|
||||
<Pressable
|
||||
style={styles.trustActionRow}
|
||||
disabled={creating}
|
||||
onPress={() => {
|
||||
setSetupTrustPrompt(null)
|
||||
void handleCreate({ setupOverride: 'skip' })
|
||||
}}
|
||||
>
|
||||
<Text style={styles.trustActionText}>Don't run</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</BottomDrawer>
|
||||
<SetupHookTrustDrawer
|
||||
visible={visible && drawerView === 'trust' && setupTrustPrompt != null}
|
||||
prompt={setupTrustPrompt}
|
||||
busy={creating}
|
||||
onRunOnce={() => void approveSetupTrust(false)}
|
||||
onAlwaysTrust={() => void approveSetupTrust(true)}
|
||||
onDontRun={skipSetupTrust}
|
||||
onClose={closeSetupTrust}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1164,6 +1228,12 @@ const styles = StyleSheet.create({
|
|||
fontSize: 13,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
sourceWarning: {
|
||||
marginTop: -spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
fontSize: 12,
|
||||
color: colors.statusAmber
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -1247,52 +1317,6 @@ const styles = StyleSheet.create({
|
|||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
trustHeader: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
trustScriptBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
trustScriptLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
trustScriptText: {
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
trustActionGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.input,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
trustActionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
trustActionText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '500'
|
||||
},
|
||||
trustActionSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type Props = {
|
|||
client: RpcClient | null
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
onRouteVisibleChange: (visible: boolean) => void
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
|
|
@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
|||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
onVisibleChange,
|
||||
onRouteVisibleChange,
|
||||
onCreated
|
||||
|
|
@ -58,6 +60,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
|||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={existingWorktrees}
|
||||
onCreated={onCreated}
|
||||
onClose={close}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { Check } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
export type SetupTrustPrompt = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
scriptContent: string
|
||||
contentHash: string
|
||||
previouslyApproved: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
prompt: SetupTrustPrompt | null
|
||||
busy: boolean
|
||||
onRunOnce: () => void
|
||||
onAlwaysTrust: () => void
|
||||
onDontRun: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace
|
||||
// create that would run an untrusted setup script. Extracted from NewWorktreeModal
|
||||
// to keep that file focused; the async persist/create logic stays with the caller.
|
||||
export function SetupHookTrustDrawer({
|
||||
visible,
|
||||
prompt,
|
||||
busy,
|
||||
onRunOnce,
|
||||
onAlwaysTrust,
|
||||
onDontRun,
|
||||
onClose
|
||||
}: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible && prompt != null} onClose={onClose}>
|
||||
{prompt ? (
|
||||
<View>
|
||||
<View style={styles.trustHeader}>
|
||||
<Text style={styles.title}>
|
||||
{prompt.previouslyApproved
|
||||
? `${prompt.repoName}'s setup script changed`
|
||||
: `Run setup from ${prompt.repoName}?`}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This repository's orca.yaml runs before the workspace starts. Only run it if you trust
|
||||
this repository.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.trustScriptBox}>
|
||||
<Text style={styles.trustScriptLabel}>
|
||||
{prompt.previouslyApproved ? 'New setup script' : 'Setup script'}
|
||||
</Text>
|
||||
<Text style={styles.trustScriptText}>{prompt.scriptContent}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.trustActionGroup}>
|
||||
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onRunOnce}>
|
||||
<Check size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.trustActionText}>Run hooks</Text>
|
||||
</Pressable>
|
||||
<View style={styles.trustActionSeparator} />
|
||||
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onAlwaysTrust}>
|
||||
<Check size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.trustActionText}>Always trust and run</Text>
|
||||
</Pressable>
|
||||
<View style={styles.trustActionSeparator} />
|
||||
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onDontRun}>
|
||||
<Text style={styles.trustActionText}>Don't run</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
trustHeader: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
trustScriptBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
trustScriptLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
trustScriptText: {
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
trustActionGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.input,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
trustActionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
trustActionText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '500'
|
||||
},
|
||||
trustActionSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
|
||||
import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes'
|
||||
import { TaskProviderLogo } from './TaskProviderLogo'
|
||||
|
||||
// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes,
|
||||
// lucide glyphs for the neutral modes.
|
||||
export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) {
|
||||
if (icon.type === 'provider') {
|
||||
return <TaskProviderLogo provider={icon.provider} size={14} color={color} />
|
||||
}
|
||||
if (icon.name === 'sparkles') {
|
||||
return <Sparkles size={14} color={color} />
|
||||
}
|
||||
if (icon.name === 'git-branch') {
|
||||
return <GitBranch size={14} color={color} />
|
||||
}
|
||||
return <CaseSensitive size={14} color={color} />
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={composer.name}
|
||||
onChangeText={composer.setName}
|
||||
placeholder="Workspace name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showBranchOverride ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Branch name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={composer.branchNameOverride ?? ''}
|
||||
onChangeText={composer.handleBranchNameOverrideChange}
|
||||
placeholder="Derived from name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{composer.reuseEligibleBranch ? (
|
||||
<View style={styles.field}>
|
||||
<View style={styles.reuseRow}>
|
||||
<Text style={styles.reuseLabel} numberOfLines={1}>
|
||||
Reuse branch “{composer.reuseEligibleBranch}”
|
||||
</Text>
|
||||
<Switch
|
||||
value={composer.reuseSelectedBranch}
|
||||
onValueChange={composer.setReuseSelectedBranch}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
style={styles.reuseSwitch}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : 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 }]
|
||||
}
|
||||
})
|
||||
|
|
@ -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<SmartNameMode>(() => resolveDefaultSmartMode(availability))
|
||||
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('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<void> {
|
||||
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 (
|
||||
<BottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
dragContentToDismiss={false}
|
||||
contentScrollable={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Name or 'Create From'</Text>
|
||||
<Pressable onPress={closeSoon} hitSlop={8}>
|
||||
<Text style={styles.done}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
style={styles.search}
|
||||
value={composer.name}
|
||||
onChangeText={composer.setName}
|
||||
placeholder="Type a name or search a source"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<View style={styles.tabRow}>
|
||||
{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 (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={[styles.tab, selected && styles.tabSelected]}
|
||||
onPress={() => setMode(option.id)}
|
||||
>
|
||||
<SmartSourceModeIcon icon={option.icon} color={tint} />
|
||||
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{effectiveMode === 'gitlab' ? (
|
||||
<View style={styles.chipRow}>
|
||||
{MR_STATE_FILTER_OPTIONS.map((option) => {
|
||||
const selected = option.id === mrStateFilter
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={[styles.chip, selected && styles.chipSelected]}
|
||||
onPress={() => setMrStateFilter(option.id)}
|
||||
>
|
||||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{crossRepoPrompt ? (
|
||||
<View style={styles.crossRepo}>
|
||||
<Text style={styles.crossRepoText}>
|
||||
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
|
||||
</Text>
|
||||
<View style={styles.crossRepoActions}>
|
||||
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
|
||||
<Text style={styles.crossRepoDismissText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
|
||||
<Text style={styles.crossRepoSwitchText}>
|
||||
Switch to {crossRepoPrompt.matchingRepo.displayName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
|
||||
<Text style={styles.notice}>Connect the repository to search sources.</Text>
|
||||
) : needsGitHubRemote ? (
|
||||
<Text style={styles.notice}>
|
||||
This SSH repo needs a GitHub remote to list issues and PRs.
|
||||
</Text>
|
||||
) : error ? (
|
||||
<Text style={styles.errorNotice}>{error}</Text>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={rows}
|
||||
keyExtractor={(row) => row.value}
|
||||
style={styles.list}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
ListFooterComponent={
|
||||
loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : showEmpty ? (
|
||||
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
|
||||
)}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import {
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import type { SmartNameSelection } from '../tasks/mobile-composer-source-types'
|
||||
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { TaskProviderLogo } from './TaskProviderLogo'
|
||||
|
||||
type Props = {
|
||||
composer: MobileComposerSource
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onBeforeOpen?: () => void
|
||||
onOpenDrawer: () => void
|
||||
}
|
||||
|
||||
function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) {
|
||||
if (kind === 'github-pr') {
|
||||
return <GitPullRequest size={15} color={colors.textSecondary} />
|
||||
}
|
||||
if (kind === 'gitlab-mr') {
|
||||
return <GitMerge size={15} color={colors.textSecondary} />
|
||||
}
|
||||
if (kind === 'github-issue' || kind === 'gitlab-issue') {
|
||||
return <CircleDot size={15} color={colors.textSecondary} />
|
||||
}
|
||||
if (kind === 'branch') {
|
||||
return <GitBranch size={15} color={colors.textSecondary} />
|
||||
}
|
||||
return <TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
|
||||
}
|
||||
|
||||
export function SmartWorkspaceSourceField({
|
||||
composer,
|
||||
label,
|
||||
disabled,
|
||||
onBeforeOpen,
|
||||
onOpenDrawer
|
||||
}: Props) {
|
||||
const selection = composer.smartNameSelection
|
||||
|
||||
function openDrawer(): void {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
onBeforeOpen?.()
|
||||
onOpenDrawer()
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
{label} <Text style={styles.labelHint}>[Optional]</Text>
|
||||
</Text>
|
||||
{selection ? (
|
||||
<View style={styles.pill}>
|
||||
<SelectionIcon kind={selection.kind} />
|
||||
<Text style={styles.pillLabel} numberOfLines={1}>
|
||||
{selection.label}
|
||||
</Text>
|
||||
{selection.url ? (
|
||||
<Pressable
|
||||
hitSlop={6}
|
||||
onPress={() => selection.url && void Linking.openURL(selection.url).catch(() => {})}
|
||||
>
|
||||
<ExternalLink size={15} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable hitSlop={6} onPress={composer.handleClearSmartNameSelection}>
|
||||
<X size={15} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
style={[styles.input, disabled && styles.disabled]}
|
||||
disabled={disabled}
|
||||
onPress={openDrawer}
|
||||
>
|
||||
<Text
|
||||
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{composer.name || 'Type a name or search a source'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
|
@ -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: <Sparkles size={16} color={colors.textSecondary} />,
|
||||
title: `Use "${row.name}"`,
|
||||
subtitle: 'Name this workspace'
|
||||
}
|
||||
case 'create-branch':
|
||||
return {
|
||||
icon: <GitBranch size={16} color={colors.accentBlue} />,
|
||||
title: `Create branch "${row.name}"`,
|
||||
subtitle: 'New branch'
|
||||
}
|
||||
case 'github':
|
||||
return {
|
||||
icon: <TaskProviderLogo provider="github" size={16} color={colors.textSecondary} />,
|
||||
title: row.item.title,
|
||||
subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`,
|
||||
status: row.item.state
|
||||
}
|
||||
case 'gitlab':
|
||||
return {
|
||||
icon: <TaskProviderLogo provider="gitlab" size={16} color={colors.textSecondary} />,
|
||||
title: row.item.title,
|
||||
subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`,
|
||||
status: row.item.state
|
||||
}
|
||||
case 'branch':
|
||||
return {
|
||||
icon: <GitBranch size={16} color={colors.textSecondary} />,
|
||||
title: row.localBranchName || row.refName,
|
||||
subtitle: row.refName
|
||||
}
|
||||
case 'linear':
|
||||
return {
|
||||
icon: <TaskProviderLogo provider="linear" size={16} color={colors.textSecondary} />,
|
||||
title: row.issue.title,
|
||||
subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`,
|
||||
status: row.issue.state?.name
|
||||
}
|
||||
default:
|
||||
return { icon: <CaseSensitive size={16} color={colors.textSecondary} />, title: '' }
|
||||
}
|
||||
}
|
||||
|
||||
export function SmartWorkspaceSourceRow({ row, onPress }: Props) {
|
||||
const content = resolveRowContent(row)
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.icon}>{content.icon}</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{content.title}
|
||||
</Text>
|
||||
{content.subtitle ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{content.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{content.status ? (
|
||||
<View style={styles.pill}>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{content.status}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createBlankWorkspace } from './blank-workspace-create'
|
||||
|
||||
type Call = { method: string; params: unknown }
|
||||
|
||||
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
calls.push({ method, params })
|
||||
const result = script(method, calls.length)
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
id: '1',
|
||||
ok: false,
|
||||
error: { code: 'x', message: result.message },
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
}
|
||||
|
||||
describe('createBlankWorkspace', () => {
|
||||
it('assembles exactly the params the modal historically sent, omitting empty extras', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
|
||||
|
||||
const result = await createBlankWorkspace({
|
||||
client,
|
||||
repoId: 'repo-1',
|
||||
baseName: 'octopus',
|
||||
startupCommand: undefined,
|
||||
createdWithAgentId: undefined,
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual({
|
||||
method: 'worktree.create',
|
||||
params: {
|
||||
repo: 'id:repo-1',
|
||||
startupCommand: undefined,
|
||||
setupDecision: 'inherit',
|
||||
name: 'octopus'
|
||||
}
|
||||
})
|
||||
const params = calls[0]?.params as Record<string, unknown>
|
||||
expect('createdWithAgent' in params).toBe(false)
|
||||
expect('comment' in params).toBe(false)
|
||||
})
|
||||
|
||||
it('includes createdWithAgent and comment only when provided', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls)
|
||||
|
||||
await createBlankWorkspace({
|
||||
client,
|
||||
repoId: 'repo-2',
|
||||
baseName: 'manatee',
|
||||
startupCommand: 'claude',
|
||||
createdWithAgentId: 'claude',
|
||||
comment: 'spike',
|
||||
setupDecision: 'run'
|
||||
})
|
||||
|
||||
expect(calls[0]?.params).toMatchObject({
|
||||
repo: 'id:repo-2',
|
||||
name: 'manatee',
|
||||
startupCommand: 'claude',
|
||||
setupDecision: 'run',
|
||||
createdWithAgent: 'claude',
|
||||
comment: 'spike'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries with a numeric suffix on a branch-collision error', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient((_method, call) => {
|
||||
if (call === 1) {
|
||||
return new Error('Branch "octopus" already exists locally. Pick a different branch name.')
|
||||
}
|
||||
return { worktree: { id: 'wt-3' } }
|
||||
}, calls)
|
||||
|
||||
const result = await createBlankWorkspace({
|
||||
client,
|
||||
repoId: 'repo-1',
|
||||
baseName: 'octopus',
|
||||
startupCommand: undefined,
|
||||
createdWithAgentId: undefined,
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
|
||||
expect(calls).toHaveLength(2)
|
||||
const retryParams = calls[1]?.params as Record<string, unknown>
|
||||
expect(retryParams.name).toBe('octopus-2')
|
||||
})
|
||||
|
||||
it('retries on the bare older-runtime collision message', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient((_method, call) => {
|
||||
if (call === 1) {
|
||||
return new Error('Branch "octopus" already exists.')
|
||||
}
|
||||
return { worktree: { id: 'wt-4' } }
|
||||
}, calls)
|
||||
|
||||
const result = await createBlankWorkspace({
|
||||
client,
|
||||
repoId: 'repo-1',
|
||||
baseName: 'octopus',
|
||||
startupCommand: undefined,
|
||||
createdWithAgentId: undefined,
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('surfaces a non-collision error without retrying', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => new Error('SSH connection is not available'), calls)
|
||||
|
||||
const result = await createBlankWorkspace({
|
||||
client,
|
||||
repoId: 'repo-1',
|
||||
baseName: 'octopus',
|
||||
startupCommand: undefined,
|
||||
createdWithAgentId: undefined,
|
||||
comment: undefined,
|
||||
setupDecision: 'skip'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ error: 'SSH connection is not available' })
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorkspaceCreateSetupDecision } from './workspace-create-params'
|
||||
|
||||
// The blank/named create path, extracted from NewWorktreeModal so the modal keeps
|
||||
// only the UI-coupled setup-trust flow. Assembles worktree.create params and
|
||||
// applies the shared name-collision retry.
|
||||
export async function createBlankWorkspace(args: {
|
||||
client: RpcClient
|
||||
repoId: string
|
||||
baseName: string
|
||||
startupCommand: string | undefined
|
||||
createdWithAgentId: TuiAgent | undefined
|
||||
comment: string | undefined
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
return createWorktreeWithNameRetry({
|
||||
client: args.client,
|
||||
baseName: args.baseName,
|
||||
buildParams: (name) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${args.repoId}`,
|
||||
startupCommand: args.startupCommand,
|
||||
setupDecision: args.setupDecision,
|
||||
name
|
||||
}
|
||||
if (args.createdWithAgentId) {
|
||||
params.createdWithAgent = args.createdWithAgentId
|
||||
}
|
||||
if (args.comment) {
|
||||
params.comment = args.comment
|
||||
}
|
||||
return params
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)', () => {
|
||||
expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true)
|
||||
expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false)
|
||||
})
|
||||
|
||||
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<string, unknown>) => ({
|
||||
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]
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
|
||||
import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name'
|
||||
import {
|
||||
buildGitHubWorkspaceSource,
|
||||
buildGitLabWorkspaceSource,
|
||||
buildLinearWorkspaceSource,
|
||||
buildWorkspaceSourceSelection,
|
||||
getWorkspaceSourceName,
|
||||
shouldApplyWorkspaceSourceAutoName
|
||||
} from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { resolveComposerBranchPick as resolveSharedComposerBranchPick } 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 buildGitHubWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function buildGitLabLinkedWorkItem(item: {
|
||||
type: 'issue' | 'mr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repoId: string
|
||||
}): MobileLinkedWorkItem {
|
||||
return buildGitLabWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function buildLinearLinkedWorkItem(issue: {
|
||||
identifier: string
|
||||
title: string
|
||||
url: string
|
||||
workspaceId?: string
|
||||
}): MobileLinkedWorkItem {
|
||||
return buildLinearWorkspaceSource(issue)
|
||||
}
|
||||
|
||||
// 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 shouldApplyWorkspaceSourceAutoName(args)
|
||||
}
|
||||
|
||||
export function resolveWorkItemAutoName(item: {
|
||||
type: 'issue' | 'pr' | 'mr'
|
||||
number: number
|
||||
title: string
|
||||
provider: 'github' | 'gitlab' | 'linear'
|
||||
linearIdentifier?: string
|
||||
}): string {
|
||||
return getWorkspaceSourceName({ ...item, url: '' }).seedName
|
||||
}
|
||||
|
||||
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 {
|
||||
return buildWorkspaceSourceSelection(args) as SmartNameSelection | 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 selection = resolveSharedComposerBranchPick(args)
|
||||
return {
|
||||
base: {
|
||||
baseBranch: selection.baseBranch,
|
||||
branchNameOverride: selection.branchNameOverride
|
||||
},
|
||||
reuseEligibleBranch: selection.reuseEligibleBranch,
|
||||
reuseSelectedBranch: selection.defaultReuse,
|
||||
...(selection.name !== undefined && selection.lastAutoName !== undefined
|
||||
? { name: selection.name, lastAutoName: selection.lastAutoName }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export type { GitHubWorkItem, GitLabWorkItem, LinearIssue }
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { GitHubPrStartPoint } from '../../../src/shared/types'
|
||||
|
||||
// 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 = Pick<
|
||||
GitHubPrStartPoint,
|
||||
'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify'
|
||||
>
|
||||
|
||||
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<GitHubPrStartPoint> {
|
||||
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 GitHubPrStartPoint | { error: string }
|
||||
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<ComposerHostedBase> {
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
import type {
|
||||
WorkspaceSourceLinkedItem,
|
||||
WorkspaceSourceSelection
|
||||
} from '../../../src/shared/new-workspace/workspace-source'
|
||||
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
|
||||
|
||||
export type { SmartNameMode }
|
||||
|
||||
export type ComposerBaseState = {
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
pushTarget?: WorkspaceCreateGitPushTarget
|
||||
branchNameOverride?: string
|
||||
}
|
||||
|
||||
// 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 = Omit<WorkspaceSourceLinkedItem, 'provider'> & {
|
||||
provider: Exclude<WorkspaceSourceLinkedItem['provider'], 'jira'>
|
||||
}
|
||||
|
||||
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 = Omit<WorkspaceSourceSelection, 'kind'> & {
|
||||
kind: SmartNameSelectionKind
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Runtime capability the desktop advertises when it supports the mobile Tasks RPC
|
||||
// surface (github/gitlab/linear work items + repo.searchRefs). Older paired
|
||||
// desktops omit it, so mobile must degrade to blank/new-branch sources only.
|
||||
// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts.
|
||||
export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1'
|
||||
|
|
@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
isSetupHookTrusted,
|
||||
normalizeSetupHookTrust,
|
||||
persistSetupHookTrustApproval,
|
||||
trustedOrcaHooksWithSetupApproval,
|
||||
wasSetupHookPreviouslyApproved
|
||||
} from './setup-hook-trust'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
describe('setup hook trust', () => {
|
||||
it('trusts a setup script only when the approved hash matches', () => {
|
||||
|
|
@ -71,6 +73,27 @@ describe('setup hook trust', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('persists and returns the approved trust state', async () => {
|
||||
let persisted: unknown
|
||||
const client = {
|
||||
sendRequest: async (_method: string, params: unknown) => {
|
||||
persisted = params
|
||||
return { ok: true, result: null }
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
|
||||
const next = await persistSetupHookTrustApproval({
|
||||
client,
|
||||
trust: {},
|
||||
repoId: 'repo-1',
|
||||
contentHash: 'setup-hash',
|
||||
alwaysTrust: false
|
||||
})
|
||||
|
||||
expect(persisted).toEqual({ trustedOrcaHooks: next })
|
||||
expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects previous setup approval and ignores incomplete trust payloads', () => {
|
||||
expect(
|
||||
wasSetupHookPreviouslyApproved(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
export type SetupHookTrust = {
|
||||
contentHash: string
|
||||
|
|
@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: {
|
|||
return { ...args.trust, [args.repoId]: nextRepo }
|
||||
}
|
||||
|
||||
export async function persistSetupHookTrustApproval(args: {
|
||||
client: RpcClient
|
||||
trust: PersistedTrustedOrcaHooks
|
||||
repoId: string
|
||||
contentHash: string
|
||||
alwaysTrust: boolean
|
||||
}): Promise<PersistedTrustedOrcaHooks> {
|
||||
const next = trustedOrcaHooksWithSetupApproval(args)
|
||||
const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function normalizeSetupHookTrust(
|
||||
setupTrust: SetupHookTrust | null | undefined
|
||||
): SetupHookTrust | null {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
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<string, unknown> }
|
||||
|
||||
function fakeClient(byMethod: Record<string, unknown>, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not send oversized source queries to any provider', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient({}, calls)
|
||||
const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client })
|
||||
expect(calls).toEqual([])
|
||||
expect(result).toMatchObject({
|
||||
githubItems: [],
|
||||
gitlabItems: [],
|
||||
linearIssues: [],
|
||||
branches: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import type {
|
||||
BaseRefSearchResult,
|
||||
GitHubWorkItem,
|
||||
GitLabWorkItem,
|
||||
LinearIssue
|
||||
} from '../../../src/shared/types'
|
||||
import {
|
||||
isSmartWorkspaceSourceQueryWithinLimit,
|
||||
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<SmartFanOutResult, 'needsGitHubRemote' | 'error'> = {
|
||||
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<SmartFanOutResult> {
|
||||
if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) {
|
||||
// Why: the source limit is an outbound-request boundary, not only a render
|
||||
// limit; pasted payloads must never fan out to provider CLIs or SSH hosts.
|
||||
return { ...EMPTY, needsGitHubRemote: false, error: '' }
|
||||
}
|
||||
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<GitHubWorkItem[]>([]),
|
||||
tasks.gitlab ?? Promise.resolve<GitLabWorkItem[]>([]),
|
||||
tasks.linear ?? Promise.resolve<LinearIssue[]>([]),
|
||||
tasks.branches ?? Promise.resolve<BaseRefSearchResult[]>([])
|
||||
])
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deriveRepoSlug,
|
||||
findRepoMatchingSlug,
|
||||
findRepoMatchingSlugForPaste,
|
||||
resolvePasteIntent,
|
||||
type PasteRepoCandidate
|
||||
} from './smart-source-paste-intent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('rejects oversized paste intents before any exact lookup', () => {
|
||||
expect(
|
||||
resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`)
|
||||
).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()
|
||||
})
|
||||
|
||||
it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
sendRequest: async (_method: string, params: unknown) => {
|
||||
const repo = (params as { repo: string }).repo
|
||||
calls.push(repo)
|
||||
return {
|
||||
ok: true,
|
||||
result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null
|
||||
}
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
{ owner: 'enterprise', repo: 'widgets' },
|
||||
new Map()
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'b' })
|
||||
expect(calls).toEqual(['id:a', 'id:b'])
|
||||
})
|
||||
|
||||
it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => {
|
||||
let calls = 0
|
||||
const client = {
|
||||
sendRequest: async () => {
|
||||
calls += 1
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' }
|
||||
}
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
const cache = new Map<string, { owner: string; repo: string } | null>()
|
||||
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache)
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache)
|
||||
).resolves.toBeNull()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps local matching usable when the optional repo slug lookup rejects', async () => {
|
||||
const client = {
|
||||
sendRequest: async () => {
|
||||
throw new Error('connection closed')
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
{ owner: 'enterprise', repo: 'widgets' },
|
||||
new Map()
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
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 { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
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<ReturnType<typeof parseGitLabIssueOrMRLink>>
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!isSmartWorkspaceSourceQueryWithinLimit(query)) {
|
||||
return null
|
||||
}
|
||||
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 findRepoMatchingSlugForPaste(
|
||||
client: RpcClient,
|
||||
repos: readonly PasteRepoCandidate[],
|
||||
slug: RepoSlug,
|
||||
cache: Map<string, RepoSlug | null>
|
||||
): Promise<PasteRepoCandidate | null> {
|
||||
const projected = findRepoMatchingSlug(repos, slug)
|
||||
if (projected) {
|
||||
return projected
|
||||
}
|
||||
// Why: projected remote metadata is incomplete for SSH and GitHub Enterprise;
|
||||
// ask each repo's owning runtime instead of assuming github.com URL syntax.
|
||||
for (const repo of repos) {
|
||||
let resolved = cache.get(repo.id)
|
||||
if (!cache.has(repo.id)) {
|
||||
try {
|
||||
const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` })
|
||||
if (!response.ok && response.error.code === 'method_not_found') {
|
||||
// Why: RPC availability is host-wide; avoid repeating an unsupported
|
||||
// probe for every repo or on the next paste attempt.
|
||||
repos.forEach((candidate) => cache.set(candidate.id, null))
|
||||
return null
|
||||
}
|
||||
resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null
|
||||
} catch {
|
||||
resolved = null
|
||||
}
|
||||
cache.set(repo.id, resolved ?? null)
|
||||
}
|
||||
if (slugsEqual(resolved ?? null, slug)) {
|
||||
return repo
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function lookupGitHubItemByNumber(
|
||||
client: RpcClient,
|
||||
repoId: string,
|
||||
number: number
|
||||
): Promise<GitHubWorkItem | null> {
|
||||
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<GitHubWorkItem | null> {
|
||||
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<ReturnType<typeof parseGitLabIssueOrMRLink>>
|
||||
): Promise<GitLabWorkItem | null> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<string, unknown> }
|
||||
|
||||
function fakeClient(result: unknown, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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<GitHubWorkItem[]> {
|
||||
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<GitLabWorkItem[]> {
|
||||
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<LinearIssue[]> {
|
||||
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<BaseRefSearchResult[]> {
|
||||
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 }))
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorkspaceFromComposerSource } from './source-workspace-create'
|
||||
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
|
||||
|
||||
type Call = { method: string; params: Record<string, unknown> }
|
||||
|
||||
function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
|
||||
const result = handle(method, calls.length)
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
id: '1',
|
||||
ok: false,
|
||||
error: { code: 'x', message: result.message },
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
}
|
||||
|
||||
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(() => ({ worktree: { id: 'wt-1' } }), calls)
|
||||
const selection: MobileComposerCreateSelection = {
|
||||
kind: 'work-item',
|
||||
item: {
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 7,
|
||||
title: 'Bug',
|
||||
url: 'u',
|
||||
repoId: 'repo-9'
|
||||
}
|
||||
}
|
||||
// The composer supplies the title-derived name as workspaceName; with none,
|
||||
// buildTaskWorkspaceCreateParams falls back to the "<type>-<number>" 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'
|
||||
})
|
||||
})
|
||||
|
||||
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 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' }
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a PR base as a fallback when the selection carries none', async () => {
|
||||
const calls: Call[] = []
|
||||
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('creates a Linear workspace with workspace + org routing', async () => {
|
||||
const calls: Call[] = []
|
||||
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'
|
||||
}
|
||||
}
|
||||
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
|
||||
expect(calls[0]!.params).toMatchObject({
|
||||
repo: 'id:repo-1',
|
||||
linkedLinearIssue: 'ENG-9',
|
||||
linkedLinearIssueWorkspaceId: 'ws-1',
|
||||
linkedLinearIssueOrganizationUrlKey: 'acme'
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses an existing branch with a single attempt (no suffix retry)', async () => {
|
||||
const calls: Call[] = []
|
||||
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'
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import type { RpcClient } from '../transport/rpc-client'
|
||||
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 WorkspaceCreateSetupDecision,
|
||||
type WorkspaceCreateTaskItem
|
||||
} from './workspace-create-params'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export type CreateWorkspaceFromComposerArgs = {
|
||||
client: RpcClient
|
||||
selection: MobileComposerCreateSelection
|
||||
targetRepoId: string
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromComposerSource(
|
||||
args: CreateWorkspaceFromComposerArgs
|
||||
): Promise<WorktreeCreateResult> {
|
||||
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
|
||||
selection: Extract<MobileComposerCreateSelection, { kind: 'work-item' }>
|
||||
targetRepoId: string
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const item = selection.item
|
||||
const taskItem = toTaskItem(item, targetRepoId)
|
||||
|
||||
// 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: taskItem,
|
||||
targetRepoId,
|
||||
setupDecision,
|
||||
agent: agent.choice,
|
||||
workspaceName,
|
||||
note,
|
||||
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.
|
||||
const baseName = String(params.name)
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
buildParams: (name) => ({ ...params, name })
|
||||
})
|
||||
}
|
||||
|
||||
async function createBranchWorkspace(args: {
|
||||
client: RpcClient
|
||||
selection: Extract<MobileComposerCreateSelection, { kind: 'branch' }>
|
||||
targetRepoId: string
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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<string, unknown>): Record<string, unknown> => {
|
||||
if (createdWithAgentId) {
|
||||
params.createdWithAgent = createdWithAgentId
|
||||
}
|
||||
if (comment) {
|
||||
params.comment = comment
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
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,
|
||||
maxAttempts: 1,
|
||||
buildParams: (name) =>
|
||||
applyCommon({
|
||||
repo: `id:${targetRepoId}`,
|
||||
name,
|
||||
setupDecision,
|
||||
baseBranch: selection.refName,
|
||||
branchNameOverride: selection.localBranchName,
|
||||
startupCommand: agent.startupCommand
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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: selection.branchNameOverride || selection.localBranchName
|
||||
})
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
name: candidate,
|
||||
setupDecision,
|
||||
baseBranch: selection.baseBranch,
|
||||
startupCommand: agent.startupCommand
|
||||
}
|
||||
if (selection.branchNameOverride) {
|
||||
params.branchNameOverride = candidate
|
||||
}
|
||||
return applyCommon(params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function createNewBranchWorkspace(args: {
|
||||
client: RpcClient
|
||||
selection: Extract<MobileComposerCreateSelection, { kind: 'new-branch' }>
|
||||
targetRepoId: string
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
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<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
name: candidate,
|
||||
setupDecision,
|
||||
branchNameOverride: candidate,
|
||||
startupCommand: agent.startupCommand
|
||||
}
|
||||
if (createdWithAgentId) {
|
||||
params.createdWithAgent = createdWithAgentId
|
||||
}
|
||||
if (comment) {
|
||||
params.comment = comment
|
||||
}
|
||||
return params
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
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 { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning'
|
||||
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 {
|
||||
ComposerBaseState,
|
||||
MobileComposerCreateSelection,
|
||||
MobileLinkedWorkItem,
|
||||
SmartNameSelection
|
||||
} from './mobile-composer-source-types'
|
||||
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<MobileLinkedWorkItem | null>(null)
|
||||
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
|
||||
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
|
||||
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
|
||||
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
|
||||
const [resolvingBase, setResolvingBase] = useState(false)
|
||||
// Set when the "Create branch <name>" 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<ComposerHostedBase>) => {
|
||||
setResolvingBase(true)
|
||||
void resolve
|
||||
.then((result) => {
|
||||
if (resolveTokenRef.current !== token) {
|
||||
return
|
||||
}
|
||||
setBase({
|
||||
baseBranch: result.baseBranch,
|
||||
compareBaseRef: result.compareBaseRef,
|
||||
pushTarget: result.pushTarget,
|
||||
branchNameOverride: result.branchNameOverride
|
||||
})
|
||||
setForkPushWarning(getForkPushWarning(result))
|
||||
})
|
||||
.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>": 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) => {
|
||||
const next = resolveComposerManualBranchNameChange({
|
||||
value,
|
||||
pushTarget: base.pushTarget,
|
||||
forkPushWarning
|
||||
})
|
||||
setBase({
|
||||
...base,
|
||||
branchNameOverride: next.branchNameOverride,
|
||||
pushTarget: next.pushTarget
|
||||
})
|
||||
setForkPushWarning(next.forkPushWarning)
|
||||
},
|
||||
[base, forkPushWarning]
|
||||
)
|
||||
|
||||
const smartNameSelection = useMemo<SmartNameSelection | null>(
|
||||
() => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }),
|
||||
[base.baseBranch, linkedWorkItem]
|
||||
)
|
||||
|
||||
const createSelection = useMemo<MobileComposerCreateSelection | null>(
|
||||
() =>
|
||||
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<typeof useMobileComposerSource>
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
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 {
|
||||
findRepoMatchingSlugForPaste,
|
||||
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<SmartFanOutResult>(EMPTY_FAN)
|
||||
const [paste, setPaste] = useState<PasteResolved>({ github: null, gitlab: null })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [crossRepoPrompt, setCrossRepoPrompt] = useState<SmartCrossRepoPrompt | null>(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<string>('')
|
||||
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(new Map())
|
||||
|
||||
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,
|
||||
repoSlugCache: repoSlugCacheRef.current
|
||||
})
|
||||
.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<SmartWorkspaceSourceRow[]>(
|
||||
() =>
|
||||
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 }
|
||||
repoSlugCache: Map<string, { owner: string; repo: string } | null>
|
||||
}): Promise<{
|
||||
fan: SmartFanOutResult
|
||||
paste: PasteResolved
|
||||
crossRepoPrompt: SmartCrossRepoPrompt | null
|
||||
}> {
|
||||
const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = 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 = await findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
intent.link.slug,
|
||||
repoSlugCache
|
||||
)
|
||||
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 }
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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(false)
|
||||
})
|
||||
|
||||
it('treats plain names as non-lookup text', () => {
|
||||
expect(isWorkItemLookupText('')).toBe(false)
|
||||
expect(isWorkItemLookupText('fix the login bug')).toBe(false)
|
||||
expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from '../../../src/shared/new-workspace/work-item-lookup-text'
|
||||
|
|
@ -144,7 +144,7 @@ describe('task workspace create params', () => {
|
|||
).toMatchObject({
|
||||
repo: 'id:repo-linear',
|
||||
name: 'eng-42',
|
||||
displayName: 'Ship Linear parity',
|
||||
displayName: 'ENG-42 Ship Linear parity',
|
||||
linkedLinearIssue: 'ENG-42',
|
||||
startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity',
|
||||
createdWithAgent: 'grok'
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type {
|
||||
CreateSparseCheckoutRequest,
|
||||
GitPushTarget,
|
||||
SetupDecision,
|
||||
TuiAgent
|
||||
} from '../../../src/shared/types'
|
||||
import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
|
||||
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
|
||||
|
||||
export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip'
|
||||
|
||||
export type WorkspaceCreateSparseCheckout = {
|
||||
directories: string[]
|
||||
presetId?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateGitPushTarget = {
|
||||
remoteName: string
|
||||
branchName: string
|
||||
remoteUrl?: string
|
||||
}
|
||||
export type WorkspaceCreateSetupDecision = SetupDecision
|
||||
export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest
|
||||
export type WorkspaceCreateGitPushTarget = GitPushTarget
|
||||
|
||||
export type WorkspaceCreateHostedStartPoint = {
|
||||
baseBranch: string
|
||||
|
|
@ -48,6 +45,8 @@ type WorkspaceCreateLinearItem = {
|
|||
identifier: string
|
||||
title: string
|
||||
url: string
|
||||
workspaceId?: string
|
||||
organizationUrlKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,9 +65,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 +80,41 @@ 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 sourceName =
|
||||
item.provider === 'linear'
|
||||
? getWorkspaceSourceName({
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: item.source.title,
|
||||
url: item.source.url,
|
||||
linearIdentifier: item.source.identifier
|
||||
})
|
||||
: getWorkspaceSourceName({ provider: item.provider, ...item.source })
|
||||
const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {}
|
||||
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 +124,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 +137,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 +151,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
|
||||
// Why: server-side collision checks (branch already exists locally / on a remote
|
||||
// / already has PR #N) can fire even after a pre-flight basename dedupe —
|
||||
// branches outlive worktrees in git, and remote branches/PRs aren't visible from
|
||||
// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree
|
||||
// loop in src/renderer/src/store/slices/worktrees.ts.
|
||||
export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string }
|
||||
|
||||
// Creates a worktree, retrying with a numeric suffix on a name-collision error.
|
||||
// buildParams receives the candidate name so callers can assemble source-specific
|
||||
// params (linked issue/PR, base branch, etc.) around it. Callers that can't clear
|
||||
// a collision by re-suffixing (e.g. reusing a fixed existing branch) pass
|
||||
// maxAttempts: 1 to fail fast instead of burning the full retry budget.
|
||||
export async function createWorktreeWithNameRetry(args: {
|
||||
client: RpcClient
|
||||
baseName: string
|
||||
buildParams: (name: string) => Record<string, unknown>
|
||||
maxAttempts?: number
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, baseName, buildParams } = args
|
||||
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
|
||||
let lastError: string | null = null
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const candidateName = getClientWorktreeCreateCandidate(baseName, attempt)
|
||||
const response = await client.sendRequest('worktree.create', buildParams(candidateName), {
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
return { worktreeId: result.worktree.id, name: candidateName }
|
||||
}
|
||||
lastError = response.error.message
|
||||
if (!isRetryableWorktreeCreateConflict(lastError ?? '')) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { error: lastError ?? 'Failed to create workspace' }
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluateCompat } from './protocol-compat'
|
||||
|
||||
describe('evaluateCompat', () => {
|
||||
it('allows the current mobile app to connect before a protocol-2 desktop updates', () => {
|
||||
expect(
|
||||
evaluateCompat({
|
||||
desktopProtocolVersion: 2,
|
||||
desktopMinCompatibleMobileVersion: 2
|
||||
})
|
||||
).toEqual({ kind: 'ok' })
|
||||
})
|
||||
})
|
||||
|
|
@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => {
|
|||
})
|
||||
expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' })
|
||||
})
|
||||
|
||||
it('accepts worktree.create payloads sent by the previous mobile protocol', () => {
|
||||
const create = methodParams(WORKTREE_METHODS, 'worktree.create')
|
||||
|
||||
expectParses(create, {
|
||||
repo: 'id:repo-github',
|
||||
name: 'fix-mobile-tasks',
|
||||
displayName: 'Fix mobile tasks',
|
||||
setupDecision: 'run',
|
||||
activate: true,
|
||||
startupDraft: 'https://github.com/acme/app/pull/123',
|
||||
createdWithAgent: 'codex',
|
||||
linkedPR: 123,
|
||||
baseBranch: 'origin/main',
|
||||
compareBaseRef: 'origin/main',
|
||||
branchNameOverride: 'feature/mobile-tasks',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' }
|
||||
})
|
||||
expectParses(create, {
|
||||
repo: 'id:repo-gitlab',
|
||||
name: 'mr-7',
|
||||
linkedGitLabMR: 7,
|
||||
sparseCheckout: { directories: ['mobile'], presetId: 'mobile' },
|
||||
comment: 'keep mobile parity'
|
||||
})
|
||||
expectParses(create, {
|
||||
repo: 'id:repo-linear',
|
||||
name: 'eng-42',
|
||||
linkedLinearIssue: 'ENG-42',
|
||||
linkedLinearIssueWorkspaceId: 'workspace-1',
|
||||
linkedLinearIssueOrganizationUrlKey: 'acme'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -232,11 +232,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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<LinearIssue> | null | undefined
|
||||
|
||||
const EMPTY_HINT_BY_MODE: Record<SmartNameMode, string> = {
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import {
|
||||
getLinkedWorkItemProvider,
|
||||
getLinkedWorkItemWorkspaceName,
|
||||
type LinkedWorkItemSummary
|
||||
} from '@/lib/new-workspace'
|
||||
buildGitHubWorkspaceSource,
|
||||
buildGitLabWorkspaceSource,
|
||||
buildLinearWorkspaceSource,
|
||||
buildWorkspaceSourceSelection,
|
||||
getWorkspaceSourceName,
|
||||
getWorkspaceSourceProvider
|
||||
} from '../../../../shared/new-workspace/workspace-source'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
|
|
@ -63,7 +66,7 @@ export function toFolderWorkspaceLinkedTask(
|
|||
if (!item) {
|
||||
return null
|
||||
}
|
||||
const provider = getLinkedWorkItemProvider(item)
|
||||
const provider = getWorkspaceSourceProvider(item)
|
||||
return {
|
||||
provider,
|
||||
type: item.type,
|
||||
|
|
@ -79,60 +82,23 @@ export function toFolderWorkspaceLinkedTask(
|
|||
export function getSmartNameSelection(
|
||||
linkedWorkItem: LinkedWorkItemSummary | null
|
||||
): SmartWorkspaceNameSelection | null {
|
||||
if (!linkedWorkItem) {
|
||||
return null
|
||||
}
|
||||
const provider = getLinkedWorkItemProvider(linkedWorkItem)
|
||||
const kind: SmartWorkspaceNameSelection['kind'] =
|
||||
provider === 'linear'
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
return buildWorkspaceSourceSelection({ linkedWorkItem }) as SmartWorkspaceNameSelection | null
|
||||
}
|
||||
|
||||
export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null {
|
||||
return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null)
|
||||
return getWorkspaceSourceName(item).displayName || null
|
||||
}
|
||||
|
||||
export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary {
|
||||
return {
|
||||
type: item.type,
|
||||
provider: 'github',
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
return buildGitHubWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary {
|
||||
return {
|
||||
type: item.type,
|
||||
provider: 'gitlab',
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
return buildGitLabWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary {
|
||||
return buildLinearIssueLinkedWorkItem(issue)
|
||||
return buildLinearWorkspaceSource(issue)
|
||||
}
|
||||
|
||||
export function getFolderWorkspacePrimaryActionLabel(): string {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getComposerRepoWorktreeBranches,
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
|
|
@ -209,6 +210,20 @@ describe('isBranchCheckedOutInWorktrees', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('getComposerRepoWorktreeBranches', () => {
|
||||
it('supplies only the selected repo branches to reuse eligibility', () => {
|
||||
expect(
|
||||
getComposerRepoWorktreeBranches(
|
||||
[
|
||||
{ repoId: 'repo-a', branch: 'feature/a' },
|
||||
{ repoId: 'repo-b', branch: 'feature/b' }
|
||||
],
|
||||
'repo-a'
|
||||
)
|
||||
).toEqual(['feature/a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveComposerReuseOverride', () => {
|
||||
it('keeps the selection override for a reusable (non-busy) local branch', () => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
export {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
getComposerRepoWorktreeBranches,
|
||||
resolveComposerBranchPick,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerManualBranchNameChange,
|
||||
resolveComposerReuseOverride,
|
||||
type ComposerBranchPick,
|
||||
type ComposerBranchSelection
|
||||
} from '../../../shared/composer-branch-selection'
|
||||
|
|
|
|||
|
|
@ -1,21 +1 @@
|
|||
import type { GitHubPrStartPoint } from '../../../shared/types'
|
||||
|
||||
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
|
||||
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
|
||||
|
||||
// Why: only warn for fork PRs where the push target points away from origin and
|
||||
// whose author left "Allow edits from maintainers" off. That's the one case
|
||||
// where our push to the contributor's fork can be rejected by GitHub. Returns
|
||||
// the warning text to show, or null when no warning applies.
|
||||
export function getForkPushWarning(
|
||||
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
|
||||
): string | null {
|
||||
if (
|
||||
result.maintainerCanModify === false &&
|
||||
result.pushTarget !== undefined &&
|
||||
result.pushTarget.remoteName !== 'origin'
|
||||
) {
|
||||
return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
|
||||
}
|
||||
return null
|
||||
}
|
||||
export * from '../../../shared/new-workspace/fork-push-warning'
|
||||
|
|
|
|||
|
|
@ -147,6 +147,10 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug
|
|||
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
|
||||
import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results'
|
||||
import { getForkPushWarning } from './fork-push-warning'
|
||||
import {
|
||||
buildWorkspaceSourceSelection,
|
||||
shouldApplyWorkspaceSourceAutoName
|
||||
} from '../../../shared/new-workspace/workspace-source'
|
||||
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
|
||||
|
|
@ -164,12 +168,10 @@ import {
|
|||
} from '@/lib/workspace-create-error-format'
|
||||
import type { SshConnectionStatus } from '../../../shared/ssh-types'
|
||||
import {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerBranchPick,
|
||||
resolveComposerManualBranchNameChange,
|
||||
resolveComposerReuseOverride
|
||||
getComposerRepoWorktreeBranches
|
||||
} from './composer-branch-selection'
|
||||
import { isCurrentComposerDropOwner } from './composer-drop-owner'
|
||||
import {
|
||||
|
|
@ -2072,7 +2074,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
// name or it silently becomes a slugified-URL workspace name.
|
||||
if (
|
||||
suggestedName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(suggestedName)
|
||||
lastAutoNameRef.current = suggestedName
|
||||
|
|
@ -2307,7 +2312,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const nextName = titleName?.seedName ?? suggestedName
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
|
|
@ -2909,7 +2917,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const nextName = getLinkedItemDisplayName(linkedItem)
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
|
|
@ -3013,7 +3024,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const nextName = getLinkedItemDisplayName(linkedItem)
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
|
|
@ -3110,11 +3124,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const handleSmartBranchSelect = useCallback(
|
||||
(refName: string, localBranchName: string): void => {
|
||||
smartGitHubPrStartPointSelectionRef.current = null
|
||||
const selection = resolveComposerBranchSelection({
|
||||
const selection = resolveComposerBranchPick({
|
||||
refName,
|
||||
localBranchName,
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId)
|
||||
})
|
||||
setBaseBranch(selection.baseBranch)
|
||||
setCompareBaseRef(undefined)
|
||||
|
|
@ -3130,34 +3145,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
// Note: worktreesByRepo only covers visible worktrees; a branch busy only
|
||||
// in a hidden external worktree falls through to the backend conflict
|
||||
// check, which rejects it with a clear "already exists locally" error.
|
||||
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
|
||||
localBranchName,
|
||||
(worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch)
|
||||
)
|
||||
const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } =
|
||||
resolveComposerBranchReuse({
|
||||
refName,
|
||||
localBranchName,
|
||||
selectionProducedOverride: selection.branchNameOverride !== undefined,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection
|
||||
setReuseEligibleBranch(nextReuseEligibleBranch)
|
||||
setReuseSelectedBranch(defaultReuse)
|
||||
setBranchNameOverridePreservesNameEdits(defaultReuse)
|
||||
const effectiveOverride = resolveComposerReuseOverride({
|
||||
refName,
|
||||
localBranchName,
|
||||
branchNameOverride: selection.branchNameOverride,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
if (selection.name !== undefined && selection.lastAutoName !== undefined) {
|
||||
setName(selection.name)
|
||||
lastAutoNameRef.current = selection.lastAutoName
|
||||
branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : ''
|
||||
setBranchNameOverride(effectiveOverride)
|
||||
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
|
||||
setBranchNameOverride(selection.branchNameOverride)
|
||||
} else {
|
||||
setBranchNameOverride(effectiveOverride)
|
||||
branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : ''
|
||||
setBranchNameOverride(selection.branchNameOverride)
|
||||
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
|
||||
}
|
||||
},
|
||||
[name, worktreesByRepo, repoId]
|
||||
|
|
@ -3194,9 +3193,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const suggestedName =
|
||||
getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue)
|
||||
if (
|
||||
!name.trim() ||
|
||||
name === lastAutoNameRef.current ||
|
||||
isWorkItemLookupText(name) ||
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
}) ||
|
||||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
) {
|
||||
setName(suggestedName)
|
||||
|
|
@ -3213,9 +3213,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
// Why: same lookup-text rule as applyLinkedWorkItem, plus the typed
|
||||
// Linear identifier ("STA-123") that matched this issue.
|
||||
if (
|
||||
!name.trim() ||
|
||||
name === lastAutoNameRef.current ||
|
||||
isWorkItemLookupText(name) ||
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
}) ||
|
||||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
) {
|
||||
setName(suggestedName)
|
||||
|
|
@ -3262,33 +3263,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
if (isProjectGroupTarget) {
|
||||
return getFolderSmartNameSelection(linkedWorkItem)
|
||||
}
|
||||
if (linkedWorkItem) {
|
||||
const provider = getLinkedWorkItemProvider(linkedWorkItem)
|
||||
const isLinear = provider === 'linear'
|
||||
const kind: SmartWorkspaceNameSelection['kind'] = isLinear
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
isLinear || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
}
|
||||
if (baseBranch) {
|
||||
return { kind: 'branch', label: baseBranch }
|
||||
}
|
||||
return null
|
||||
return buildWorkspaceSourceSelection({
|
||||
linkedWorkItem,
|
||||
baseBranch
|
||||
}) as SmartWorkspaceNameSelection | null
|
||||
}, [baseBranch, isProjectGroupTarget, linkedWorkItem])
|
||||
|
||||
const handleOpenAgentSettings = useCallback((): void => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 `/<path>/-/(issues|
|
||||
// work_items|merge_requests)/<digits>` is treated as a GitLab item URL
|
||||
// regardless of host. Modern GitLab emits issue URLs as
|
||||
// `/-/work_items/<iid>`; treat that as an issue work item, same as the
|
||||
// legacy `/-/issues/<iid>` 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LinearIssue } from '../../../shared/types'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear-links'
|
||||
import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source'
|
||||
|
||||
export function isLinearLinkedWorkItem(
|
||||
item: Pick<LinkedWorkItemSummary, 'provider' | 'linearIdentifier'> | null | undefined
|
||||
|
|
@ -9,21 +9,5 @@ export function isLinearLinkedWorkItem(
|
|||
}
|
||||
|
||||
export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary {
|
||||
const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
|
||||
return {
|
||||
type: 'issue',
|
||||
provider: 'linear',
|
||||
// Why: Linear issue prose must not enter prompt metadata; keep only the
|
||||
// string identifier/link and leave numeric issue metadata empty.
|
||||
number: 0,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
linearIdentifier: issue.identifier,
|
||||
...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}),
|
||||
...(organizationUrlKey
|
||||
? {
|
||||
linearOrganizationUrlKey: organizationUrlKey
|
||||
}
|
||||
: {})
|
||||
}
|
||||
return buildLinearWorkspaceSource(issue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,4 @@
|
|||
import type { LinkedWorkItemSummary } from './new-workspace'
|
||||
|
||||
// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern
|
||||
// GitLab emits issue URLs as `/-/work_items/<iid>` as well as the legacy
|
||||
// `/-/issues/<iid>`. Recognize both forms.
|
||||
const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i
|
||||
|
||||
export function isGitLabIssueUrl(url: string): boolean {
|
||||
try {
|
||||
return GL_ISSUE_PATH_RE.test(new URL(url).pathname)
|
||||
} catch {
|
||||
return GL_ISSUE_PATH_RE.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
function isJiraIssueUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
/\.atlassian\.net$/i.test(parsed.hostname) ||
|
||||
/\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinkedWorkItemProvider(
|
||||
item: LinkedWorkItemSummary
|
||||
): NonNullable<LinkedWorkItemSummary['provider']> {
|
||||
if (item.provider) {
|
||||
return item.provider
|
||||
}
|
||||
if (item.linearIdentifier) {
|
||||
return 'linear'
|
||||
}
|
||||
if (item.jiraIdentifier || isJiraIssueUrl(item.url)) {
|
||||
return 'jira'
|
||||
}
|
||||
if (item.type === 'mr') {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (isGitLabIssueUrl(item.url)) {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (item.number === 0 && !item.url.includes('github.com')) {
|
||||
return 'linear'
|
||||
}
|
||||
return 'github'
|
||||
}
|
||||
export {
|
||||
getWorkspaceSourceProvider as getLinkedWorkItemProvider,
|
||||
isGitLabIssueUrl
|
||||
} from '../../../shared/new-workspace/workspace-source'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@ describe('isWorkItemLookupText', () => {
|
|||
expect(isWorkItemLookupText('fix-2')).toBe(false)
|
||||
expect(isWorkItemLookupText('terminal scrollbar polish')).toBe(false)
|
||||
expect(isWorkItemLookupText('https://example.com/some/page')).toBe(false)
|
||||
expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,24 +1 @@
|
|||
import { getSmartGitHubSubmitIntent } from './smart-github-submit'
|
||||
import { parseGitLabIssueOrMRLink } from './gitlab-links'
|
||||
|
||||
const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i
|
||||
|
||||
/**
|
||||
* Why: text typed into the smart name field to *find* a work item — a GitHub
|
||||
* or GitLab URL, "#123", or a linear.app link — is a lookup query, never a
|
||||
* deliberate workspace name. Selection handlers use this to decide that the
|
||||
* resolved item's title-derived name may replace the field content; otherwise
|
||||
* the pasted URL silently survives behind the selection pill and the
|
||||
* workspace gets a slugified-URL name.
|
||||
*/
|
||||
export function isWorkItemLookupText(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
getSmartGitHubSubmitIntent(trimmed) !== null ||
|
||||
parseGitLabIssueOrMRLink(trimmed) !== null ||
|
||||
LINEAR_ISSUE_URL_RE.test(trimmed)
|
||||
)
|
||||
}
|
||||
export * from '../../../shared/new-workspace/work-item-lookup-text'
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ import {
|
|||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../../shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
classifyWorktreeForceDeleteReason,
|
||||
getLockedWorktreeRemovalReason,
|
||||
|
|
@ -2954,22 +2959,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
options
|
||||
) => {
|
||||
const automationProvenanceRequest = options?.automationProvenanceRequest
|
||||
const retryableConflictPatterns = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
/^Branch ".+" already exists\./i,
|
||||
/already has pr #\d+/i
|
||||
]
|
||||
const nextCandidateName = (current: string, attempt: number): string =>
|
||||
attempt === 0 ? current : `${current}-${attempt + 1}`
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const candidateName = nextCandidateName(name, attempt)
|
||||
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const candidateName = getClientWorktreeCreateCandidate(name, attempt)
|
||||
// Why: older runtimes may still reject exact PR branch overrides on
|
||||
// collision, so the renderer retries both branch and worktree names.
|
||||
const candidateBranchNameOverride = branchNameOverride
|
||||
? nextCandidateName(branchNameOverride, attempt)
|
||||
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
|
||||
: undefined
|
||||
try {
|
||||
// Why: Manual sort is user-authored order. Stamp new workspaces
|
||||
|
|
@ -3130,8 +3126,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message))
|
||||
if (!shouldRetry || attempt === 24) {
|
||||
const shouldRetry = isRetryableWorktreeCreateConflict(message)
|
||||
if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,15 @@ export function isBranchCheckedOutInWorktrees(
|
|||
return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName)
|
||||
}
|
||||
|
||||
export function getComposerRepoWorktreeBranches(
|
||||
worktrees: readonly { repoId: string; branch: string }[],
|
||||
repoId: string | null
|
||||
): string[] {
|
||||
return repoId
|
||||
? worktrees.filter((worktree) => worktree.repoId === repoId).map((worktree) => worktree.branch)
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #5181: decide whether a picked branch row is an existing LOCAL branch
|
||||
* that can be reused (checked out) instead of branched off, and whether reuse
|
||||
|
|
@ -99,6 +108,41 @@ export function resolveComposerReuseOverride(args: {
|
|||
return args.branchNameOverride
|
||||
}
|
||||
|
||||
export type ComposerBranchPick = ComposerBranchSelection & {
|
||||
reuseEligibleBranch: string | null
|
||||
defaultReuse: boolean
|
||||
}
|
||||
|
||||
export function resolveComposerBranchPick(args: {
|
||||
refName: string
|
||||
localBranchName: string
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
worktreeBranches: readonly string[]
|
||||
}): ComposerBranchPick {
|
||||
const selection = resolveComposerBranchSelection(args)
|
||||
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
|
||||
args.localBranchName,
|
||||
args.worktreeBranches
|
||||
)
|
||||
const reuse = resolveComposerBranchReuse({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
selectionProducedOverride: selection.branchNameOverride !== undefined,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
return {
|
||||
...selection,
|
||||
branchNameOverride: resolveComposerReuseOverride({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
branchNameOverride: selection.branchNameOverride,
|
||||
branchCheckedOutElsewhere
|
||||
}),
|
||||
...reuse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch-name override to apply when creating a worktree from the composer.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import type { GitHubPrStartPoint } from '../types'
|
||||
|
||||
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
|
||||
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
|
||||
|
||||
// Why: this is the one fork target where Orca can prepare the workspace but a
|
||||
// later push may still be rejected by GitHub permissions.
|
||||
export function getForkPushWarning(
|
||||
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
|
||||
): string | null {
|
||||
return result.maintainerCanModify === false &&
|
||||
result.pushTarget !== undefined &&
|
||||
result.pushTarget.remoteName !== 'origin'
|
||||
? FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
|
||||
: null
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
type GitHubIssueOrPRLink,
|
||||
parseGitHubIssueOrPRLink,
|
||||
parseGitHubIssueOrPRNumber
|
||||
} from '../github-links'
|
||||
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
|
||||
|
||||
export * from '../github-links'
|
||||
|
||||
const HTTP_URL_PREFIX_RE = /^https?:\/\//i
|
||||
|
||||
export type GitHubLinkQuery = {
|
||||
query: string
|
||||
directNumber: number | null
|
||||
directLink?: GitHubIssueOrPRLink
|
||||
tooLarge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 `/<path>/-/(issues|
|
||||
// work_items|merge_requests)/<digits>` is treated as a GitLab item URL
|
||||
// regardless of host. Modern GitLab emits issue URLs as
|
||||
// `/-/work_items/<iid>`; treat that as an issue work item, same as the
|
||||
// legacy `/-/issues/<iid>` 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<LinearIssue> | null | undefined
|
||||
|
||||
const EMPTY_HINT_BY_MODE: Record<SmartNameMode, string> = {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from './github-links'
|
||||
import { parseGitLabIssueOrMRLink } from './gitlab-links'
|
||||
|
||||
const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/[^/\s]+\/issue\/[^/\s]+(?:\/\S*)?$/i
|
||||
const GITHUB_ITEM_URL_IN_TEXT_RE =
|
||||
/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/(?:issues|pull)\/\d+[^\s]*/i
|
||||
const TRAILING_URL_PUNCTUATION_RE = /[),.;:!?]+$/
|
||||
|
||||
function hasGitHubLookup(value: string): boolean {
|
||||
if (parseGitHubIssueOrPRNumber(value) !== null || parseGitHubIssueOrPRLink(value) !== null) {
|
||||
return true
|
||||
}
|
||||
const embedded = GITHUB_ITEM_URL_IN_TEXT_RE.exec(value)?.[0]
|
||||
return embedded
|
||||
? parseGitHubIssueOrPRLink(embedded.replace(TRAILING_URL_PUNCTUATION_RE, '')) !== null
|
||||
: false
|
||||
}
|
||||
|
||||
/** Lookup references may be replaced by an auto-name; deliberate names may not. */
|
||||
export function isWorkItemLookupText(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
hasGitHubLookup(trimmed) ||
|
||||
parseGitLabIssueOrMRLink(trimmed) !== null ||
|
||||
LINEAR_ISSUE_URL_RE.test(trimmed)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildLinearWorkspaceSource,
|
||||
buildWorkspaceSourceSelection,
|
||||
getWorkspaceSourceName,
|
||||
getWorkspaceSourceProvider,
|
||||
shouldApplyWorkspaceSourceAutoName,
|
||||
shouldPreserveWorkspaceSourceOnRepoChange
|
||||
} from './workspace-source'
|
||||
|
||||
describe('workspace source policy', () => {
|
||||
const linear = buildLinearWorkspaceSource({
|
||||
identifier: 'ENG-42',
|
||||
title: 'Ship mobile parity',
|
||||
url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
it('builds one Linear identity for desktop and mobile create flows', () => {
|
||||
expect(linear).toMatchObject({
|
||||
provider: 'linear',
|
||||
number: 0,
|
||||
linearIdentifier: 'ENG-42',
|
||||
linearWorkspaceId: 'workspace-1',
|
||||
linearOrganizationUrlKey: 'acme'
|
||||
})
|
||||
expect(getWorkspaceSourceName(linear)).toEqual({
|
||||
seedName: 'eng-42-ship-mobile-parity',
|
||||
displayName: 'ENG-42 Ship mobile parity'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves global work-item sources across repo changes', () => {
|
||||
expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true)
|
||||
expect(
|
||||
shouldPreserveWorkspaceSourceOnRepoChange({
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 'Repo scoped',
|
||||
url: 'https://github.com/o/r/issues/1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shares provider inference, selection labels, and auto-name gates', () => {
|
||||
const legacyGitLab = {
|
||||
type: 'issue' as const,
|
||||
number: 7,
|
||||
title: 'Self hosted',
|
||||
url: 'https://gitlab.example.com/g/p/-/work_items/7'
|
||||
}
|
||||
expect(getWorkspaceSourceProvider(legacyGitLab)).toBe('gitlab')
|
||||
expect(buildWorkspaceSourceSelection({ linkedWorkItem: legacyGitLab })).toMatchObject({
|
||||
kind: 'gitlab-issue',
|
||||
label: '#7 Self hosted'
|
||||
})
|
||||
expect(shouldApplyWorkspaceSourceAutoName({ currentName: '#42', lastAutoName: 'old' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
shouldApplyWorkspaceSourceAutoName({ currentName: 'my workspace', lastAutoName: 'old' })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear-links'
|
||||
import type { FolderWorkspaceLinkedTask, LinearIssue } from '../types'
|
||||
import {
|
||||
getLinkedWorkItemSuggestedName,
|
||||
getLinkedWorkItemWorkspaceName,
|
||||
type WorkspaceIntentWorkItem
|
||||
} from '../workspace-name'
|
||||
import { isWorkItemLookupText } from './work-item-lookup-text'
|
||||
|
||||
export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider']
|
||||
|
||||
export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & {
|
||||
linearWorkspaceId?: string
|
||||
linearOrganizationUrlKey?: string
|
||||
}
|
||||
|
||||
export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & {
|
||||
provider: 'github'
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
export type GitLabWorkspaceSource = WorkspaceSourceLinkedItem & {
|
||||
provider: 'gitlab'
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
|
||||
export type LinearWorkspaceSource = WorkspaceSourceLinkedItem & {
|
||||
provider: 'linear'
|
||||
type: 'issue'
|
||||
}
|
||||
|
||||
export type WorkspaceSourceItemLike = Omit<WorkspaceSourceLinkedItem, 'provider'> & {
|
||||
provider?: WorkspaceSourceProvider
|
||||
}
|
||||
|
||||
export type WorkspaceSourceSelectionKind =
|
||||
| 'github-pr'
|
||||
| 'github-issue'
|
||||
| 'gitlab-mr'
|
||||
| 'gitlab-issue'
|
||||
| 'branch'
|
||||
| 'linear'
|
||||
| 'jira'
|
||||
|
||||
export type WorkspaceSourceSelection = {
|
||||
kind: WorkspaceSourceSelectionKind
|
||||
label: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const GITLAB_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i
|
||||
|
||||
export function isGitLabIssueUrl(url: string): boolean {
|
||||
try {
|
||||
return GITLAB_ISSUE_PATH_RE.test(new URL(url).pathname)
|
||||
} catch {
|
||||
return GITLAB_ISSUE_PATH_RE.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
function isJiraIssueUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
/\.atlassian\.net$/i.test(parsed.hostname) ||
|
||||
/\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceSourceProvider(item: WorkspaceSourceItemLike): WorkspaceSourceProvider {
|
||||
if (item.provider) {
|
||||
return item.provider
|
||||
}
|
||||
if (item.linearIdentifier) {
|
||||
return 'linear'
|
||||
}
|
||||
if (item.jiraIdentifier || isJiraIssueUrl(item.url)) {
|
||||
return 'jira'
|
||||
}
|
||||
if (item.type === 'mr' || isGitLabIssueUrl(item.url)) {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (item.number === 0 && !item.url.includes('github.com')) {
|
||||
return 'linear'
|
||||
}
|
||||
return 'github'
|
||||
}
|
||||
|
||||
export function buildGitHubWorkspaceSource(item: {
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repoId?: string
|
||||
}): GitHubWorkspaceSource {
|
||||
return { provider: 'github', ...item }
|
||||
}
|
||||
|
||||
export function buildGitLabWorkspaceSource(item: {
|
||||
type: 'issue' | 'mr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repoId?: string
|
||||
}): GitLabWorkspaceSource {
|
||||
return { provider: 'gitlab', ...item }
|
||||
}
|
||||
|
||||
export function buildLinearWorkspaceSource(
|
||||
issue: Pick<LinearIssue, 'identifier' | 'title' | 'url' | 'workspaceId'>
|
||||
): LinearWorkspaceSource {
|
||||
const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
|
||||
return {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
// Why: Linear uses a string identifier; numeric issue metadata must stay empty.
|
||||
number: 0,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
linearIdentifier: issue.identifier,
|
||||
...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}),
|
||||
...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldApplyWorkspaceSourceAutoName(args: {
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
}): boolean {
|
||||
return (
|
||||
!args.currentName.trim() ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
isWorkItemLookupText(args.currentName)
|
||||
)
|
||||
}
|
||||
|
||||
function toWorkspaceIntentItem(item: WorkspaceSourceItemLike): WorkspaceIntentWorkItem {
|
||||
return { ...item, provider: getWorkspaceSourceProvider(item) }
|
||||
}
|
||||
|
||||
export function getWorkspaceSourceName(item: WorkspaceSourceItemLike): {
|
||||
seedName: string
|
||||
displayName: string
|
||||
} {
|
||||
const normalized = toWorkspaceIntentItem(item)
|
||||
const resolved = getLinkedWorkItemWorkspaceName(normalized)
|
||||
return {
|
||||
seedName: resolved?.seedName ?? getLinkedWorkItemSuggestedName(normalized),
|
||||
displayName: resolved?.displayName ?? item.title.trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkspaceSourceSelection(args: {
|
||||
linkedWorkItem: WorkspaceSourceItemLike | null
|
||||
baseBranch?: string
|
||||
}): WorkspaceSourceSelection | null {
|
||||
const { linkedWorkItem, baseBranch } = args
|
||||
if (!linkedWorkItem) {
|
||||
return baseBranch ? { kind: 'branch', label: baseBranch } : null
|
||||
}
|
||||
const provider = getWorkspaceSourceProvider(linkedWorkItem)
|
||||
const kind: WorkspaceSourceSelectionKind =
|
||||
provider === 'linear'
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldPreserveWorkspaceSourceOnRepoChange(
|
||||
item: WorkspaceSourceItemLike | null
|
||||
): boolean {
|
||||
if (!item) {
|
||||
return false
|
||||
}
|
||||
const provider = getWorkspaceSourceProvider(item)
|
||||
return provider === 'linear' || provider === 'jira'
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getClientWorktreeCreateCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from './worktree-create-retry-policy'
|
||||
|
||||
describe('client worktree create retry policy', () => {
|
||||
it('uses the same suffix sequence for every client', () => {
|
||||
expect(getClientWorktreeCreateCandidate('feature', 0)).toBe('feature')
|
||||
expect(getClientWorktreeCreateCandidate('feature', 1)).toBe('feature-2')
|
||||
})
|
||||
|
||||
it('retries only known branch and review conflicts', () => {
|
||||
expect(isRetryableWorktreeCreateConflict('Branch already exists locally')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Branch "x" already exists.')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Branch already has PR #42')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Permission denied')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export const CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS = 25
|
||||
|
||||
// Why: mixed-version runtimes can still return these legacy conflicts instead
|
||||
// of performing their own suffix retry, so every client needs one policy.
|
||||
const RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
/^Branch ".+" already exists\./i,
|
||||
/already has pr #\d+/i
|
||||
]
|
||||
|
||||
export function getClientWorktreeCreateCandidate(value: string, attempt: number): string {
|
||||
return attempt === 0 ? value : `${value}-${attempt + 1}`
|
||||
}
|
||||
|
||||
export function isRetryableWorktreeCreateConflict(message: string): boolean {
|
||||
return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
|
@ -45,6 +45,28 @@ describe('evaluateCompat', () => {
|
|||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('allows desktop protocol 3 to roll out before mobile protocol 2 updates', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: 2,
|
||||
minCompatibleDesktopVersion: 2,
|
||||
desktopProtocolVersion: 3,
|
||||
desktopMinCompatibleMobileVersion: 2
|
||||
})
|
||||
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('allows mobile protocol 3 to roll out before desktop protocol 2 updates', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: 3,
|
||||
minCompatibleDesktopVersion: 2,
|
||||
desktopProtocolVersion: 2,
|
||||
desktopMinCompatibleMobileVersion: 2
|
||||
})
|
||||
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('blocks with mobile-too-old when desktop requires a newer mobile', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
|
|
|
|||
Loading…
Reference in New Issue