mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
feat(mobile): add workspace source selection
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Mobile Workspace Source Selector
|
||||
|
||||
## Problem and goal
|
||||
|
||||
Desktop workspace creation already exposes source selection through `SmartWorkspaceNameField` (`src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx:90`). Mobile `NewWorktreeModal` renders only Repository, Workspace Name, and Agent (`mobile/src/components/NewWorktreeModal.tsx:699`) and its create payload sends none of the source fields already accepted by `worktree.create` (`mobile/src/components/NewWorktreeModal.tsx:608`; schema at `src/main/runtime/rpc/methods/worktree-schemas.ts:62`).
|
||||
|
||||
Add an optional native **Start from** field with the same naming, branch-reuse, PR start-point, and linkage semantics as desktop. Reuse the existing runtime RPCs and `src/shared` functions; do not copy renderer logic or extend the current mobile-only naming/branch mirrors.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- GitLab or another provider, cross-repository URL switching, or source creation.
|
||||
- Provider credential setup inside the create sheet.
|
||||
- Desktop composer changes or a new source-search RPC/cache.
|
||||
- Immediate consistency with provider mutations hidden by the runtime's existing caches.
|
||||
|
||||
## Verified contracts and constraints
|
||||
|
||||
- `github.listWorkItems` returns `{ items, sources, errors? }`. Empty-query reads use the existing GitHub cache (up to 120 seconds). Issue-side partial errors may accompany valid PR rows. An empty-query PR-side failure rejects the RPC, but a queried PR-side failure is currently logged and returned as a missing PR subset without error metadata.
|
||||
- `linear.status` returns the current connection/workspace metadata. `linear.searchIssues` returns an array. `linear.listIssues` returns `{ items, errors?, hasMore? }` on current hosts and may be an array on older hosts. Linear search and git-ref search can convert host/provider failures to an empty result, so the client cannot always distinguish “no matches” from a backend failure.
|
||||
- `repo.searchRefs` returns `refDetails: { refName, localBranchName }[]` plus legacy `refs`. Exact local-branch reuse requires `refDetails`; do not infer reuse from legacy string rows.
|
||||
- `worktree.resolvePrBase` returns `baseBranch`, `compareBaseRef`, `branchNameOverride`, optional `pushTarget`, and optional `maintainerCanModify`, or `{ error }`. It is not a free lookup: it fetches git refs and may query GitHub. A client timeout does not cancel that host work.
|
||||
- All six source/read RPC methods above are already registered and mobile-allowlisted. Literal `sendRequest` calls are discovered by `mobile-rpc-allowlist.test.ts`; no production allowlist edit is expected.
|
||||
- `resolveComposerBranchSelection` is only the first branch step. Desktop also uses `isBranchCheckedOutInWorktrees`, `resolveComposerBranchReuse`, `resolveComposerReuseOverride`, and `resolveComposerBranchNameOverrideForCreate`.
|
||||
- Mobile Metro already watches `src/shared`, so `workspace-name.ts`, `composer-branch-selection.ts`, `linear-links.ts`, and their pure dependencies are directly reusable.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a static `mobile.workspace-source-selector.v1` runtime capability and pass the host capability list into `NewWorktreeModal`. Hide the optional field until capability state is loaded; omit it on older hosts. This fails closed because older `worktree.create` schemas may silently strip unknown optional linkage fields.
|
||||
2. Keep `NewWorktreeModal` as wiring. Put RPC normalization, source-to-create mapping, name ownership, and request lifecycle state in focused named modules; add a dedicated `NewWorkspaceSourceDrawer` built from `BottomDrawer`, `MobileSearchField`, and `FlatList`. Pass `contentScrollable={false}` so `FlatList` owns scrolling instead of nesting inside the drawer's `ScrollView`. The already-grandfathered modal must not grow materially.
|
||||
3. Extract the desktop smart-source 2 KiB UTF-8 query guard into `src/shared` and consume it from desktop and mobile. It is a UI safety bound, not an RPC/API limit. Debounce non-empty search by 200 ms, pass a limit of 12 to each backend, and cap the rendered mixed list to 12 rows.
|
||||
4. Show All, GitHub, Branches, and Linear filters. GitHub and Branches are unavailable for folder repositories or a disconnected SSH target; Linear remains valid because its linkage is not repo-scoped. Read `linear.status` when the modal opens so a Linear-connected folder/disconnected-SSH repo can expose the field, refresh it each time the drawer opens, and omit Linear when disconnected. While the modal is open, consume the existing `runtime.clientEvents.subscribe` `sshStateChanged` event for the selected target (with `ssh.getState` as initial state), invalidate repo-backed reads on every transition, and expose GitHub/Branches again only after `connected`; do not add polling. Hide the field entirely when the selected repo has no available source kind.
|
||||
5. Normalize only known envelopes. Drop malformed rows, preserve GitHub/Linear partial-error metadata, and treat a missing/malformed top-level collection as an incompatible-host error rather than a valid empty list. Legacy `repo.searchRefs.refs` may be shown only as “branch from” rows: mark them unverified and force their create-time `branchNameOverride` to `undefined`, so a local-looking legacy string cannot accidentally reuse an existing branch.
|
||||
6. Track name ownership explicitly as `blank | source | user`. A selection replaces only `blank` or `source`; every keyboard edit becomes `user`, even when its text equals the suggestion. Clearing a source clears only a `source` name. A repo change clears GitHub/branch selection and its source-owned name, but preserves a Linear selection and all user-owned names.
|
||||
7. Resolve a PR before committing the row selection. Keep the drawer open, guard the row with an immediate in-flight ref, and show a delayed spinner. On success store the full resolver result; on failure keep the prior selection unchanged. Extract and reuse the desktop fork-warning predicate: warn only when `maintainerCanModify === false` and `pushTarget` exists with a non-`origin` remote.
|
||||
8. For a verified eligible local branch, show the desktop-compatible **Reuse existing branch** toggle and use `resolveComposerBranchReuse` for its default. Treat `selectionProducedOverride` as true only when the branch selection actually took ownership of a `blank` or `source` name; a `user` name must stay user-owned even when it prefixes the selected ref. Hide reuse for remote, unverified legacy, or known-busy branches. Preserve an enabled exact override across workspace-name edits; disabling it creates a fresh branch from the selected ref.
|
||||
9. Feed the existing mobile create path through one pure candidate builder. Preserve setup, trust, SSH, agent, note, timeout, and collision behavior. On retries, suffix the workspace name and a fresh-branch/PR override together; keep a verified reusable local branch exact. Hand `worktree.displayName` from the successful response to `onCreated`/navigation, falling back to the candidate name only if it is absent, so an auto-managed display name is visible immediately. The runtime remains authoritative if another window or external git process wins the race.
|
||||
|
||||
## Data flow
|
||||
|
||||
- `status.get` capabilities plus repo kind, SSH state, and modal-open `linear.status` determine whether the field and filters exist.
|
||||
- Drawer input passes the shared UTF-8 bound and debounce, then issues the eligible RPCs concurrently; normalized, generation-scoped results become rows.
|
||||
- A row passes through source mapping, explicit name ownership, PR resolution or branch-reuse resolution, then the pure candidate builder feeds the existing `worktree.create` loop. Only its success response confirms the final branch/workspace.
|
||||
|
||||
### Source mapping
|
||||
|
||||
| Source | Name semantics | `worktree.create` additions |
|
||||
| --- | --- | --- |
|
||||
| GitHub issue | `getLinkedWorkItemWorkspaceName`, falling back to `issue-<number>` | `linkedIssue`; auto-managed `displayName` |
|
||||
| GitHub PR | Same naming with `pr-<number>` fallback; full `resolvePrBase` result | `linkedPR`, `baseBranch`, `compareBaseRef`, `branchNameOverride`, `pushTarget`; auto-managed `displayName` |
|
||||
| Linear issue | `getLinearIssueWorkspaceName` seed; `getLinkedWorkItemWorkspaceName` display from the Linear identity | `linkedLinearIssue`, `linkedLinearIssueWorkspaceId`, `linkedLinearIssueOrganizationUrlKey`; auto-managed `displayName` |
|
||||
| Branch/ref | Full shared composer branch-selection/reuse pipeline | `baseBranch` and the create-time `branchNameOverride` when applicable |
|
||||
|
||||
Use the GitHub identity fallback for both the seed and auto-managed display name when the title has no sluggable characters, matching `getSmartGitHubSubmitResolution`; do not fall through to the unrelated blank-name creature. Derive the Linear organization key with `getLinearOrganizationUrlKeyFromIssueUrl`. Omit `displayName` after a user-owned name edit, matching desktop. Pass the selected repo ID to every repo-scoped RPC; do not trust or reuse a row's stale repo object.
|
||||
|
||||
## Request lifecycle and consistency
|
||||
|
||||
- Scope every response to `{ client epoch, modal open epoch, repo id, filter, query sequence, selection sequence }`. Closing/reopening, reconnecting, changing repo/filter/query/SSH state, clearing/changing a selection, or replacing the RPC client invalidates all older responses and PR resolutions.
|
||||
- Generation guards suppress state commits; they do not cancel GitHub, Linear, git, or SSH work. Do not describe them as cancellation.
|
||||
- Use an immediate ref guard for row selection and Create so two taps in one React turn cannot issue duplicate requests.
|
||||
- Re-read repositories when the modal opens. If a repo is removed later, let the create RPC fail authoritatively, clear the stale repo on refresh, and retain the user's manual name.
|
||||
- Treat provider rows and PR resolution as snapshots. A PR force-push after resolution still creates from the fetched SHA; a title/status change may remain cached. Reopening/retrying may refresh but must not promise immediate provider consistency.
|
||||
- Use the current `worktree.ps` branch snapshot to avoid offering reuse for a known-busy local branch. External/unregistered worktrees and concurrent checkouts can still race; never promise exact reuse until the created worktree response confirms its branch.
|
||||
|
||||
## Edge cases, errors, and latency
|
||||
|
||||
- No source selected: creation and blank-name creature fallback are unchanged.
|
||||
- Empty query in All: recent open GitHub work items plus assigned Linear issues. Do not query refs. Empty query in Branches: show a bounded ref list so the filter is useful without typing.
|
||||
- Oversized query: issue no provider/git RPC and show inline validation.
|
||||
- A GitHub title containing only non-sluggable characters still selects and creates with the deterministic `issue-<number>`/`pr-<number>` identity fallback.
|
||||
- GitHub issue-side partial failure: render successful rows with the warning. RPC/contract failure: render an inline retry action; auth/tooling copy must name the selected host because `gh` runs there. A queried PR-side failure can only look like a missing subset with today's RPC contract, so use honest result/empty copy rather than fabricating a retryable error.
|
||||
- Linear disconnected: omit the filter and rows. A disconnect after opening may fail or return empty; refresh status on the next modal/drawer open.
|
||||
- SSH target transition: an `sshStateChanged` event immediately invalidates in-flight repo-backed reads and removes GitHub/Branches until the selected target is connected again; Linear availability is unchanged.
|
||||
- Linear search and `repo.searchRefs` can mask backend errors as empty with today's RPC contracts. Use honest “No results” copy and retain this as a residual risk; do not fabricate an actionable provider error.
|
||||
- PR resolution failure: keep the drawer and prior selection, show the error inline, and never fall back to the repo default branch.
|
||||
- Known busy local branch: branch from the selected ref with no reuse override. Known reusable local branch defaults to exact reuse only when the branch selection took ownership of a blank/source-owned name.
|
||||
- Folder repo: offer connected Linear only and hide the field otherwise. GitHub PRs and branches require a git repo.
|
||||
- Use explicit 30-second timeouts for provider/ref/PR reads and the existing long create timeout. Disable immediately; show visible loading only after about 200 ms and keep row/button geometry fixed.
|
||||
|
||||
## Performance and blast radius
|
||||
|
||||
Source searches are initiated only while the drawer or a selection resolution is active; the lightweight `linear.status` availability read also runs when the modal opens. Already-started host work may finish after close. No polling, client cache, per-row lookup, or app-startup request is added. Omit GitHub `noCache` during normal search to preserve the runtime cache and rate-limit budget.
|
||||
|
||||
All mode is not one call: a settled query issues up to three concurrent RPCs (GitHub, Linear, refs). Internally, GitHub can perform separate issue/PR reads, Linear can fan out across connected workspaces, and slash ref queries can run two git probes plus an SSH capability fallback. Use a development transport counter/timer to record exact RPC fanout and raw cold/warm settled-result timings for representative All and Branches queries on local and connected SSH repos; do not claim percentiles from ad hoc samples or claim the debounce/generation guard makes this work free.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: exact GitHub, Linear, and ref envelopes; partial errors; malformed top-level rejection; legacy refs never enabling reuse; UTF-8 query bound.
|
||||
- Unit: every source mapping, non-sluggable GitHub title fallback, Linear workspace/org metadata, fork warning, known-busy/reusable branches, create-time override resolution, retry candidates, and explicit name ownership.
|
||||
- Component: capability gate, folder/disconnected-SSH source availability, live `sshStateChanged` disconnect/reconnect, filters, empty/loading/partial/error states, debounce, clear, duplicate-tap guards, keyboard/back behavior, and stale responses across query/filter/repo/close/client reconnect.
|
||||
- Integration: exact `worktree.create` payload and returned-display-name navigation handoff for each source, including Linear on a folder repo; PR resolver failure; repo removal; manual-name preservation; no-source regression; setup/agent/collision behavior.
|
||||
- Contract: runtime status advertises the new capability; all literal mobile RPCs remain registered and allowlisted. No Git compatibility-matrix change is needed because this feature adds no git command.
|
||||
- Validation: focused Vitest, mobile and repository typecheck/lint, max-lines ratchet, an Expo production bundle, paired phone/emulator checks against local and SSH repos, and the development-only RPC-count/raw-timing evidence above. Provider validation must not mutate an issue/PR/Linear item.
|
||||
|
||||
## UI quality bar
|
||||
|
||||
Match the Repository and Agent field hierarchy, mobile tokens, 44-point touch targets, accessibility labels, disabled treatment, and drawer row geometry. Keep the Create action dominant; provider/type icons are secondary and row text must carry the meaning. `BottomDrawer` owns keyboard/safe-area movement and Android back dismissal. Do not add colors, type sizes, or elevation tiers.
|
||||
|
||||
## Review screenshots
|
||||
|
||||
Required, uncommitted device/emulator screenshots:
|
||||
|
||||
1. Create Workspace with the empty optional field at phone width.
|
||||
2. Search drawer with keyboard visible, filters, and a mixed authenticated result set (or the honest available subset).
|
||||
3. Collapsed selected PR or branch with its auto-owned name and any fork warning.
|
||||
4. Deterministic oversized-query validation, or an available live provider partial/error state, with stable drawer geometry.
|
||||
5. Adjacent Repository/Agent picker smoke showing correct top-drawer layering and a reachable Create action.
|
||||
|
||||
Manual-name preservation, stale suppression, exact payloads, and branch reuse are behavioral claims; prove them with tests/validation notes, not static screenshots.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the runtime capability and shared query bound, then cover mixed-version gating.
|
||||
2. Add and test normalized source RPC, selection, name-ownership, branch-reuse, and create-candidate modules.
|
||||
3. Add and test the native source drawer.
|
||||
4. Wire the field, host capabilities, worktree branch snapshot, and source payload into the modal without materially growing the grandfathered file.
|
||||
5. Run focused tests, typecheck, lint, max-lines ratchet, production bundle, performance audit, and paired mobile/Electron validation.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- **Scope:** one mobile field/drawer, pure shared/client modules, and one capability string; no provider mutation or new search backend.
|
||||
- **Architecture/data flow:** native presentation owns transient state; runtime RPC owns host/provider/git work; `src/shared` owns naming and branch semantics; the runtime owns final conflict resolution.
|
||||
- **Failure modes covered:** mixed versions, malformed/partial envelopes, auth/network loss, stale async work, repo removal, PR resolution, duplicate taps, and external branch races.
|
||||
- **Test coverage required:** unit normalization/mapping/ownership/retry tests, component lifecycle/availability/interaction tests, exact-payload integration tests, and runtime capability/allowlist contracts.
|
||||
- **Performance/blast radius:** bounded visible work with no polling, but All mode and PR resolution are multi-operation and must be measured locally and over SSH.
|
||||
- **UI quality bar:** match adjacent mobile fields/drawers and `docs/STYLEGUIDE.md`; preserve touch targets, stable loading/error geometry, keyboard movement, and Create-action hierarchy.
|
||||
- **Required review screenshots:** empty field, mixed search with keyboard, selected source, inline error/partial state, and adjacent-picker layering.
|
||||
- **Residual risks:** Linear search/ref RPCs and queried GitHub PR reads can mask backend failures as missing results; GitHub results may be cached; provider and git state can change after selection.
|
||||
@@ -175,7 +175,7 @@ export function HostScreen({
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
|
||||
const [hostCapabilities, setHostCapabilities] = useState<string[] | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [routeActionState, setRouteActionState] = useState(() =>
|
||||
@@ -510,7 +510,7 @@ export function HostScreen({
|
||||
// Why: drop the prior host's capabilities while disconnected/switching so
|
||||
// a capability-gated action (e.g. Agent Session History) can't linger for
|
||||
// a host that doesn't support it.
|
||||
setHostCapabilities([])
|
||||
setHostCapabilities(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
@@ -1362,7 +1362,7 @@ export function HostScreen({
|
||||
hostId,
|
||||
worktreeId: actionTarget.worktreeId,
|
||||
worktreeName: actionTarget.displayName || actionTarget.repo,
|
||||
hostCapabilities,
|
||||
hostCapabilities: hostCapabilities ?? [],
|
||||
navigate: navigateFromHostList,
|
||||
onDone: () => setActionTarget(null)
|
||||
}),
|
||||
@@ -1417,6 +1417,11 @@ export function HostScreen({
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktreeBranches={displayWorktrees.map((worktree) => ({
|
||||
repoId: worktree.repoId,
|
||||
branch: worktree.branch
|
||||
}))}
|
||||
hostCapabilities={hostCapabilities}
|
||||
onVisibleChange={(visible) => {
|
||||
newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { WebSocket } from 'ws'
|
||||
import {
|
||||
DESKTOP_PROTOCOL_VERSION,
|
||||
MIN_COMPATIBLE_MOBILE_VERSION
|
||||
MIN_COMPATIBLE_MOBILE_VERSION,
|
||||
MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY
|
||||
} from '../../src/shared/protocol-version'
|
||||
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockWorkspaceSourceRequest } from './mock-server-workspace-source'
|
||||
import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures'
|
||||
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
|
||||
|
||||
@@ -103,6 +105,9 @@ export function handleRequest(
|
||||
if (handleMockFilePreviewRequest(request, respond, success, error)) {
|
||||
return
|
||||
}
|
||||
if (handleMockWorkspaceSourceRequest(request, respond, success, FAKE_REPOS[0]?.id ?? 'repo-1')) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'status.get':
|
||||
@@ -114,7 +119,8 @@ export function handleRequest(
|
||||
graphStatus: 'ready',
|
||||
windowCount: 1,
|
||||
tabCount: 2,
|
||||
terminalCount: 2
|
||||
terminalCount: 2,
|
||||
capabilities: [MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY]
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
|
||||
type Respond = (response: RpcResponse) => void
|
||||
type Success = (id: string, result: unknown) => RpcResponse
|
||||
|
||||
function repoSelectorToId(repoSelector: unknown): string | null {
|
||||
if (typeof repoSelector !== 'string') {
|
||||
return null
|
||||
}
|
||||
return repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector
|
||||
}
|
||||
|
||||
export function handleMockWorkspaceSourceRequest(
|
||||
request: RpcRequest,
|
||||
respond: Respond,
|
||||
success: Success,
|
||||
defaultRepoId: string
|
||||
): boolean {
|
||||
if (request.method === 'linear.status') {
|
||||
respond(success(request.id, { connected: true, selectedWorkspaceId: 'workspace-mock' }))
|
||||
return true
|
||||
}
|
||||
if (request.method === 'github.listWorkItems') {
|
||||
const repoId = repoSelectorToId(request.params?.repo) ?? defaultRepoId
|
||||
respond(
|
||||
success(request.id, {
|
||||
items: [
|
||||
{
|
||||
id: 'mock-issue-42',
|
||||
type: 'issue',
|
||||
number: 42,
|
||||
title: 'Improve mobile workspace creation',
|
||||
url: 'https://github.com/stablyai/orca/issues/42',
|
||||
repoId
|
||||
},
|
||||
{
|
||||
id: 'mock-pr-77',
|
||||
type: 'pr',
|
||||
number: 77,
|
||||
title: 'Add source selector',
|
||||
url: 'https://github.com/stablyai/orca/pull/77',
|
||||
branchName: 'feature/source-selector',
|
||||
baseRefName: 'main',
|
||||
repoId
|
||||
}
|
||||
],
|
||||
sources: { issues: null, prs: null, originCandidate: null, upstreamCandidate: null }
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (request.method === 'linear.listIssues' || request.method === 'linear.searchIssues') {
|
||||
respond(
|
||||
success(request.id, [
|
||||
{
|
||||
id: 'linear-mock-1',
|
||||
workspaceId: 'workspace-mock',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Polish mobile source search',
|
||||
url: 'https://linear.app/acme/issue/ENG-123/polish-mobile-source-search'
|
||||
}
|
||||
])
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (request.method === 'repo.searchRefs') {
|
||||
respond(
|
||||
success(request.id, {
|
||||
refDetails: [
|
||||
{ refName: 'feature/mobile-ready', localBranchName: 'feature/mobile-ready' },
|
||||
{ refName: 'origin/main', localBranchName: 'main' }
|
||||
],
|
||||
refs: ['feature/mobile-ready', 'origin/main']
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (request.method === 'worktree.resolvePrBase') {
|
||||
respond(
|
||||
success(request.id, {
|
||||
baseBranch: 'refs/pull/77/head',
|
||||
compareBaseRef: 'origin/main',
|
||||
branchNameOverride: 'feature/source-selector',
|
||||
maintainerCanModify: true
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const animation = vi.hoisted(() => ({
|
||||
timingCallbacks: [] as Array<((finished: boolean) => void) | undefined>
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
BackHandler: { addEventListener: () => ({ remove: vi.fn() }) },
|
||||
Keyboard: { addListener: () => ({ remove: vi.fn() }), dismiss: vi.fn() },
|
||||
Modal: 'Modal',
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
select: (options: { ios?: unknown; default?: unknown }) => options.ios ?? options.default
|
||||
},
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: {
|
||||
absoluteFill: {},
|
||||
absoluteFillObject: {},
|
||||
create: (styles: unknown) => styles
|
||||
},
|
||||
View: 'View',
|
||||
useWindowDimensions: () => ({ height: 800, width: 400 })
|
||||
}))
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
||||
}))
|
||||
|
||||
function chainableGesture() {
|
||||
const gesture: Record<string, (...args: unknown[]) => unknown> = {}
|
||||
for (const method of [
|
||||
'activeOffsetY',
|
||||
'onBegin',
|
||||
'onEnd',
|
||||
'onUpdate',
|
||||
'simultaneousWithExternalGesture'
|
||||
]) {
|
||||
gesture[method] = () => gesture
|
||||
}
|
||||
return gesture
|
||||
}
|
||||
|
||||
vi.mock('react-native-gesture-handler', () => ({
|
||||
Gesture: { Native: chainableGesture, Pan: chainableGesture },
|
||||
GestureDetector: 'GestureDetector',
|
||||
GestureHandlerRootView: 'GestureHandlerRootView'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-reanimated', () => ({
|
||||
default: { ScrollView: 'AnimatedScrollView', View: 'AnimatedView' },
|
||||
Extrapolation: { CLAMP: 'clamp' },
|
||||
interpolate: (_value: number, _input: number[], output: number[]) => output[1],
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
useAnimatedScrollHandler: () => vi.fn(),
|
||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||
useSharedValue: (value: number | boolean) => ({ value }),
|
||||
withSpring: (value: number) => value,
|
||||
withTiming: (value: number, _config: unknown, callback?: (finished: boolean) => void) => {
|
||||
animation.timingCallbacks.push(callback)
|
||||
return value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../layout/responsive-layout', () => ({
|
||||
useResponsiveLayout: () => ({ isWideLayout: false, modalMaxWidth: 640 })
|
||||
}))
|
||||
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('BottomDrawer exit animation', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
animation.timingCallbacks = []
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports hidden only after iOS dismisses the native modal', () => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const onHidden = vi.fn()
|
||||
const renderDrawer = (visible: boolean, label: string) =>
|
||||
createElement(BottomDrawer, { visible, onClose: vi.fn(), onHidden }, label)
|
||||
const restoreWarning = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(renderDrawer(true, 'source picker'))
|
||||
})
|
||||
} finally {
|
||||
restoreWarning()
|
||||
}
|
||||
animation.timingCallbacks = []
|
||||
|
||||
act(() => {
|
||||
renderer?.update(renderDrawer(false, 'source picker'))
|
||||
})
|
||||
act(() => {
|
||||
renderer?.update(renderDrawer(false, 'source picker after parent refresh'))
|
||||
})
|
||||
|
||||
const exitCallbacks = animation.timingCallbacks.filter(Boolean)
|
||||
expect(exitCallbacks).toHaveLength(1)
|
||||
act(() => exitCallbacks[0]?.(true))
|
||||
expect(renderer?.root.findByType('Modal').props.visible).toBe(false)
|
||||
expect(onHidden).not.toHaveBeenCalled()
|
||||
|
||||
act(() => renderer?.root.findByType('Modal').props.onDismiss())
|
||||
expect(renderer?.root.findAllByType('Modal')).toHaveLength(0)
|
||||
expect(onHidden).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
@@ -24,6 +24,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
|
||||
import { useBottomDrawerNativeVisibility } from './use-bottom-drawer-native-visibility'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
|
||||
const DISMISS_THRESHOLD = 80
|
||||
@@ -39,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onHidden?: () => void
|
||||
children: ReactNode
|
||||
dragContentToDismiss?: boolean
|
||||
contentScrollable?: boolean
|
||||
@@ -48,6 +50,7 @@ type Props = {
|
||||
export function BottomDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
onHidden,
|
||||
children,
|
||||
dragContentToDismiss = true,
|
||||
contentScrollable = true,
|
||||
@@ -55,6 +58,12 @@ export function BottomDrawer({
|
||||
}: Props) {
|
||||
const [mounted, setMounted] = useState(visible)
|
||||
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
|
||||
const onHiddenRef = useRef(onHidden)
|
||||
onHiddenRef.current = onHidden
|
||||
const handleHidden = useCallback(() => {
|
||||
setMounted(false)
|
||||
onHiddenRef.current?.()
|
||||
}, [])
|
||||
|
||||
// Why: opening drawers should mount before commit; waiting for a passive
|
||||
// Effect adds a null render before every drawer can animate in.
|
||||
@@ -72,7 +81,7 @@ export function BottomDrawer({
|
||||
<MountedBottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onHidden={() => setMounted(false)}
|
||||
onHidden={handleHidden}
|
||||
dragContentToDismiss={dragContentToDismiss}
|
||||
contentScrollable={contentScrollable}
|
||||
zIndex={zIndex}
|
||||
@@ -83,7 +92,7 @@ export function BottomDrawer({
|
||||
}
|
||||
|
||||
type MountedBottomDrawerProps = Props & {
|
||||
onHidden: () => void
|
||||
onHidden: NonNullable<Props['onHidden']>
|
||||
}
|
||||
|
||||
function MountedBottomDrawer({
|
||||
@@ -103,6 +112,10 @@ function MountedBottomDrawer({
|
||||
const contentDragCanDismiss = useSharedValue(false)
|
||||
const { height: screenHeight } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { nativeVisible, hideNativeModal, reportHidden } = useBottomDrawerNativeVisibility(
|
||||
visible,
|
||||
onHidden
|
||||
)
|
||||
// Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
|
||||
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
|
||||
// transforms below) is unchanged, so phone behavior stays identical.
|
||||
@@ -117,11 +130,11 @@ function MountedBottomDrawer({
|
||||
Keyboard.dismiss()
|
||||
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(onHidden)()
|
||||
runOnJS(hideNativeModal)()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [onHidden, visible])
|
||||
}, [hideNativeModal, visible])
|
||||
|
||||
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
|
||||
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
|
||||
@@ -271,9 +284,17 @@ function MountedBottomDrawer({
|
||||
// the scrolled content and clips the sheet. The Modal stays mounted (visible)
|
||||
// for the whole life of MountedBottomDrawer so the reanimated exit animation
|
||||
// runs before the parent unmounts us; show/hide is driven by `progress`, so
|
||||
// animationType stays "none". onRequestClose handles the Android back button.
|
||||
// animationType stays "none". On iOS, onDismiss is the ownership boundary:
|
||||
// UIKit must finish dismissing this controller before a sibling can present.
|
||||
return (
|
||||
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
|
||||
<Modal
|
||||
visible={nativeVisible}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={dismiss}
|
||||
onDismiss={reportHidden}
|
||||
>
|
||||
<Animated.View
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { NewWorkspaceSourceDrawer } from './NewWorkspaceSourceDrawer'
|
||||
|
||||
const sourceRpc = vi.hoisted(() => ({
|
||||
searchWorkspaceSources: vi.fn(),
|
||||
resolveWorkspaceSourcePr: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-source/workspace-source-rpc', () => ({
|
||||
NEW_WORKSPACE_SOURCE_RESULT_LIMIT: 12,
|
||||
resolveWorkspaceSourcePr: sourceRpc.resolveWorkspaceSourcePr,
|
||||
searchWorkspaceSources: sourceRpc.searchWorkspaceSources
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: 'FlatList',
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
CircleDot: 'CircleDot',
|
||||
GitBranch: 'GitBranch',
|
||||
GitPullRequest: 'GitPullRequest',
|
||||
ListTodo: 'ListTodo'
|
||||
}))
|
||||
|
||||
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
|
||||
vi.mock('./MobileSearchField', () => ({ MobileSearchField: 'MobileSearchField' }))
|
||||
|
||||
const client = { sendRequest: vi.fn() } as unknown as RpcClient
|
||||
const initialAvailability = { github: true, branches: true, linear: false }
|
||||
const initialResult = {
|
||||
rows: [
|
||||
{
|
||||
kind: 'github',
|
||||
key: 'github:issue:1',
|
||||
item: {
|
||||
id: 'issue-1',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 'First issue',
|
||||
url: 'https://github.com/acme/app/issues/1',
|
||||
repoId: 'repo-1',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null
|
||||
}
|
||||
}
|
||||
],
|
||||
warnings: [],
|
||||
errors: []
|
||||
}
|
||||
|
||||
const prResult = {
|
||||
...initialResult,
|
||||
rows: [
|
||||
{
|
||||
kind: 'github',
|
||||
key: 'github:pr:2',
|
||||
item: {
|
||||
...initialResult.rows[0]!.item,
|
||||
id: 'pr-2',
|
||||
type: 'pr',
|
||||
number: 2,
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/acme/app/pull/2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function drawerProps(
|
||||
overrides: Partial<Parameters<typeof NewWorkspaceSourceDrawer>[0]> = {}
|
||||
): Parameters<typeof NewWorkspaceSourceDrawer>[0] {
|
||||
return {
|
||||
visible: true,
|
||||
client,
|
||||
repoId: 'repo-1',
|
||||
availability: initialAvailability,
|
||||
sshStateGeneration: 0,
|
||||
name: { value: '', owner: 'blank' },
|
||||
worktreeBranches: [],
|
||||
onSelect: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function mountDrawer(
|
||||
overrides: Partial<Parameters<typeof NewWorkspaceSourceDrawer>[0]> = {}
|
||||
): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorkspaceSourceDrawer, drawerProps(overrides)))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('workspace source drawer did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
function renderedRows(renderer: ReactTestRenderer): unknown[] {
|
||||
return renderer.root.findByType('FlatList').props.data as unknown[]
|
||||
}
|
||||
|
||||
describe('NewWorkspaceSourceDrawer result generations', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
sourceRpc.searchWorkspaceSources.mockReset()
|
||||
sourceRpc.resolveWorkspaceSourcePr.mockReset()
|
||||
sourceRpc.searchWorkspaceSources
|
||||
.mockResolvedValueOnce(initialResult)
|
||||
.mockReturnValue(new Promise(() => {}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('removes prior rows immediately when the query enters debounce', async () => {
|
||||
renderer = await mountDrawer()
|
||||
expect(renderedRows(renderer)).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
renderer?.root.findByType('MobileSearchField').props.onChangeText('second')
|
||||
})
|
||||
|
||||
expect(renderedRows(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('removes prior rows immediately when the filter changes', async () => {
|
||||
renderer = await mountDrawer()
|
||||
const githubFilter = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.find((node) => node.props.accessibilityLabel === 'GitHub source filter')
|
||||
|
||||
act(() => githubFilter?.props.onPress())
|
||||
|
||||
expect(renderedRows(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('removes prior rows immediately when source availability changes', async () => {
|
||||
renderer = await mountDrawer()
|
||||
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(
|
||||
NewWorkspaceSourceDrawer,
|
||||
drawerProps({ availability: { github: false, branches: false, linear: true } })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderedRows(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores search results from before a same-availability SSH transition', async () => {
|
||||
let resolveStaleSearch: ((value: typeof initialResult) => void) | null = null
|
||||
sourceRpc.searchWorkspaceSources
|
||||
.mockReset()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveStaleSearch = resolve
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
renderer = await mountDrawer()
|
||||
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(NewWorkspaceSourceDrawer, drawerProps({ sshStateGeneration: 1 }))
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
resolveStaleSearch?.(initialResult)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(sourceRpc.searchWorkspaceSources).toHaveBeenCalledTimes(2)
|
||||
expect(renderedRows(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores PR resolution from before a same-availability SSH transition', async () => {
|
||||
sourceRpc.searchWorkspaceSources.mockReset().mockResolvedValue(prResult)
|
||||
let resolveStalePr: ((value: { baseBranch: string; compareBaseRef: string }) => void) | null =
|
||||
null
|
||||
sourceRpc.resolveWorkspaceSourcePr.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveStalePr = resolve
|
||||
})
|
||||
)
|
||||
const onSelect = vi.fn()
|
||||
renderer = await mountDrawer({ onSelect })
|
||||
|
||||
act(() => {
|
||||
renderer!.root
|
||||
.findByType('FlatList')
|
||||
.props.renderItem({ item: prResult.rows[0] })
|
||||
.props.onPress()
|
||||
})
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(NewWorkspaceSourceDrawer, drawerProps({ onSelect, sshStateGeneration: 1 }))
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
resolveStalePr?.({ baseBranch: 'main', compareBaseRef: 'origin/main' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases PR selection ownership when closed and keeps a reopened request isolated', async () => {
|
||||
vi.useFakeTimers()
|
||||
sourceRpc.searchWorkspaceSources.mockReset().mockResolvedValue(prResult)
|
||||
let resolveFirst: ((value: { baseBranch: string; compareBaseRef: string }) => void) | null =
|
||||
null
|
||||
sourceRpc.resolveWorkspaceSourcePr
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
renderer = await mountDrawer()
|
||||
|
||||
const renderRow = () =>
|
||||
renderer!.root.findByType('FlatList').props.renderItem({ item: prResult.rows[0] })
|
||||
const pressRenderedRow = (): void => {
|
||||
renderRow().props.onPress()
|
||||
}
|
||||
act(pressRenderedRow)
|
||||
const pendingRow = renderRow()
|
||||
expect(pendingRow.props.disabled).toBe(true)
|
||||
expect(pendingRow.props.accessibilityState).toEqual({ disabled: true })
|
||||
expect(pendingRow.props.children[2].props.children).toBeNull()
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(199))
|
||||
expect(renderRow().props.children[2].props.children).toBeNull()
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1))
|
||||
expect(renderRow().props.children[2].props.children.type).toBe('ActivityIndicator')
|
||||
|
||||
act(() =>
|
||||
renderer?.update(createElement(NewWorkspaceSourceDrawer, drawerProps({ visible: false })))
|
||||
)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(NewWorkspaceSourceDrawer, drawerProps()))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(
|
||||
renderer.root.findByType('FlatList').props.renderItem({ item: prResult.rows[0] }).props
|
||||
.disabled
|
||||
).toBe(false)
|
||||
|
||||
act(pressRenderedRow)
|
||||
expect(sourceRpc.resolveWorkspaceSourcePr).toHaveBeenCalledTimes(2)
|
||||
act(() => resolveFirst?.({ baseBranch: 'main', compareBaseRef: 'origin/main' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
act(pressRenderedRow)
|
||||
expect(sourceRpc.resolveWorkspaceSourcePr).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { CircleDot, GitBranch, GitPullRequest, ListTodo } from 'lucide-react-native'
|
||||
import { isWorkspaceSourceQueryWithinLimit } from '../../../src/shared/workspace-source-query'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type {
|
||||
NewWorkspaceSourceFilter,
|
||||
NewWorkspaceSourceRow,
|
||||
ResolvedNewWorkspaceSource,
|
||||
WorkspaceSourceAvailability
|
||||
} from '../workspace-source/new-workspace-source-types'
|
||||
import {
|
||||
NEW_WORKSPACE_SOURCE_RESULT_LIMIT,
|
||||
resolveWorkspaceSourcePr,
|
||||
searchWorkspaceSources
|
||||
} from '../workspace-source/workspace-source-rpc'
|
||||
import {
|
||||
resolveBranchWorkspaceSource,
|
||||
resolveGitHubWorkspaceSource,
|
||||
resolveLinearWorkspaceSource
|
||||
} from '../workspace-source/workspace-source-selection'
|
||||
import type { WorkspaceNameState } from '../workspace-source/workspace-source-name-state'
|
||||
import { getAvailableWorkspaceSourceFilters } from '../workspace-source/workspace-source-availability'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MobileSearchField } from './MobileSearchField'
|
||||
import {
|
||||
workspaceSourceAccessibilityLabel,
|
||||
workspaceSourceFilterLabel,
|
||||
workspaceSourceSubtitle,
|
||||
workspaceSourceTitle
|
||||
} from './new-workspace-source-drawer-presentation'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 200
|
||||
const VISIBLE_LOADING_DELAY_MS = 200
|
||||
const SOURCE_LIST_HEIGHT = 250
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
repoId: string
|
||||
availability: WorkspaceSourceAvailability
|
||||
sshStateGeneration: number
|
||||
name: WorkspaceNameState
|
||||
worktreeBranches: readonly string[]
|
||||
onSelect: (source: ResolvedNewWorkspaceSource) => void
|
||||
onClose: () => void
|
||||
onHidden?: () => void
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
export function NewWorkspaceSourceDrawer({
|
||||
visible,
|
||||
client,
|
||||
repoId,
|
||||
availability,
|
||||
sshStateGeneration,
|
||||
name,
|
||||
worktreeBranches,
|
||||
onSelect,
|
||||
onClose,
|
||||
onHidden,
|
||||
onOpen
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<NewWorkspaceSourceFilter>('all')
|
||||
const [rows, setRows] = useState<NewWorkspaceSourceRow[]>([])
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showLoading, setShowLoading] = useState(false)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [selectionError, setSelectionError] = useState('')
|
||||
// Why: selection disables immediately, while visible progress stays delayed for fast hosts.
|
||||
const [selectionPending, setSelectionPending] = useState(false)
|
||||
const [resolvingKey, setResolvingKey] = useState<string | null>(null)
|
||||
const generationRef = useRef(0)
|
||||
const selectionEpochRef = useRef(0)
|
||||
const selectionInFlightRef = useRef(false)
|
||||
const filters = useMemo(() => getAvailableWorkspaceSourceFilters(availability), [availability])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
onOpen()
|
||||
}
|
||||
}, [onOpen, visible])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
generationRef.current += 1
|
||||
selectionEpochRef.current += 1
|
||||
selectionInFlightRef.current = false
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!filters.includes(filter)) {
|
||||
setFilter('all')
|
||||
}
|
||||
}, [filter, filters])
|
||||
|
||||
useEffect(() => {
|
||||
const generation = ++generationRef.current
|
||||
// Why: stale host work must not retain request-owned selection UI.
|
||||
selectionEpochRef.current += 1
|
||||
selectionInFlightRef.current = false
|
||||
setSelectionPending(false)
|
||||
setResolvingKey(null)
|
||||
setSelectionError('')
|
||||
// Why: rows belong to the query/filter/availability generation that loaded them;
|
||||
// retaining them during debounce would leave stale sources selectable.
|
||||
setRows([])
|
||||
setWarnings([])
|
||||
setErrors([])
|
||||
if (!visible || !client || !isWorkspaceSourceQueryWithinLimit(query)) {
|
||||
setLoading(false)
|
||||
setShowLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setShowLoading(false)
|
||||
let requestTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const loadingTimer = setTimeout(() => {
|
||||
if (generation === generationRef.current) {
|
||||
setShowLoading(true)
|
||||
}
|
||||
}, VISIBLE_LOADING_DELAY_MS)
|
||||
const run = () => {
|
||||
void searchWorkspaceSources({
|
||||
client,
|
||||
repoId,
|
||||
query,
|
||||
filter,
|
||||
availability
|
||||
})
|
||||
.then((result) => {
|
||||
if (generation !== generationRef.current) {
|
||||
return
|
||||
}
|
||||
setRows(result.rows)
|
||||
setWarnings(result.warnings)
|
||||
setErrors(result.errors)
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === generationRef.current) {
|
||||
setLoading(false)
|
||||
setShowLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (query.trim()) {
|
||||
requestTimer = setTimeout(run, SEARCH_DEBOUNCE_MS)
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(loadingTimer)
|
||||
if (requestTimer) {
|
||||
clearTimeout(requestTimer)
|
||||
}
|
||||
}
|
||||
}, [availability, client, filter, query, repoId, retryKey, sshStateGeneration, visible])
|
||||
|
||||
const selectRow = useCallback(
|
||||
async (row: NewWorkspaceSourceRow): Promise<void> => {
|
||||
if (selectionInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
selectionInFlightRef.current = true
|
||||
generationRef.current += 1
|
||||
const selectionEpoch = ++selectionEpochRef.current
|
||||
setSelectionPending(true)
|
||||
setSelectionError('')
|
||||
let spinnerTimer: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
if (row.kind === 'github') {
|
||||
if (row.item.type === 'pr') {
|
||||
spinnerTimer = setTimeout(() => {
|
||||
if (selectionEpoch === selectionEpochRef.current) {
|
||||
setResolvingKey(row.key)
|
||||
}
|
||||
}, VISIBLE_LOADING_DELAY_MS)
|
||||
const startPoint = await resolveWorkspaceSourcePr({
|
||||
client: client!,
|
||||
repoId,
|
||||
item: row.item
|
||||
})
|
||||
if (selectionEpoch !== selectionEpochRef.current) {
|
||||
return
|
||||
}
|
||||
onSelect(resolveGitHubWorkspaceSource(row.item, startPoint))
|
||||
} else {
|
||||
onSelect(resolveGitHubWorkspaceSource(row.item))
|
||||
}
|
||||
} else if (row.kind === 'linear') {
|
||||
onSelect(resolveLinearWorkspaceSource(row.issue))
|
||||
} else {
|
||||
onSelect(
|
||||
resolveBranchWorkspaceSource({
|
||||
...row,
|
||||
name,
|
||||
worktreeBranches
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectionEpoch === selectionEpochRef.current) {
|
||||
setSelectionError(
|
||||
error instanceof Error ? error.message : 'Failed to resolve the selected source.'
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (spinnerTimer) {
|
||||
clearTimeout(spinnerTimer)
|
||||
}
|
||||
// Why: a stale completion must not release guards owned by a newer tap.
|
||||
if (selectionEpoch === selectionEpochRef.current) {
|
||||
selectionInFlightRef.current = false
|
||||
setSelectionPending(false)
|
||||
setResolvingKey(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, name, onSelect, repoId, worktreeBranches]
|
||||
)
|
||||
|
||||
const oversized = !isWorkspaceSourceQueryWithinLimit(query)
|
||||
const message = oversized
|
||||
? 'Search must be 2 KB or less.'
|
||||
: selectionError || errors[0] || warnings[0] || ''
|
||||
const emptyCopy = query.trim() ? 'No results.' : 'No recent sources.'
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose} onHidden={onHidden} contentScrollable={false}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Start from</Text>
|
||||
<Text style={styles.subtitle}>Choose an issue, pull request, or branch.</Text>
|
||||
</View>
|
||||
<MobileSearchField
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search sources"
|
||||
autoFocus
|
||||
focusKey={visible}
|
||||
accessibilityLabel="Search workspace sources"
|
||||
/>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filters}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{filters.map((item) => (
|
||||
<Pressable
|
||||
key={item}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: filter === item }}
|
||||
accessibilityLabel={`${workspaceSourceFilterLabel(item)} source filter`}
|
||||
style={[styles.filter, filter === item && styles.filterSelected]}
|
||||
onPress={() => setFilter(item)}
|
||||
>
|
||||
<Text style={[styles.filterText, filter === item && styles.filterTextSelected]}>
|
||||
{workspaceSourceFilterLabel(item)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={styles.statusSlot}>
|
||||
{message ? (
|
||||
<View style={styles.statusRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
(oversized || errors.length > 0 || selectionError) && styles.errorText
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
{!oversized && errors.length > 0 ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry source search"
|
||||
onPress={() => setRetryKey((value) => value + 1)}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<FlatList
|
||||
data={oversized ? [] : rows.slice(0, NEW_WORKSPACE_SOURCE_RESULT_LIMIT)}
|
||||
keyExtractor={(row) => row.key}
|
||||
style={styles.list}
|
||||
contentContainerStyle={rows.length === 0 ? styles.emptyList : undefined}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
nestedScrollEnabled
|
||||
ItemSeparatorComponent={SourceSeparator}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
{showLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : !loading && !oversized && !errors.length ? (
|
||||
<Text style={styles.emptyText}>{emptyCopy}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={workspaceSourceAccessibilityLabel(item)}
|
||||
accessibilityState={{ disabled: selectionPending }}
|
||||
disabled={selectionPending}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void selectRow(item)}
|
||||
>
|
||||
<SourceIcon row={item} />
|
||||
<View style={styles.rowCopy}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{workspaceSourceTitle(item)}
|
||||
</Text>
|
||||
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
||||
{workspaceSourceSubtitle(item)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.trailing}>
|
||||
{resolvingKey === item.key ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceIcon({ row }: { row: NewWorkspaceSourceRow }) {
|
||||
const props = { size: 16, color: colors.textSecondary, strokeWidth: 2.1 }
|
||||
return row.kind === 'branch' ? (
|
||||
<GitBranch {...props} />
|
||||
) : row.kind === 'linear' ? (
|
||||
<ListTodo {...props} />
|
||||
) : row.item.type === 'pr' ? (
|
||||
<GitPullRequest {...props} />
|
||||
) : (
|
||||
<CircleDot {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function SourceSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { paddingHorizontal: spacing.xs, marginBottom: spacing.md },
|
||||
title: { fontSize: 15, fontWeight: '600', color: colors.textPrimary },
|
||||
subtitle: { fontSize: 13, color: colors.textMuted, marginTop: 2 },
|
||||
filters: { gap: spacing.xs, paddingVertical: spacing.sm },
|
||||
filter: {
|
||||
minHeight: 44,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterSelected: { backgroundColor: colors.bgRaised, borderColor: colors.textSecondary },
|
||||
filterText: { fontSize: 12, color: colors.textSecondary },
|
||||
filterTextSelected: { color: colors.textPrimary, fontWeight: '600' },
|
||||
statusSlot: { minHeight: 34, justifyContent: 'center' },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
statusText: { flex: 1, fontSize: 12, color: colors.textSecondary },
|
||||
errorText: { color: colors.statusRed },
|
||||
retryText: { fontSize: 12, fontWeight: '600', color: colors.textPrimary, padding: spacing.xs },
|
||||
list: {
|
||||
flexGrow: 0,
|
||||
height: SOURCE_LIST_HEIGHT,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.input,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
emptyList: { flexGrow: 1 },
|
||||
emptyState: {
|
||||
minHeight: SOURCE_LIST_HEIGHT,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: { fontSize: typography.bodySize, color: colors.textSecondary },
|
||||
row: {
|
||||
minHeight: 58,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
rowPressed: { backgroundColor: colors.bgRaised },
|
||||
rowCopy: { flex: 1, minWidth: 0 },
|
||||
rowTitle: { fontSize: typography.bodySize, color: colors.textPrimary },
|
||||
rowSubtitle: { fontSize: 12, color: colors.textMuted, marginTop: 2 },
|
||||
trailing: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' },
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Pressable, StyleSheet, Switch, Text, View } from 'react-native'
|
||||
import { ChevronDown, CircleDot, GitBranch, GitPullRequest, ListTodo, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { NewWorkspaceSourceController } from '../workspace-source/use-new-workspace-source'
|
||||
|
||||
type Props = {
|
||||
controller: NewWorkspaceSourceController
|
||||
onPrepareOpen: () => void
|
||||
}
|
||||
|
||||
export function NewWorkspaceSourceField({ controller, onPrepareOpen }: Props) {
|
||||
if (!controller.fieldVisible) {
|
||||
return null
|
||||
}
|
||||
const source = controller.source
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
Start from <Text style={styles.labelHint}>[Optional]</Text>
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
source ? `Start from ${sourceLabel(source)}` : 'Choose a workspace source'
|
||||
}
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
onPrepareOpen()
|
||||
controller.setDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
<SourceIcon source={source} />
|
||||
<Text style={[styles.fieldButtonText, !source && styles.placeholder]} numberOfLines={1}>
|
||||
{source ? sourceLabel(source) : 'Default branch'}
|
||||
</Text>
|
||||
{source ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear workspace source"
|
||||
hitSlop={10}
|
||||
style={styles.clearButton}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
controller.clearSource()
|
||||
}}
|
||||
>
|
||||
<X size={15} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
{source?.kind === 'branch' && source.reuseEligibleBranch ? (
|
||||
<View style={styles.reuseRow}>
|
||||
<View style={styles.reuseCopy}>
|
||||
<Text style={styles.reuseTitle}>Reuse existing branch</Text>
|
||||
<Text style={styles.reuseSubtitle} numberOfLines={1}>
|
||||
Check out {source.reuseEligibleBranch} without creating a new branch.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel="Reuse existing branch"
|
||||
value={source.reuseEnabled}
|
||||
onValueChange={controller.setReuseEnabled}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{source?.kind === 'github' && source.forkWarning ? (
|
||||
<Text style={styles.warning}>{source.forkWarning}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function sourceLabel(source: NonNullable<NewWorkspaceSourceController['source']>): string {
|
||||
return source.kind === 'github'
|
||||
? `${source.item.type === 'pr' ? 'PR' : 'Issue'} #${source.item.number}: ${source.item.title}`
|
||||
: source.kind === 'linear'
|
||||
? `${source.issue.identifier}: ${source.issue.title}`
|
||||
: source.refName
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: NewWorkspaceSourceController['source'] }) {
|
||||
const props = { size: 15, color: colors.textSecondary, strokeWidth: 2.1 }
|
||||
return !source || source.kind === 'branch' ? (
|
||||
<GitBranch {...props} />
|
||||
) : source?.kind === 'linear' ? (
|
||||
<ListTodo {...props} />
|
||||
) : source?.kind === 'github' && source.item.type === 'pr' ? (
|
||||
<GitPullRequest {...props} />
|
||||
) : (
|
||||
<CircleDot {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
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 },
|
||||
fieldButton: {
|
||||
minHeight: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
fieldButtonText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
placeholder: { color: colors.textMuted },
|
||||
clearButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: -spacing.md
|
||||
},
|
||||
reuseRow: {
|
||||
minHeight: 52,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
reuseCopy: { flex: 1, minWidth: 0 },
|
||||
reuseTitle: { fontSize: 13, color: colors.textPrimary },
|
||||
reuseSubtitle: { fontSize: 12, color: colors.textMuted, marginTop: 1 },
|
||||
warning: { fontSize: 12, color: colors.statusAmber, marginTop: spacing.xs }
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fixture = vi.hoisted(() => {
|
||||
const repo = { id: 'repo-1', displayName: 'App', path: '/app', kind: 'git' as const }
|
||||
return {
|
||||
repo,
|
||||
sourceDrawerVisible: false,
|
||||
setDrawerVisible: vi.fn((visible: boolean) => {
|
||||
fixture.sourceDrawerVisible = visible
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Switch: 'Switch',
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronUp: 'ChevronUp'
|
||||
}))
|
||||
|
||||
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
|
||||
vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' }))
|
||||
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
vi.mock('./MobileWorkspaceNameInput', () => ({ MobileWorkspaceNameInput: 'NameInput' }))
|
||||
vi.mock('./NewWorkspaceSourceDrawer', () => ({
|
||||
NewWorkspaceSourceDrawer: 'NewWorkspaceSourceDrawer'
|
||||
}))
|
||||
vi.mock('./NewWorkspaceSourceField', () => ({
|
||||
NewWorkspaceSourceField: 'NewWorkspaceSourceField'
|
||||
}))
|
||||
vi.mock('../cache/repo-cache', () => ({
|
||||
getCachedRepos: () => [fixture.repo],
|
||||
setCachedRepos: vi.fn()
|
||||
}))
|
||||
vi.mock('../worktree/use-last-visited-worktree-repo', () => ({
|
||||
useLastVisitedWorktreeRepoId: () => ({ loaded: true, repoId: fixture.repo.id })
|
||||
}))
|
||||
vi.mock('../workspace-source/use-live-workspace-ssh-state', () => ({
|
||||
useLiveWorkspaceSshState: () => ({
|
||||
state: null,
|
||||
connecting: false,
|
||||
generation: 0,
|
||||
connect: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('../workspace-source/use-new-workspace-source', () => ({
|
||||
useNewWorkspaceSource: () => ({
|
||||
name: { value: '', owner: 'blank' },
|
||||
source: null,
|
||||
availability: { github: true, branches: true, linear: false },
|
||||
sshStateGeneration: 0,
|
||||
fieldVisible: true,
|
||||
drawerVisible: fixture.sourceDrawerVisible,
|
||||
setDrawerVisible: fixture.setDrawerVisible,
|
||||
setManualName: vi.fn(),
|
||||
selectSource: vi.fn(),
|
||||
clearSource: vi.fn(),
|
||||
setReuseEnabled: vi.fn(),
|
||||
refreshLinearStatus: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
|
||||
const client = {
|
||||
sendRequest: vi.fn(async (method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return { ok: true, result: { repos: [fixture.repo] } }
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return { ok: true, result: { settings: {} } }
|
||||
}
|
||||
if (method === 'ui.get') {
|
||||
return { ok: true, result: { ui: {} } }
|
||||
}
|
||||
if (method === 'preflight.detectAgents') {
|
||||
return { ok: true, result: ['codex'] }
|
||||
}
|
||||
if (method === 'repo.hooks') {
|
||||
return { ok: true, result: { hooks: null, source: null } }
|
||||
}
|
||||
return { ok: true, result: {} }
|
||||
})
|
||||
} as never
|
||||
|
||||
function modal(overrides: { existingWorktreePaths?: string[] } = {}) {
|
||||
return createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
existingWorktreePaths: overrides.existingWorktreePaths,
|
||||
hostCapabilities: ['mobile.workspace-source-selector.v1'],
|
||||
onCreated: vi.fn(),
|
||||
onClose: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function textContent(node: ReactTestInstance): string {
|
||||
return node
|
||||
.findAllByType('Text')
|
||||
.map((text) => text.props.children)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function fieldPress(renderer: ReactTestRenderer, label: string): ReactTestInstance {
|
||||
const field = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.find((node) => textContent(node).includes(label))
|
||||
if (!field) {
|
||||
throw new Error(`Missing ${label} field`)
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
function visibleNativeLayers(renderer: ReactTestRenderer): ReactTestInstance[] {
|
||||
return [
|
||||
...renderer.root.findAllByType('BottomDrawer'),
|
||||
...renderer.root.findAllByType('PickerListDrawer'),
|
||||
...renderer.root.findAllByType('NewWorkspaceSourceDrawer')
|
||||
].filter((node) => node.props.visible === true)
|
||||
}
|
||||
|
||||
function layer(renderer: ReactTestRenderer, type: string, title?: string): ReactTestInstance {
|
||||
return renderer.root
|
||||
.findAllByType(type)
|
||||
.find((node) => title === undefined || node.props.title === title)!
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal native drawer ownership', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
fixture.sourceDrawerVisible = false
|
||||
fixture.setDrawerVisible.mockClear()
|
||||
vi.mocked(client.sendRequest).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function mount(): Promise<ReactTestRenderer> {
|
||||
await act(async () => {
|
||||
renderer = create(modal())
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
return renderer!
|
||||
}
|
||||
|
||||
it('never presents a picker while the Create drawer still owns the native modal', async () => {
|
||||
const view = await mount()
|
||||
expect(visibleNativeLayers(view)).toHaveLength(1)
|
||||
|
||||
act(() => fieldPress(view, 'App').props.onPress())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(0)
|
||||
|
||||
act(() => layer(view, 'BottomDrawer').props.onHidden())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(1)
|
||||
expect(layer(view, 'PickerListDrawer', 'Repository').props.visible).toBe(true)
|
||||
|
||||
act(() => layer(view, 'PickerListDrawer', 'Repository').props.onClose())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(0)
|
||||
act(() => layer(view, 'PickerListDrawer', 'Repository').props.onHidden())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves source and adjacent-picker ordering across parent refreshes', async () => {
|
||||
const view = await mount()
|
||||
const sourceField = () => layer(view, 'NewWorkspaceSourceField')
|
||||
|
||||
fixture.sourceDrawerVisible = true
|
||||
act(() => {
|
||||
sourceField().props.onPrepareOpen()
|
||||
view.update(modal({ existingWorktreePaths: ['/app/first'] }))
|
||||
})
|
||||
expect(visibleNativeLayers(view)).toHaveLength(0)
|
||||
act(() => layer(view, 'BottomDrawer').props.onHidden())
|
||||
expect(layer(view, 'NewWorkspaceSourceDrawer').props.visible).toBe(true)
|
||||
|
||||
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onClose())
|
||||
act(() => view.update(modal({ existingWorktreePaths: ['/app/second'] })))
|
||||
expect(visibleNativeLayers(view)).toHaveLength(0)
|
||||
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onHidden())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(1)
|
||||
|
||||
fixture.sourceDrawerVisible = true
|
||||
act(() => sourceField().props.onPrepareOpen())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(0)
|
||||
act(() => layer(view, 'BottomDrawer').props.onHidden())
|
||||
expect(layer(view, 'NewWorkspaceSourceDrawer').props.visible).toBe(true)
|
||||
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onClose())
|
||||
act(() => layer(view, 'NewWorkspaceSourceDrawer').props.onHidden())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(1)
|
||||
|
||||
act(() => fieldPress(view, 'Codex').props.onPress())
|
||||
expect(visibleNativeLayers(view)).toHaveLength(0)
|
||||
act(() => layer(view, 'BottomDrawer').props.onHidden())
|
||||
expect(layer(view, 'PickerListDrawer', 'Agent').props.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,9 @@ import { BottomDrawer } from './BottomDrawer'
|
||||
import { PickerListDrawer } from './PickerListDrawer'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput'
|
||||
import { NewWorkspaceSourceDrawer } from './NewWorkspaceSourceDrawer'
|
||||
import { NewWorkspaceSourceField } from './NewWorkspaceSourceField'
|
||||
import { useNewWorktreeModalPresentation } from './new-worktree-modal-presentation'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
|
||||
@@ -34,7 +37,6 @@ import {
|
||||
MOBILE_TUI_AGENT_LAUNCH_COMMANDS
|
||||
} from '../tasks/mobile-tui-agents'
|
||||
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import {
|
||||
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
|
||||
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
|
||||
@@ -49,6 +51,9 @@ import {
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo,
|
||||
resolveMobileNewWorkspaceDialogRepoId
|
||||
} from '../worktree/new-workspace-dialog-repo-selection'
|
||||
import { useNewWorkspaceSource } from '../workspace-source/use-new-workspace-source'
|
||||
import { buildWorkspaceSourceCreateCandidate } from '../workspace-source/workspace-source-create-candidate'
|
||||
import { useLiveWorkspaceSshState } from '../workspace-source/use-live-workspace-ssh-state'
|
||||
|
||||
type Repo = {
|
||||
id: string
|
||||
@@ -56,6 +61,7 @@ type Repo = {
|
||||
path: string
|
||||
badgeColor?: string
|
||||
connectionId?: string | null
|
||||
kind?: 'git' | 'folder'
|
||||
}
|
||||
|
||||
type SetupDecision = 'inherit' | 'run' | 'skip'
|
||||
@@ -124,6 +130,8 @@ type Props = {
|
||||
// on the on-disk directory basename, so paths (not displayNames) are
|
||||
// what the suggestion logic must dedupe against.
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktreeBranches?: readonly { repoId: string; branch: string }[]
|
||||
hostCapabilities?: readonly string[] | null
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -133,6 +141,8 @@ export function NewWorktreeModal({
|
||||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktreeBranches,
|
||||
hostCapabilities,
|
||||
onCreated,
|
||||
onClose
|
||||
}: Props) {
|
||||
@@ -157,6 +167,8 @@ export function NewWorktreeModal({
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktreeBranches={existingWorktreeBranches}
|
||||
hostCapabilities={hostCapabilities}
|
||||
onCreated={onCreated}
|
||||
onClose={onClose}
|
||||
/>
|
||||
@@ -168,6 +180,8 @@ function NewWorktreeModalContent({
|
||||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktreeBranches,
|
||||
hostCapabilities,
|
||||
onCreated,
|
||||
onClose
|
||||
}: Props) {
|
||||
@@ -183,9 +197,6 @@ function NewWorktreeModalContent({
|
||||
)
|
||||
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 [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
|
||||
@@ -197,9 +208,11 @@ function NewWorktreeModalContent({
|
||||
> | null>(null)
|
||||
const [runSetup, setRunSetup] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const createInFlightRef = useRef(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(initialRepos == null)
|
||||
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
|
||||
const modalPresentation = useNewWorktreeModalPresentation()
|
||||
|
||||
// Why: matches the desktop UI — the input shows a generic "Workspace name"
|
||||
// placeholder, not the suggested creature. The creature name is only used
|
||||
@@ -208,11 +221,31 @@ function NewWorktreeModalContent({
|
||||
// existingWorktreePaths at submission time.
|
||||
|
||||
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
|
||||
const liveSsh = useLiveWorkspaceSshState({
|
||||
visible,
|
||||
client,
|
||||
targetId: selectedRepoConnectionId
|
||||
})
|
||||
const sshGate = deriveWorkspaceSshGate({
|
||||
connectionId: selectedRepoConnectionId,
|
||||
state: sshState,
|
||||
connecting: sshConnectingTargetId === selectedRepoConnectionId
|
||||
state: liveSsh.state,
|
||||
connecting: liveSsh.connecting
|
||||
})
|
||||
const workspaceSource = useNewWorkspaceSource({
|
||||
visible,
|
||||
client,
|
||||
capabilities: hostCapabilities,
|
||||
repo: selectedRepo,
|
||||
repoConnected: !selectedRepoConnectionId || sshGate.status === 'connected',
|
||||
sshStateGeneration: liveSsh.generation
|
||||
})
|
||||
const selectedRepoWorktreeBranches = useMemo(
|
||||
() =>
|
||||
(existingWorktreeBranches ?? [])
|
||||
.filter((worktree) => worktree.repoId === selectedRepo?.id)
|
||||
.map((worktree) => worktree.branch),
|
||||
[existingWorktreeBranches, selectedRepo?.id]
|
||||
)
|
||||
const detectedAgentIds =
|
||||
detectedAgentIdsState?.connectionId === selectedRepoConnectionId &&
|
||||
(selectedRepoConnectionId === null || sshGate.status === 'connected')
|
||||
@@ -325,45 +358,6 @@ function NewWorktreeModalContent({
|
||||
}
|
||||
}, [visible, client, hostId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || !selectedRepoConnectionId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void client
|
||||
.sendRequest('ssh.getState', { targetId: selectedRepoConnectionId })
|
||||
.then((response) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
setSshState(
|
||||
state.state ?? {
|
||||
targetId: selectedRepoConnectionId,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!stale) {
|
||||
setSshState({
|
||||
targetId: selectedRepoConnectionId,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Failed to read SSH connection state.',
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, selectedRepoConnectionId, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) {
|
||||
return
|
||||
@@ -445,47 +439,6 @@ function NewWorktreeModalContent({
|
||||
}
|
||||
}, [client, selectedRepo])
|
||||
|
||||
async function connectSelectedSshRepo(): Promise<void> {
|
||||
if (!client || !selectedRepoConnectionId) {
|
||||
return
|
||||
}
|
||||
setSshConnectingTargetId(selectedRepoConnectionId)
|
||||
setSshState({
|
||||
targetId: selectedRepoConnectionId,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
try {
|
||||
const response = await client.sendRequest(
|
||||
'ssh.connect',
|
||||
{ targetId: selectedRepoConnectionId },
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
setSshState(
|
||||
result.state ?? {
|
||||
targetId: selectedRepoConnectionId,
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
setSshState({
|
||||
targetId: selectedRepoConnectionId,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.',
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
} finally {
|
||||
setSshConnectingTargetId((current) => (current === selectedRepoConnectionId ? null : current))
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSetupHookTrust(
|
||||
repoId: string,
|
||||
contentHash: string,
|
||||
@@ -508,9 +461,10 @@ function NewWorktreeModalContent({
|
||||
}
|
||||
|
||||
async function handleCreate(options: CreateOptions = {}) {
|
||||
if (!client || !selectedRepo) {
|
||||
if (!client || !selectedRepo || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
createInFlightRef.current = true
|
||||
setCreating(true)
|
||||
setError('')
|
||||
|
||||
@@ -555,7 +509,7 @@ function NewWorktreeModalContent({
|
||||
// server invent one. The pre-flight basename dedupe is only a hint;
|
||||
// the authoritative collision is checked server-side against git
|
||||
// branches/remotes/PRs, so we also retry-with-suffix on conflict.
|
||||
const trimmedName = name.trim()
|
||||
const trimmedName = workspaceSource.name.value.trim()
|
||||
const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? [])
|
||||
|
||||
// Why: mirrors src/renderer/src/store/slices/worktrees.ts
|
||||
@@ -602,32 +556,41 @@ function NewWorktreeModalContent({
|
||||
contentHash: setupTrust.contentHash,
|
||||
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
|
||||
})
|
||||
modalPresentation.openLayer('setup-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}`,
|
||||
const baseParams: Record<string, unknown> = {
|
||||
startupCommand: command,
|
||||
setupDecision,
|
||||
name: candidateName
|
||||
setupDecision
|
||||
}
|
||||
if (selectedAgent.id !== '__blank__') {
|
||||
params.createdWithAgent = selectedAgent.id
|
||||
baseParams.createdWithAgent = selectedAgent.id
|
||||
}
|
||||
if (note.trim()) {
|
||||
params.comment = note.trim()
|
||||
baseParams.comment = note.trim()
|
||||
}
|
||||
const params = buildWorkspaceSourceCreateCandidate({
|
||||
baseParams,
|
||||
baseName,
|
||||
name: workspaceSource.name,
|
||||
source: workspaceSource.source,
|
||||
selectedRepoId: selectedRepo.id,
|
||||
attempt
|
||||
})
|
||||
|
||||
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 } }
|
||||
const result = (response as RpcSuccess).result as {
|
||||
worktree: { id: string; displayName?: string }
|
||||
}
|
||||
onClose()
|
||||
onCreated(result.worktree.id, candidateName)
|
||||
onCreated(result.worktree.id, result.worktree.displayName ?? candidateName)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -640,6 +603,7 @@ function NewWorktreeModalContent({
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create workspace')
|
||||
} finally {
|
||||
createInFlightRef.current = false
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
@@ -676,9 +640,34 @@ function NewWorktreeModalContent({
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
function openSelectionLayer(layer: 'source' | 'repository' | 'agent'): void {
|
||||
prepareSelectionPickerOpen()
|
||||
modalPresentation.openLayer(layer)
|
||||
}
|
||||
|
||||
function closeSelectionLayer(layer: 'source' | 'repository' | 'agent'): void {
|
||||
if (layer === 'source') {
|
||||
workspaceSource.setDrawerVisible(false)
|
||||
} else if (layer === 'repository') {
|
||||
setShowRepoPicker(false)
|
||||
} else {
|
||||
setShowAgentPicker(false)
|
||||
}
|
||||
modalPresentation.closeLayer(layer)
|
||||
}
|
||||
|
||||
function closeSetupTrustPrompt(): void {
|
||||
setSetupTrustPrompt(null)
|
||||
modalPresentation.closeLayer('setup-trust')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<BottomDrawer
|
||||
visible={visible && modalPresentation.visibleLayer === 'form'}
|
||||
onClose={onClose}
|
||||
onHidden={() => modalPresentation.handleLayerHidden('form')}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
@@ -701,7 +690,7 @@ function NewWorktreeModalContent({
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
openSelectionLayer('repository')
|
||||
setShowRepoPicker(true)
|
||||
}}
|
||||
>
|
||||
@@ -750,7 +739,7 @@ function NewWorktreeModalContent({
|
||||
sshGate.connectInProgress && styles.disabled
|
||||
]}
|
||||
disabled={sshGate.connectInProgress}
|
||||
onPress={() => void connectSelectedSshRepo()}
|
||||
onPress={() => void liveSsh.connect()}
|
||||
>
|
||||
<Text style={styles.sshConnectText}>
|
||||
{sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
|
||||
@@ -763,15 +752,22 @@ function NewWorktreeModalContent({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{selectedRepo ? (
|
||||
<NewWorkspaceSourceField
|
||||
controller={workspaceSource}
|
||||
onPrepareOpen={() => openSelectionLayer('source')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
|
||||
</Text>
|
||||
<MobileWorkspaceNameInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
value={workspaceSource.name.value}
|
||||
onChangeText={(t) => {
|
||||
setName(t)
|
||||
workspaceSource.setManualName(t)
|
||||
setError('')
|
||||
}}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
@@ -791,7 +787,7 @@ function NewWorktreeModalContent({
|
||||
style={[styles.fieldButton, sshGate.requiresConnection && styles.disabled]}
|
||||
disabled={sshGate.requiresConnection}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
openSelectionLayer('agent')
|
||||
setShowAgentPicker(true)
|
||||
}}
|
||||
>
|
||||
@@ -905,20 +901,43 @@ function NewWorktreeModalContent({
|
||||
|
||||
{/* Sub-modals for pickers — rendered outside the main modal so they
|
||||
layer on top and scroll without touch conflicts. */}
|
||||
<NewWorkspaceSourceDrawer
|
||||
visible={
|
||||
visible &&
|
||||
modalPresentation.visibleLayer === 'source' &&
|
||||
selectedRepo != null &&
|
||||
workspaceSource.drawerVisible
|
||||
}
|
||||
client={client}
|
||||
repoId={selectedRepo?.id ?? ''}
|
||||
availability={workspaceSource.availability}
|
||||
sshStateGeneration={workspaceSource.sshStateGeneration}
|
||||
name={workspaceSource.name}
|
||||
worktreeBranches={selectedRepoWorktreeBranches}
|
||||
onSelect={(source) => {
|
||||
workspaceSource.selectSource(source)
|
||||
modalPresentation.closeLayer('source')
|
||||
}}
|
||||
onClose={() => closeSelectionLayer('source')}
|
||||
onHidden={() => modalPresentation.handleLayerHidden('source')}
|
||||
onOpen={workspaceSource.refreshLinearStatus}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && showRepoPicker}
|
||||
visible={visible && modalPresentation.visibleLayer === 'repository' && showRepoPicker}
|
||||
title="Repository"
|
||||
items={repoPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => setSelectedRepo(item.repo)}
|
||||
onClose={() => setShowRepoPicker(false)}
|
||||
onClose={() => closeSelectionLayer('repository')}
|
||||
onHidden={() => modalPresentation.handleLayerHidden('repository')}
|
||||
renderIcon={(item) => {
|
||||
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && showAgentPicker}
|
||||
visible={visible && modalPresentation.visibleLayer === 'agent' && showAgentPicker}
|
||||
title="Agent"
|
||||
items={pickerAgentOptions}
|
||||
selectedId={selectedAgent.id}
|
||||
@@ -926,13 +945,17 @@ function NewWorktreeModalContent({
|
||||
setAgentOverridden(true)
|
||||
setSelectedAgent(agent)
|
||||
}}
|
||||
onClose={() => setShowAgentPicker(false)}
|
||||
onClose={() => closeSelectionLayer('agent')}
|
||||
onHidden={() => modalPresentation.handleLayerHidden('agent')}
|
||||
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
|
||||
/>
|
||||
|
||||
<BottomDrawer
|
||||
visible={visible && setupTrustPrompt != null}
|
||||
onClose={() => setSetupTrustPrompt(null)}
|
||||
visible={
|
||||
visible && modalPresentation.visibleLayer === 'setup-trust' && setupTrustPrompt != null
|
||||
}
|
||||
onClose={closeSetupTrustPrompt}
|
||||
onHidden={() => modalPresentation.handleLayerHidden('setup-trust')}
|
||||
>
|
||||
{setupTrustPrompt ? (
|
||||
<View>
|
||||
@@ -968,7 +991,7 @@ function NewWorktreeModalContent({
|
||||
false
|
||||
)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
closeSetupTrustPrompt()
|
||||
await handleCreate({
|
||||
setupOverride: 'run',
|
||||
approvedSetupContentHash: approvedHash
|
||||
@@ -995,7 +1018,7 @@ function NewWorktreeModalContent({
|
||||
true
|
||||
)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
closeSetupTrustPrompt()
|
||||
await handleCreate({
|
||||
setupOverride: 'run',
|
||||
approvedSetupContentHash: approvedHash
|
||||
@@ -1014,7 +1037,7 @@ function NewWorktreeModalContent({
|
||||
style={styles.trustActionRow}
|
||||
disabled={creating}
|
||||
onPress={() => {
|
||||
setSetupTrustPrompt(null)
|
||||
closeSetupTrustPrompt()
|
||||
void handleCreate({ setupOverride: 'skip' })
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,8 @@ type Props = {
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktreeBranches?: readonly { repoId: string; branch: string }[]
|
||||
hostCapabilities?: readonly string[] | null
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
onRouteVisibleChange: (visible: boolean) => void
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
@@ -24,6 +26,8 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktreeBranches,
|
||||
hostCapabilities,
|
||||
onVisibleChange,
|
||||
onRouteVisibleChange,
|
||||
onCreated
|
||||
@@ -58,6 +62,8 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktreeBranches={existingWorktreeBranches}
|
||||
hostCapabilities={hostCapabilities}
|
||||
onCreated={onCreated}
|
||||
onClose={close}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ type Props<T extends { id: string; label: string }> = {
|
||||
selectedId: string
|
||||
onSelect: (item: T) => void
|
||||
onClose: () => void
|
||||
onHidden?: () => void
|
||||
renderIcon?: (item: T) => ReactNode
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onHidden,
|
||||
renderIcon
|
||||
}: Props<T>) {
|
||||
const [closing, setClosing] = useState(false)
|
||||
@@ -64,6 +66,7 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
|
||||
<BottomDrawer
|
||||
visible={drawerVisible}
|
||||
onClose={finishClose}
|
||||
onHidden={onHidden}
|
||||
dragContentToDismiss={false}
|
||||
contentScrollable={false}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
NewWorkspaceSourceFilter,
|
||||
NewWorkspaceSourceRow
|
||||
} from '../workspace-source/new-workspace-source-types'
|
||||
|
||||
export function workspaceSourceFilterLabel(filter: NewWorkspaceSourceFilter): string {
|
||||
return filter === 'all'
|
||||
? 'All'
|
||||
: filter === 'github'
|
||||
? 'GitHub'
|
||||
: filter === 'branches'
|
||||
? 'Branches'
|
||||
: 'Linear'
|
||||
}
|
||||
|
||||
export function workspaceSourceTitle(row: NewWorkspaceSourceRow): string {
|
||||
return row.kind === 'github'
|
||||
? row.item.title
|
||||
: row.kind === 'linear'
|
||||
? row.issue.title
|
||||
: row.refName
|
||||
}
|
||||
|
||||
export function workspaceSourceSubtitle(row: NewWorkspaceSourceRow): string {
|
||||
return row.kind === 'github'
|
||||
? `${row.item.type === 'pr' ? 'PR' : 'Issue'} #${row.item.number}`
|
||||
: row.kind === 'linear'
|
||||
? row.issue.identifier
|
||||
: row.verified
|
||||
? 'Branch or ref'
|
||||
: 'Branch or ref · reuse unavailable'
|
||||
}
|
||||
|
||||
export function workspaceSourceAccessibilityLabel(row: NewWorkspaceSourceRow): string {
|
||||
return `${workspaceSourceSubtitle(row)}, ${workspaceSourceTitle(row)}`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAvailableWorkspaceSourceFilters } from '../workspace-source/workspace-source-availability'
|
||||
|
||||
describe('new workspace source drawer filters', () => {
|
||||
it('shows only source kinds supported by the selected repository state', () => {
|
||||
expect(
|
||||
getAvailableWorkspaceSourceFilters({ github: false, branches: false, linear: true })
|
||||
).toEqual(['all', 'linear'])
|
||||
expect(
|
||||
getAvailableWorkspaceSourceFilters({ github: true, branches: true, linear: false })
|
||||
).toEqual(['all', 'github', 'branches'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export type NewWorktreeModalLayer = 'form' | 'source' | 'repository' | 'agent' | 'setup-trust'
|
||||
|
||||
type PresentationState = {
|
||||
visible: NewWorktreeModalLayer | null
|
||||
exiting: NewWorktreeModalLayer | null
|
||||
next: NewWorktreeModalLayer | null
|
||||
}
|
||||
|
||||
const INITIAL_PRESENTATION: PresentationState = {
|
||||
visible: 'form',
|
||||
exiting: null,
|
||||
next: null
|
||||
}
|
||||
|
||||
export function useNewWorktreeModalPresentation() {
|
||||
const [state, setState] = useState(INITIAL_PRESENTATION)
|
||||
|
||||
const openLayer = useCallback((layer: Exclude<NewWorktreeModalLayer, 'form'>) => {
|
||||
setState((current) =>
|
||||
current.visible === 'form' && current.exiting === null
|
||||
? { visible: null, exiting: 'form', next: layer }
|
||||
: current
|
||||
)
|
||||
}, [])
|
||||
|
||||
const closeLayer = useCallback((layer: Exclude<NewWorktreeModalLayer, 'form'>) => {
|
||||
setState((current) =>
|
||||
current.visible === layer && current.exiting === null
|
||||
? { visible: null, exiting: layer, next: 'form' }
|
||||
: current
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleLayerHidden = useCallback((layer: NewWorktreeModalLayer) => {
|
||||
setState((current) => {
|
||||
if (current.exiting === layer) {
|
||||
return { visible: current.next, exiting: null, next: null }
|
||||
}
|
||||
return current.visible === layer && layer !== 'form'
|
||||
? { visible: 'form', exiting: null, next: null }
|
||||
: current
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
visibleLayer: state.visible,
|
||||
openLayer,
|
||||
closeLayer,
|
||||
handleLayerHidden
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
export function useBottomDrawerNativeVisibility(visible: boolean, onHidden: () => void) {
|
||||
const [nativeVisible, setNativeVisible] = useState(true)
|
||||
const nativeHideRequestedRef = useRef(false)
|
||||
const hiddenReportedRef = useRef(false)
|
||||
|
||||
const reportHidden = useCallback(() => {
|
||||
if (hiddenReportedRef.current) {
|
||||
return
|
||||
}
|
||||
hiddenReportedRef.current = true
|
||||
onHidden()
|
||||
}, [onHidden])
|
||||
|
||||
const hideNativeModal = useCallback(() => {
|
||||
if (nativeHideRequestedRef.current) {
|
||||
return
|
||||
}
|
||||
nativeHideRequestedRef.current = true
|
||||
setNativeVisible(false)
|
||||
if (Platform.OS !== 'ios') {
|
||||
reportHidden()
|
||||
}
|
||||
}, [reportHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
nativeHideRequestedRef.current = false
|
||||
hiddenReportedRef.current = false
|
||||
setNativeVisible(true)
|
||||
}, [visible])
|
||||
|
||||
return { nativeVisible, hideNativeModal, reportHidden }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveComposerBranchSelection } from './mobile-composer-branch-selection'
|
||||
|
||||
describe('resolveComposerBranchSelection', () => {
|
||||
it('preserves a manual Tasks workspace name that prefixes the selected branch', () => {
|
||||
expect(
|
||||
resolveComposerBranchSelection({
|
||||
refName: 'feature/mobile',
|
||||
localBranchName: 'feature/mobile',
|
||||
currentName: 'feat',
|
||||
lastAutoName: ''
|
||||
})
|
||||
).toEqual({
|
||||
baseBranch: 'feature/mobile',
|
||||
branchNameOverride: undefined,
|
||||
branchAutoName: '',
|
||||
name: undefined,
|
||||
lastAutoName: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ export function resolveComposerBranchSelection(args: {
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
}): ComposerBranchSelection {
|
||||
// Why: the existing Tasks composer treats any non-auto name as user-owned,
|
||||
// even when it happens to prefix the selected branch.
|
||||
const shouldAutoName = !args.currentName.trim() || args.currentName === args.lastAutoName
|
||||
if (!shouldAutoName) {
|
||||
return {
|
||||
|
||||
@@ -1,38 +1,4 @@
|
||||
export function resolveMobileWorkspaceCreateName(args: {
|
||||
draft: string | undefined
|
||||
fallback: string
|
||||
}): string {
|
||||
return args.draft?.trim() || args.fallback
|
||||
}
|
||||
|
||||
// Why: mirrors the desktop issue-name slugger so contractions do not create
|
||||
// branch/path names like `can-t-enable` in mobile-created workspaces.
|
||||
function removeIntraWordApostrophes(input: string): string {
|
||||
return input.replace(/[‘’]/g, "'").replace(/([\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1')
|
||||
}
|
||||
|
||||
function slugifyForWorkspaceName(input: string): string {
|
||||
return removeIntraWordApostrophes(input)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[.-]+|[.-]+$/g, '')
|
||||
.slice(0, 48)
|
||||
.replace(/[-._]+$/g, '')
|
||||
}
|
||||
|
||||
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
|
||||
const withoutLeadingNumber = item.title
|
||||
.trim()
|
||||
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
|
||||
.replace(/^#\d+\s*[:-]\s*/, '')
|
||||
.replace(/\(#\d+\)/gi, '')
|
||||
.replace(/\b#\d+\b/g, '')
|
||||
.trim()
|
||||
const seed = withoutLeadingNumber || item.title.trim()
|
||||
return slugifyForWorkspaceName(seed)
|
||||
}
|
||||
export {
|
||||
getLinkedWorkItemSuggestedName,
|
||||
resolveWorkspaceCreateName as resolveMobileWorkspaceCreateName
|
||||
} from '../../../src/shared/workspace-name'
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { GitHubPrStartPoint, GitHubWorkItem, LinearIssue } from '../../../src/shared/types'
|
||||
|
||||
export type NewWorkspaceSourceFilter = 'all' | 'github' | 'branches' | 'linear'
|
||||
|
||||
export type NewWorkspaceSourceRow =
|
||||
| { kind: 'github'; key: string; item: GitHubWorkItem }
|
||||
| {
|
||||
kind: 'branch'
|
||||
key: string
|
||||
refName: string
|
||||
localBranchName: string
|
||||
verified: boolean
|
||||
}
|
||||
| { kind: 'linear'; key: string; issue: LinearIssue }
|
||||
|
||||
export type ResolvedNewWorkspaceSource =
|
||||
| {
|
||||
kind: 'github'
|
||||
item: GitHubWorkItem
|
||||
suggestedName: string
|
||||
displayName: string
|
||||
prStartPoint?: GitHubPrStartPoint
|
||||
forkWarning: string | null
|
||||
}
|
||||
| {
|
||||
kind: 'branch'
|
||||
refName: string
|
||||
localBranchName: string
|
||||
verified: boolean
|
||||
branchAutoName: string
|
||||
branchNameOverride?: string
|
||||
reuseEligibleBranch: string | null
|
||||
reuseEnabled: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'linear'
|
||||
issue: LinearIssue
|
||||
suggestedName: string
|
||||
displayName: string
|
||||
organizationUrlKey: string | null
|
||||
}
|
||||
|
||||
export type WorkspaceSourceAvailability = {
|
||||
github: boolean
|
||||
branches: boolean
|
||||
linear: boolean
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useLiveWorkspaceSshState } from './use-live-workspace-ssh-state'
|
||||
|
||||
type HookState = ReturnType<typeof useLiveWorkspaceSshState>
|
||||
type EventListener = (payload: unknown) => void
|
||||
|
||||
let current: HookState | null = null
|
||||
let eventListener: EventListener | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const unsubscribe = vi.fn()
|
||||
const sendRequest = vi.fn()
|
||||
const subscribe = vi.fn(
|
||||
(_method: string, _params: unknown, listener: EventListener): (() => void) => {
|
||||
eventListener = listener
|
||||
return unsubscribe
|
||||
}
|
||||
)
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
|
||||
function sshState(status: SshConnectionState['status'], targetId = 'ssh-1'): SshConnectionState {
|
||||
return { targetId, status, error: null, reconnectAttempt: 0 }
|
||||
}
|
||||
|
||||
function response(status: SshConnectionState['status'], targetId = 'ssh-1') {
|
||||
return {
|
||||
id: `ssh-state-${status}`,
|
||||
ok: true as const,
|
||||
result: { state: sshState(status, targetId) },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
|
||||
function Harness({ targetId = 'ssh-1' }: { targetId?: string }): null {
|
||||
current = useLiveWorkspaceSshState({ visible: true, client, targetId })
|
||||
return null
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function mountHook(targetId = 'ssh-1'): Promise<void> {
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { targetId }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
}
|
||||
|
||||
async function emit(payload: unknown): Promise<void> {
|
||||
await act(async () => {
|
||||
eventListener?.(payload)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('useLiveWorkspaceSshState stream lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
current = null
|
||||
eventListener = null
|
||||
sendRequest.mockReset()
|
||||
sendRequest.mockResolvedValue(response('connected'))
|
||||
subscribe.mockClear()
|
||||
unsubscribe.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('fails closed only on a ready replay and restores fresh SSH state', async () => {
|
||||
let resolveRefresh: ((value: ReturnType<typeof response>) => void) | null = null
|
||||
sendRequest.mockResolvedValueOnce(response('connected')).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
)
|
||||
await mountHook()
|
||||
expect(current?.state?.status).toBe('connected')
|
||||
|
||||
await emit({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
expect(current?.state?.status).toBe('connected')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emit({ type: 'ready', subscriptionId: 'runtime-events-2' })
|
||||
expect(current?.state).toBeNull()
|
||||
expect(current?.generation).toBe(1)
|
||||
|
||||
act(() => resolveRefresh?.(response('disconnected')))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'ssh.getState', { targetId: 'ssh-1' })
|
||||
expect(current?.state?.status).toBe('disconnected')
|
||||
expect(current?.generation).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores connect completion after the selected target changes', async () => {
|
||||
let resolveConnect: ((value: ReturnType<typeof response>) => void) | null = null
|
||||
sendRequest.mockImplementation((method: string, params: { targetId: string }) => {
|
||||
if (method === 'ssh.connect') {
|
||||
return new Promise((resolve) => {
|
||||
resolveConnect = resolve
|
||||
})
|
||||
}
|
||||
return Promise.resolve(response('disconnected', params.targetId))
|
||||
})
|
||||
await mountHook()
|
||||
|
||||
await act(async () => {
|
||||
void current?.connect()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(current?.connecting).toBe(true)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { targetId: 'ssh-2' }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(current?.state).toEqual(sshState('disconnected', 'ssh-2'))
|
||||
const generationAfterSwitch = current?.generation
|
||||
|
||||
act(() => resolveConnect?.(response('connected', 'ssh-1')))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(current?.state).toEqual(sshState('disconnected', 'ssh-2'))
|
||||
expect(current?.connecting).toBe(false)
|
||||
expect(current?.generation).toBe(generationAfterSwitch)
|
||||
})
|
||||
|
||||
it('keeps a completed connect result when an earlier state read resolves late', async () => {
|
||||
let resolveRead: ((value: ReturnType<typeof response>) => void) | null = null
|
||||
sendRequest.mockImplementation((method: string) => {
|
||||
if (method === 'ssh.getState') {
|
||||
return new Promise((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
}
|
||||
return Promise.resolve(response('connected'))
|
||||
})
|
||||
await mountHook()
|
||||
|
||||
await act(async () => {
|
||||
await current?.connect()
|
||||
})
|
||||
expect(current?.state).toEqual(sshState('connected'))
|
||||
|
||||
act(() => resolveRead?.(response('disconnected')))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(current?.state).toEqual(sshState('connected'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
|
||||
function fallbackState(
|
||||
targetId: string,
|
||||
status: SshConnectionState['status'],
|
||||
error: string | null
|
||||
) {
|
||||
return { targetId, status, error, reconnectAttempt: 0 } satisfies SshConnectionState
|
||||
}
|
||||
|
||||
async function readSshState(client: RpcClient, targetId: string): Promise<SshConnectionState> {
|
||||
const response = await client.sendRequest('ssh.getState', { targetId })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
return result.state ?? fallbackState(targetId, 'disconnected', null)
|
||||
}
|
||||
|
||||
export function useLiveWorkspaceSshState(args: {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
targetId: string | null
|
||||
}) {
|
||||
const [state, setState] = useState<SshConnectionState | null>(null)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [generation, setGeneration] = useState(0)
|
||||
// Why: reads, stream events, and connects can race to publish the same target's state.
|
||||
const stateEpochRef = useRef(0)
|
||||
const connectOperationEpochRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
connectOperationEpochRef.current += 1
|
||||
setConnecting(false)
|
||||
if (!args.visible || !args.client || !args.targetId) {
|
||||
setState(null)
|
||||
return
|
||||
}
|
||||
const client = args.client
|
||||
const targetId = args.targetId
|
||||
let stale = false
|
||||
let eventStreamReady = false
|
||||
const refreshState = (invalidate: boolean): void => {
|
||||
const stateEpoch = ++stateEpochRef.current
|
||||
void readSshState(client, targetId)
|
||||
.then((nextState) => {
|
||||
if (!stale && stateEpoch === stateEpochRef.current) {
|
||||
setState(nextState)
|
||||
if (invalidate) {
|
||||
setGeneration((value) => value + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!stale && stateEpoch === stateEpochRef.current) {
|
||||
setState(
|
||||
fallbackState(
|
||||
targetId,
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Failed to read SSH connection state.'
|
||||
)
|
||||
)
|
||||
if (invalidate) {
|
||||
setGeneration((value) => value + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, (payload) => {
|
||||
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
|
||||
if (event.type === 'ready') {
|
||||
const replayedAfterReconnect = eventStreamReady
|
||||
eventStreamReady = true
|
||||
if (replayedAfterReconnect) {
|
||||
// Why: events missed while offline make the last connected state unsafe
|
||||
// until a fresh host read closes the replay gap.
|
||||
setState(null)
|
||||
setGeneration((value) => value + 1)
|
||||
refreshState(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'sshStateChanged' && event.targetId === targetId && event.state) {
|
||||
stateEpochRef.current += 1
|
||||
setState(event.state)
|
||||
setGeneration((value) => value + 1)
|
||||
}
|
||||
})
|
||||
refreshState(false)
|
||||
return () => {
|
||||
stale = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [args.client, args.targetId, args.visible])
|
||||
|
||||
const connect = useCallback(async (): Promise<void> => {
|
||||
if (!args.client || !args.targetId) {
|
||||
return
|
||||
}
|
||||
const targetId = args.targetId
|
||||
const operationEpoch = ++connectOperationEpochRef.current
|
||||
const stateEpoch = ++stateEpochRef.current
|
||||
setConnecting(true)
|
||||
setState(fallbackState(targetId, 'connecting', null))
|
||||
setGeneration((value) => value + 1)
|
||||
try {
|
||||
const response = await args.client.sendRequest(
|
||||
'ssh.connect',
|
||||
{ targetId },
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
if (
|
||||
operationEpoch === connectOperationEpochRef.current &&
|
||||
stateEpoch === stateEpochRef.current
|
||||
) {
|
||||
setState(result.state ?? fallbackState(targetId, 'connected', null))
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
operationEpoch === connectOperationEpochRef.current &&
|
||||
stateEpoch === stateEpochRef.current
|
||||
) {
|
||||
setState(
|
||||
fallbackState(
|
||||
targetId,
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Failed to connect to SSH repository.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Why: switching repositories transfers ownership of connection state;
|
||||
// the previous target's completion cannot publish into the new target.
|
||||
if (operationEpoch === connectOperationEpochRef.current) {
|
||||
setGeneration((value) => value + 1)
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
}, [args.client, args.targetId])
|
||||
|
||||
return { state, connecting, generation, connect }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ResolvedNewWorkspaceSource } from './new-workspace-source-types'
|
||||
import {
|
||||
useNewWorkspaceSource,
|
||||
type NewWorkspaceSourceController
|
||||
} from './use-new-workspace-source'
|
||||
|
||||
type HookArgs = Parameters<typeof useNewWorkspaceSource>[0]
|
||||
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockResolvedValue({
|
||||
id: 'linear-status',
|
||||
ok: true,
|
||||
result: { connected: false },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const capabilities = [MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY]
|
||||
let controller: NewWorkspaceSourceController | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(props: HookArgs): null {
|
||||
controller = useNewWorkspaceSource(props)
|
||||
return null
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function hookArgs(overrides: Partial<HookArgs> = {}): HookArgs {
|
||||
return {
|
||||
visible: true,
|
||||
client,
|
||||
capabilities,
|
||||
repo: { id: 'repo-1', displayName: 'App', kind: 'git' },
|
||||
repoConnected: true,
|
||||
sshStateGeneration: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function mountHook(): Promise<void> {
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, hookArgs()))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
}
|
||||
|
||||
function githubSource(): ResolvedNewWorkspaceSource {
|
||||
return {
|
||||
kind: 'github',
|
||||
item: { type: 'issue', number: 7, title: 'Fix mobile source' } as never,
|
||||
suggestedName: 'fix-mobile-source',
|
||||
displayName: 'Issue 7 · Fix mobile source',
|
||||
forkWarning: null
|
||||
}
|
||||
}
|
||||
|
||||
function branchSource(): ResolvedNewWorkspaceSource {
|
||||
return {
|
||||
kind: 'branch',
|
||||
refName: 'feature/mobile',
|
||||
localBranchName: 'feature/mobile',
|
||||
verified: true,
|
||||
branchAutoName: 'feature/mobile',
|
||||
branchNameOverride: 'feature/mobile',
|
||||
reuseEligibleBranch: 'feature/mobile',
|
||||
reuseEnabled: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNewWorkspaceSource availability lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
controller = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('drops a capability-gated source and clears its source-owned name', async () => {
|
||||
await mountHook()
|
||||
act(() => controller?.selectSource(githubSource()))
|
||||
expect(controller?.name).toEqual({ value: 'fix-mobile-source', owner: 'source' })
|
||||
|
||||
act(() => renderer?.update(createElement(Harness, hookArgs({ capabilities: [] }))))
|
||||
|
||||
expect(controller?.source).toBeNull()
|
||||
expect(controller?.name).toEqual({ value: '', owner: 'blank' })
|
||||
})
|
||||
|
||||
it('preserves a manual name when the selected kind becomes unavailable', async () => {
|
||||
await mountHook()
|
||||
act(() => {
|
||||
controller?.selectSource(branchSource())
|
||||
controller?.setManualName('my workspace')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(
|
||||
Harness,
|
||||
hookArgs({ repo: { id: 'repo-1', displayName: 'App', kind: 'folder' } })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(controller?.source).toBeNull()
|
||||
expect(controller?.name).toEqual({ value: 'my workspace', owner: 'user' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
ResolvedNewWorkspaceSource,
|
||||
WorkspaceSourceAvailability
|
||||
} from './new-workspace-source-types'
|
||||
import {
|
||||
getWorkspaceSourceAvailability,
|
||||
hasWorkspaceSourceAvailability
|
||||
} from './workspace-source-availability'
|
||||
import {
|
||||
applyManualWorkspaceName,
|
||||
applySourceWorkspaceName,
|
||||
clearSourceWorkspaceName,
|
||||
createWorkspaceNameState,
|
||||
type WorkspaceNameState
|
||||
} from './workspace-source-name-state'
|
||||
|
||||
type SourceRepo = {
|
||||
id: string
|
||||
displayName: string
|
||||
kind?: 'git' | 'folder'
|
||||
}
|
||||
|
||||
export type NewWorkspaceSourceController = {
|
||||
name: WorkspaceNameState
|
||||
source: ResolvedNewWorkspaceSource | null
|
||||
availability: WorkspaceSourceAvailability
|
||||
sshStateGeneration: number
|
||||
fieldVisible: boolean
|
||||
drawerVisible: boolean
|
||||
setDrawerVisible: (visible: boolean) => void
|
||||
setManualName: (value: string) => void
|
||||
selectSource: (source: ResolvedNewWorkspaceSource) => void
|
||||
clearSource: () => void
|
||||
setReuseEnabled: (enabled: boolean) => void
|
||||
refreshLinearStatus: () => void
|
||||
}
|
||||
|
||||
export function useNewWorkspaceSource(args: {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
capabilities: readonly string[] | null | undefined
|
||||
repo: SourceRepo | null
|
||||
repoConnected: boolean
|
||||
sshStateGeneration: number
|
||||
}): NewWorkspaceSourceController {
|
||||
const [name, setName] = useState(() => createWorkspaceNameState())
|
||||
const [source, setSource] = useState<ResolvedNewWorkspaceSource | null>(null)
|
||||
const [linearConnected, setLinearConnected] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const linearGenerationRef = useRef(0)
|
||||
const previousRepoIdRef = useRef<string | null>(null)
|
||||
const previousSshGenerationRef = useRef(args.sshStateGeneration)
|
||||
|
||||
const refreshLinearStatus = useCallback(() => {
|
||||
const generation = ++linearGenerationRef.current
|
||||
if (!args.visible || !args.client) {
|
||||
setLinearConnected(false)
|
||||
return
|
||||
}
|
||||
void args.client
|
||||
.sendRequest('linear.status')
|
||||
.then((response) => {
|
||||
if (generation !== linearGenerationRef.current) {
|
||||
return
|
||||
}
|
||||
const result = response.ok
|
||||
? ((response as RpcSuccess).result as { connected?: unknown })
|
||||
: null
|
||||
setLinearConnected(result?.connected === true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation === linearGenerationRef.current) {
|
||||
setLinearConnected(false)
|
||||
}
|
||||
})
|
||||
}, [args.client, args.visible])
|
||||
|
||||
useEffect(() => {
|
||||
refreshLinearStatus()
|
||||
}, [refreshLinearStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const previousRepoId = previousRepoIdRef.current
|
||||
previousRepoIdRef.current = args.repo?.id ?? null
|
||||
if (!previousRepoId || previousRepoId === args.repo?.id) {
|
||||
return
|
||||
}
|
||||
if (source && source.kind !== 'linear') {
|
||||
setName(clearSourceWorkspaceName)
|
||||
setSource(null)
|
||||
}
|
||||
setDrawerVisible(false)
|
||||
}, [args.repo?.id, source])
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSshGenerationRef.current === args.sshStateGeneration) {
|
||||
return
|
||||
}
|
||||
previousSshGenerationRef.current = args.sshStateGeneration
|
||||
if (source && source.kind !== 'linear') {
|
||||
setName(clearSourceWorkspaceName)
|
||||
setSource(null)
|
||||
}
|
||||
}, [args.sshStateGeneration, source])
|
||||
|
||||
const availability = useMemo(
|
||||
() =>
|
||||
getWorkspaceSourceAvailability({
|
||||
capabilities: args.capabilities,
|
||||
repoKind: args.repo?.kind,
|
||||
repoConnected: args.repoConnected,
|
||||
linearConnected
|
||||
}),
|
||||
[args.capabilities, args.repo?.kind, args.repoConnected, linearConnected]
|
||||
)
|
||||
const effectiveSource =
|
||||
source &&
|
||||
(source.kind === 'github'
|
||||
? availability.github
|
||||
: source.kind === 'branch'
|
||||
? availability.branches
|
||||
: availability.linear)
|
||||
? source
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!source || effectiveSource) {
|
||||
return
|
||||
}
|
||||
// Why: a hidden source must not leak into Create, while a manual name remains user-owned.
|
||||
setName(clearSourceWorkspaceName)
|
||||
setSource(null)
|
||||
setDrawerVisible(false)
|
||||
}, [effectiveSource, source])
|
||||
|
||||
const selectSource = useCallback((next: ResolvedNewWorkspaceSource) => {
|
||||
setSource(next)
|
||||
setName((current) => {
|
||||
const suggestion =
|
||||
next.kind === 'github' || next.kind === 'linear' ? next.suggestedName : next.branchAutoName
|
||||
return applySourceWorkspaceName(current, suggestion).state
|
||||
})
|
||||
setDrawerVisible(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
name,
|
||||
source: effectiveSource,
|
||||
availability,
|
||||
sshStateGeneration: args.sshStateGeneration,
|
||||
fieldVisible: Boolean(args.repo) && hasWorkspaceSourceAvailability(availability),
|
||||
drawerVisible,
|
||||
setDrawerVisible,
|
||||
setManualName: (value) => setName((current) => applyManualWorkspaceName(current, value)),
|
||||
selectSource,
|
||||
clearSource: () => {
|
||||
setSource(null)
|
||||
setName(clearSourceWorkspaceName)
|
||||
},
|
||||
setReuseEnabled: (enabled) =>
|
||||
setSource((current) =>
|
||||
current?.kind === 'branch' && current.reuseEligibleBranch
|
||||
? { ...current, reuseEnabled: enabled }
|
||||
: current
|
||||
),
|
||||
refreshLinearStatus
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import {
|
||||
getWorkspaceSourceAvailability,
|
||||
supportsMobileWorkspaceSourceSelector
|
||||
} from './workspace-source-availability'
|
||||
|
||||
describe('workspace source capability gating', () => {
|
||||
it('fails closed while status is loading and on older hosts', () => {
|
||||
expect(supportsMobileWorkspaceSourceSelector(null)).toBe(false)
|
||||
expect(supportsMobileWorkspaceSourceSelector([])).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Linear available for folder and disconnected SSH repositories', () => {
|
||||
const capabilities = [MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY]
|
||||
expect(
|
||||
getWorkspaceSourceAvailability({
|
||||
capabilities,
|
||||
repoKind: 'folder',
|
||||
repoConnected: true,
|
||||
linearConnected: true
|
||||
})
|
||||
).toEqual({ github: false, branches: false, linear: true })
|
||||
expect(
|
||||
getWorkspaceSourceAvailability({
|
||||
capabilities,
|
||||
repoKind: 'git',
|
||||
repoConnected: false,
|
||||
linearConnected: true
|
||||
})
|
||||
).toEqual({ github: false, branches: false, linear: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import type {
|
||||
NewWorkspaceSourceFilter,
|
||||
WorkspaceSourceAvailability
|
||||
} from './new-workspace-source-types'
|
||||
|
||||
export function supportsMobileWorkspaceSourceSelector(
|
||||
capabilities: readonly string[] | null | undefined
|
||||
): boolean {
|
||||
return capabilities?.includes(MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
|
||||
export function getWorkspaceSourceAvailability(args: {
|
||||
capabilities: readonly string[] | null | undefined
|
||||
repoKind?: 'git' | 'folder'
|
||||
repoConnected: boolean
|
||||
linearConnected: boolean
|
||||
}): WorkspaceSourceAvailability {
|
||||
if (!supportsMobileWorkspaceSourceSelector(args.capabilities)) {
|
||||
return { github: false, branches: false, linear: false }
|
||||
}
|
||||
const repoScoped = args.repoKind !== 'folder' && args.repoConnected
|
||||
return {
|
||||
github: repoScoped,
|
||||
branches: repoScoped,
|
||||
linear: args.linearConnected
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWorkspaceSourceAvailability(availability: WorkspaceSourceAvailability): boolean {
|
||||
return availability.github || availability.branches || availability.linear
|
||||
}
|
||||
|
||||
export function getAvailableWorkspaceSourceFilters(
|
||||
availability: WorkspaceSourceAvailability
|
||||
): NewWorkspaceSourceFilter[] {
|
||||
return [
|
||||
'all',
|
||||
...(availability.github ? (['github'] as const) : []),
|
||||
...(availability.branches ? (['branches'] as const) : []),
|
||||
...(availability.linear ? (['linear'] as const) : [])
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceSourceCreateCandidate } from './workspace-source-create-candidate'
|
||||
|
||||
describe('workspace source create candidates', () => {
|
||||
it('suffixes fresh workspace and PR branch overrides together', () => {
|
||||
expect(
|
||||
buildWorkspaceSourceCreateCandidate({
|
||||
baseParams: { setupDecision: 'inherit' },
|
||||
baseName: 'review-pr',
|
||||
name: { value: 'review-pr', owner: 'source' },
|
||||
selectedRepoId: 'repo',
|
||||
attempt: 1,
|
||||
source: {
|
||||
kind: 'github',
|
||||
item: { type: 'pr', number: 2 } as never,
|
||||
suggestedName: 'review-pr',
|
||||
displayName: 'Review PR 2',
|
||||
forkWarning: null,
|
||||
prStartPoint: { baseBranch: 'sha', branchNameOverride: 'feature/review' }
|
||||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
name: 'review-pr-2',
|
||||
linkedPR: 2,
|
||||
branchNameOverride: 'feature/review-2',
|
||||
displayName: 'Review PR 2'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps verified exact branch reuse across name edits and retries', () => {
|
||||
expect(
|
||||
buildWorkspaceSourceCreateCandidate({
|
||||
baseParams: {},
|
||||
baseName: 'my-name',
|
||||
name: { value: 'my-name', owner: 'user' },
|
||||
selectedRepoId: 'repo',
|
||||
attempt: 2,
|
||||
source: {
|
||||
kind: 'branch',
|
||||
refName: 'feature/exact',
|
||||
localBranchName: 'feature/exact',
|
||||
verified: true,
|
||||
branchAutoName: 'feature/exact',
|
||||
branchNameOverride: 'feature/exact',
|
||||
reuseEligibleBranch: 'feature/exact',
|
||||
reuseEnabled: true
|
||||
}
|
||||
})
|
||||
).toMatchObject({ name: 'my-name-3', branchNameOverride: 'feature/exact' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { resolveComposerBranchNameOverrideForCreate } from '../../../src/shared/composer-branch-selection'
|
||||
import type { ResolvedNewWorkspaceSource } from './new-workspace-source-types'
|
||||
import type { WorkspaceNameState } from './workspace-source-name-state'
|
||||
|
||||
function suffixed(value: string, attempt: number): string {
|
||||
return attempt === 0 ? value : `${value}-${attempt + 1}`
|
||||
}
|
||||
|
||||
export function buildWorkspaceSourceCreateCandidate(args: {
|
||||
baseParams: Record<string, unknown>
|
||||
baseName: string
|
||||
name: WorkspaceNameState
|
||||
source: ResolvedNewWorkspaceSource | null
|
||||
selectedRepoId: string
|
||||
attempt: number
|
||||
}): Record<string, unknown> {
|
||||
const candidateName = suffixed(args.baseName, args.attempt)
|
||||
const source = args.source
|
||||
const params: Record<string, unknown> = {
|
||||
...args.baseParams,
|
||||
repo: `id:${args.selectedRepoId}`,
|
||||
name: candidateName
|
||||
}
|
||||
if (!source) {
|
||||
return params
|
||||
}
|
||||
|
||||
if (source.kind === 'github') {
|
||||
const startPoint = source.prStartPoint
|
||||
const branchNameOverride = startPoint?.branchNameOverride
|
||||
? suffixed(startPoint.branchNameOverride, args.attempt)
|
||||
: undefined
|
||||
return {
|
||||
...params,
|
||||
...(args.name.owner === 'source' ? { displayName: source.displayName } : {}),
|
||||
...(source.item.type === 'issue'
|
||||
? { linkedIssue: source.item.number }
|
||||
: { linkedPR: source.item.number }),
|
||||
...(startPoint
|
||||
? {
|
||||
baseBranch: startPoint.baseBranch,
|
||||
...(startPoint.compareBaseRef ? { compareBaseRef: startPoint.compareBaseRef } : {}),
|
||||
...(branchNameOverride ? { branchNameOverride } : {}),
|
||||
...(startPoint.pushTarget ? { pushTarget: startPoint.pushTarget } : {})
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
if (source.kind === 'linear') {
|
||||
return {
|
||||
...params,
|
||||
...(args.name.owner === 'source' ? { displayName: source.displayName } : {}),
|
||||
linkedLinearIssue: source.issue.identifier,
|
||||
...(source.issue.workspaceId
|
||||
? { linkedLinearIssueWorkspaceId: source.issue.workspaceId }
|
||||
: {}),
|
||||
...(source.organizationUrlKey
|
||||
? { linkedLinearIssueOrganizationUrlKey: source.organizationUrlKey }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
const exactReuse = source.reuseEligibleBranch !== null && source.reuseEnabled
|
||||
const resolvedOverride = exactReuse
|
||||
? source.reuseEligibleBranch!
|
||||
: source.reuseEligibleBranch
|
||||
? undefined
|
||||
: resolveComposerBranchNameOverrideForCreate({
|
||||
branchNameOverride: source.branchNameOverride,
|
||||
branchAutoName: source.branchAutoName,
|
||||
workspaceName: args.name.value.trim(),
|
||||
preserveWorkspaceNameEdits: false
|
||||
})
|
||||
const branchNameOverride =
|
||||
resolvedOverride && !exactReuse ? suffixed(resolvedOverride, args.attempt) : resolvedOverride
|
||||
return {
|
||||
...params,
|
||||
baseBranch: source.refName,
|
||||
...(branchNameOverride ? { branchNameOverride } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyManualWorkspaceName,
|
||||
applySourceWorkspaceName,
|
||||
clearSourceWorkspaceName
|
||||
} from './workspace-source-name-state'
|
||||
|
||||
describe('workspace source name ownership', () => {
|
||||
it('lets source suggestions replace blank/source names but never manual edits', () => {
|
||||
const first = applySourceWorkspaceName({ value: '', owner: 'blank' }, 'issue-8').state
|
||||
expect(first).toEqual({ value: 'issue-8', owner: 'source' })
|
||||
const manual = applyManualWorkspaceName(first, 'issue-8')
|
||||
expect(manual.owner).toBe('user')
|
||||
expect(applySourceWorkspaceName(manual, 'issue-9').state).toBe(manual)
|
||||
})
|
||||
|
||||
it('clears only source-owned names', () => {
|
||||
expect(clearSourceWorkspaceName({ value: 'auto', owner: 'source' })).toEqual({
|
||||
value: '',
|
||||
owner: 'blank'
|
||||
})
|
||||
expect(clearSourceWorkspaceName({ value: 'mine', owner: 'user' })).toEqual({
|
||||
value: 'mine',
|
||||
owner: 'user'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export type WorkspaceNameOwner = 'blank' | 'source' | 'user'
|
||||
|
||||
export type WorkspaceNameState = {
|
||||
value: string
|
||||
owner: WorkspaceNameOwner
|
||||
}
|
||||
|
||||
export function createWorkspaceNameState(value = ''): WorkspaceNameState {
|
||||
return { value, owner: value.trim() ? 'user' : 'blank' }
|
||||
}
|
||||
|
||||
export function applyManualWorkspaceName(
|
||||
_current: WorkspaceNameState,
|
||||
value: string
|
||||
): WorkspaceNameState {
|
||||
// Why: even a value equal to the current suggestion is a user decision and
|
||||
// must not be overwritten by later source changes.
|
||||
return { value, owner: 'user' }
|
||||
}
|
||||
|
||||
export function applySourceWorkspaceName(
|
||||
current: WorkspaceNameState,
|
||||
suggestedName: string
|
||||
): { state: WorkspaceNameState; sourceTookOwnership: boolean } {
|
||||
if (current.owner === 'user') {
|
||||
return { state: current, sourceTookOwnership: false }
|
||||
}
|
||||
return {
|
||||
state: { value: suggestedName, owner: suggestedName.trim() ? 'source' : 'blank' },
|
||||
sourceTookOwnership: suggestedName.trim().length > 0
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSourceWorkspaceName(current: WorkspaceNameState): WorkspaceNameState {
|
||||
return current.owner === 'source' ? { value: '', owner: 'blank' } : current
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
IncompatibleWorkspaceSourceResponseError,
|
||||
normalizeGitHubSourceResponse,
|
||||
normalizeLinearSourceResponse,
|
||||
normalizeRefSourceResponse
|
||||
} from './workspace-source-normalization'
|
||||
|
||||
const githubItem = {
|
||||
id: 'I_1',
|
||||
type: 'issue',
|
||||
number: 8,
|
||||
title: 'Fix mobile selector',
|
||||
url: 'https://github.com/acme/app/issues/8'
|
||||
}
|
||||
|
||||
describe('workspace source response normalization', () => {
|
||||
it('preserves GitHub rows beside issue-side partial errors and stamps the repo', () => {
|
||||
const result = normalizeGitHubSourceResponse(
|
||||
{
|
||||
items: [githubItem, { nope: true }],
|
||||
sources: { issues: null, prs: null },
|
||||
errors: { issues: { message: 'Issues unavailable' } }
|
||||
},
|
||||
'repo-current'
|
||||
)
|
||||
expect(result.rows).toHaveLength(1)
|
||||
expect(result.rows[0]).toMatchObject({ kind: 'github', item: { repoId: 'repo-current' } })
|
||||
expect(result.warnings).toEqual(['Issues unavailable'])
|
||||
})
|
||||
|
||||
it('rejects malformed top-level collections instead of reporting empty results', () => {
|
||||
expect(() => normalizeGitHubSourceResponse({ items: [] }, 'repo')).toThrow(
|
||||
IncompatibleWorkspaceSourceResponseError
|
||||
)
|
||||
expect(() => normalizeLinearSourceResponse({ errors: [] })).toThrow(
|
||||
IncompatibleWorkspaceSourceResponseError
|
||||
)
|
||||
expect(() => normalizeRefSourceResponse({})).toThrow(IncompatibleWorkspaceSourceResponseError)
|
||||
})
|
||||
|
||||
it('accepts current and legacy Linear collections', () => {
|
||||
const issue = {
|
||||
id: 'linear-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Ship selector',
|
||||
url: 'https://linear.app/acme/issue/ENG-1/ship-selector'
|
||||
}
|
||||
expect(normalizeLinearSourceResponse([issue]).rows).toHaveLength(1)
|
||||
expect(
|
||||
normalizeLinearSourceResponse({
|
||||
items: [issue],
|
||||
errors: [{ message: 'One workspace failed' }]
|
||||
}).warnings
|
||||
).toEqual(['One workspace failed'])
|
||||
})
|
||||
|
||||
it('marks legacy ref strings unverified so they can never imply reuse', () => {
|
||||
expect(
|
||||
normalizeRefSourceResponse({
|
||||
refDetails: [{ refName: 'main', localBranchName: 'main' }],
|
||||
refs: ['main', 'legacy']
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({ refName: 'main', verified: true }),
|
||||
expect.objectContaining({ refName: 'legacy', verified: false })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { GitHubWorkItem, LinearIssue } from '../../../src/shared/types'
|
||||
import type { NewWorkspaceSourceRow } from './new-workspace-source-types'
|
||||
|
||||
export class IncompatibleWorkspaceSourceResponseError extends Error {}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function messageFrom(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() || null
|
||||
}
|
||||
const candidate = record(value)
|
||||
return typeof candidate?.message === 'string' ? candidate.message.trim() || null : null
|
||||
}
|
||||
|
||||
function githubItem(value: unknown, repoId: string): GitHubWorkItem | null {
|
||||
const item = record(value)
|
||||
if (
|
||||
!item ||
|
||||
(item.type !== 'issue' && item.type !== 'pr') ||
|
||||
typeof item.number !== 'number' ||
|
||||
!Number.isInteger(item.number) ||
|
||||
item.number <= 0 ||
|
||||
typeof item.id !== 'string' ||
|
||||
typeof item.title !== 'string' ||
|
||||
typeof item.url !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// Why: rows are snapshots for the selected repo; never carry a repo id from
|
||||
// an old or malformed host response into later repo-scoped RPCs.
|
||||
return { ...item, repoId } as GitHubWorkItem
|
||||
}
|
||||
|
||||
function linearIssue(value: unknown): LinearIssue | null {
|
||||
const issue = record(value)
|
||||
if (
|
||||
!issue ||
|
||||
typeof issue.id !== 'string' ||
|
||||
typeof issue.identifier !== 'string' ||
|
||||
typeof issue.title !== 'string' ||
|
||||
typeof issue.url !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return issue as LinearIssue
|
||||
}
|
||||
|
||||
export function normalizeGitHubSourceResponse(
|
||||
value: unknown,
|
||||
repoId: string
|
||||
): { rows: NewWorkspaceSourceRow[]; warnings: string[] } {
|
||||
const envelope = record(value)
|
||||
if (!envelope || !Array.isArray(envelope.items) || !record(envelope.sources)) {
|
||||
throw new IncompatibleWorkspaceSourceResponseError(
|
||||
'The selected host returned incompatible GitHub results.'
|
||||
)
|
||||
}
|
||||
const rows = envelope.items.flatMap((raw) => {
|
||||
const item = githubItem(raw, repoId)
|
||||
return item
|
||||
? [{ kind: 'github' as const, key: `github-${repoId}-${item.type}-${item.number}`, item }]
|
||||
: []
|
||||
})
|
||||
const issuesError = record(envelope.errors)?.issues
|
||||
const warning = messageFrom(issuesError)
|
||||
return { rows, warnings: warning ? [warning] : [] }
|
||||
}
|
||||
|
||||
export function normalizeLinearSourceResponse(value: unknown): {
|
||||
rows: NewWorkspaceSourceRow[]
|
||||
warnings: string[]
|
||||
} {
|
||||
const envelope = Array.isArray(value) ? null : record(value)
|
||||
const items = Array.isArray(value) ? value : envelope?.items
|
||||
if (!Array.isArray(items)) {
|
||||
throw new IncompatibleWorkspaceSourceResponseError(
|
||||
'The selected host returned incompatible Linear results.'
|
||||
)
|
||||
}
|
||||
const rows = items.flatMap((raw) => {
|
||||
const issue = linearIssue(raw)
|
||||
return issue
|
||||
? [
|
||||
{
|
||||
kind: 'linear' as const,
|
||||
key: `linear-${issue.workspaceId ?? 'default'}-${issue.id}`,
|
||||
issue
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
const errors = Array.isArray(envelope?.errors) ? envelope.errors : []
|
||||
return { rows, warnings: errors.map(messageFrom).filter((item): item is string => Boolean(item)) }
|
||||
}
|
||||
|
||||
export function normalizeRefSourceResponse(value: unknown): NewWorkspaceSourceRow[] {
|
||||
const envelope = record(value)
|
||||
const hasDetails = Array.isArray(envelope?.refDetails)
|
||||
const hasLegacy = Array.isArray(envelope?.refs)
|
||||
if (!envelope || (!hasDetails && !hasLegacy)) {
|
||||
throw new IncompatibleWorkspaceSourceResponseError(
|
||||
'The selected host returned incompatible branch results.'
|
||||
)
|
||||
}
|
||||
const verified = hasDetails
|
||||
? (envelope.refDetails as unknown[]).flatMap((raw) => {
|
||||
const row = record(raw)
|
||||
return typeof row?.refName === 'string' && typeof row.localBranchName === 'string'
|
||||
? [
|
||||
{
|
||||
kind: 'branch' as const,
|
||||
key: `branch-verified-${row.refName}`,
|
||||
refName: row.refName,
|
||||
localBranchName: row.localBranchName,
|
||||
verified: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
: []
|
||||
const verifiedNames = new Set(verified.map((row) => row.refName))
|
||||
const legacy = hasLegacy
|
||||
? (envelope.refs as unknown[]).flatMap((raw) =>
|
||||
typeof raw === 'string' && raw && !verifiedNames.has(raw)
|
||||
? [
|
||||
{
|
||||
kind: 'branch' as const,
|
||||
key: `branch-legacy-${raw}`,
|
||||
refName: raw,
|
||||
localBranchName: raw,
|
||||
verified: false
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
: []
|
||||
return [...verified, ...legacy]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { searchWorkspaceSources } from './workspace-source-rpc'
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true as const, id: 'request', result }
|
||||
}
|
||||
|
||||
describe('workspace source RPC planning', () => {
|
||||
it('fans empty All out to GitHub and Linear but not refs', async () => {
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'github.listWorkItems') {
|
||||
return ok({ items: [], sources: { issues: null, prs: null } })
|
||||
}
|
||||
return ok([])
|
||||
})
|
||||
await searchWorkspaceSources({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
query: '',
|
||||
filter: 'all',
|
||||
availability: { github: true, branches: true, linear: true }
|
||||
})
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'github.listWorkItems',
|
||||
'linear.listIssues'
|
||||
])
|
||||
})
|
||||
|
||||
it('fans a settled All query out to all three bounded reads', async () => {
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'github.listWorkItems') {
|
||||
return ok({ items: [], sources: { issues: null, prs: null } })
|
||||
}
|
||||
if (method === 'repo.searchRefs') {
|
||||
return ok({ refDetails: [], refs: [] })
|
||||
}
|
||||
return ok([])
|
||||
})
|
||||
await searchWorkspaceSources({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
query: 'mobile',
|
||||
filter: 'all',
|
||||
availability: { github: true, branches: true, linear: true }
|
||||
})
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'github.listWorkItems',
|
||||
'repo.searchRefs',
|
||||
'linear.searchIssues'
|
||||
])
|
||||
for (const [, params] of sendRequest.mock.calls) {
|
||||
expect(params.limit).toBe(12)
|
||||
}
|
||||
})
|
||||
|
||||
it('names the selected host rather than the repository in GitHub errors', async () => {
|
||||
const sendRequest = vi.fn().mockRejectedValue(new Error('Authentication failed.'))
|
||||
const result = await searchWorkspaceSources({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
query: '',
|
||||
filter: 'github',
|
||||
availability: { github: true, branches: true, linear: false }
|
||||
})
|
||||
|
||||
expect(result.errors).toEqual(['Authentication failed. GitHub runs on the selected host.'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { GitHubPrStartPoint } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
NewWorkspaceSourceFilter,
|
||||
NewWorkspaceSourceRow,
|
||||
WorkspaceSourceAvailability
|
||||
} from './new-workspace-source-types'
|
||||
import {
|
||||
normalizeGitHubSourceResponse,
|
||||
normalizeLinearSourceResponse,
|
||||
normalizeRefSourceResponse
|
||||
} from './workspace-source-normalization'
|
||||
|
||||
const SOURCE_READ_TIMEOUT_MS = 30_000
|
||||
export const NEW_WORKSPACE_SOURCE_RESULT_LIMIT = 12
|
||||
|
||||
type SourceRead = { rows: NewWorkspaceSourceRow[]; warnings: string[] }
|
||||
export type WorkspaceSourceSearchResult = SourceRead & { errors: string[] }
|
||||
|
||||
function responseResult(response: Awaited<ReturnType<RpcClient['sendRequest']>>): unknown {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return (response as RpcSuccess).result
|
||||
}
|
||||
|
||||
async function readGitHub(client: RpcClient, repoId: string, query: string): Promise<SourceRead> {
|
||||
const response = await client.sendRequest(
|
||||
'github.listWorkItems',
|
||||
{ repo: `id:${repoId}`, query: query || undefined, limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
|
||||
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
|
||||
)
|
||||
return normalizeGitHubSourceResponse(responseResult(response), repoId)
|
||||
}
|
||||
|
||||
async function readLinear(client: RpcClient, query: string): Promise<SourceRead> {
|
||||
const response = query
|
||||
? await client.sendRequest(
|
||||
'linear.searchIssues',
|
||||
{ query, limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
|
||||
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
|
||||
)
|
||||
: await client.sendRequest(
|
||||
'linear.listIssues',
|
||||
{ filter: 'assigned', limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
|
||||
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
|
||||
)
|
||||
return normalizeLinearSourceResponse(responseResult(response))
|
||||
}
|
||||
|
||||
async function readBranches(client: RpcClient, repoId: string, query: string): Promise<SourceRead> {
|
||||
const response = await client.sendRequest(
|
||||
'repo.searchRefs',
|
||||
{ repo: `id:${repoId}`, query, limit: NEW_WORKSPACE_SOURCE_RESULT_LIMIT },
|
||||
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
|
||||
)
|
||||
return { rows: normalizeRefSourceResponse(responseResult(response)), warnings: [] }
|
||||
}
|
||||
|
||||
export async function searchWorkspaceSources(args: {
|
||||
client: RpcClient
|
||||
repoId: string
|
||||
query: string
|
||||
filter: NewWorkspaceSourceFilter
|
||||
availability: WorkspaceSourceAvailability
|
||||
}): Promise<WorkspaceSourceSearchResult> {
|
||||
const query = args.query.trim()
|
||||
const reads: Array<{ kind: 'github' | 'branches' | 'linear'; read: Promise<SourceRead> }> = []
|
||||
if ((args.filter === 'all' || args.filter === 'github') && args.availability.github) {
|
||||
reads.push({ kind: 'github', read: readGitHub(args.client, args.repoId, query) })
|
||||
}
|
||||
if (
|
||||
(args.filter === 'all' || args.filter === 'branches') &&
|
||||
args.availability.branches &&
|
||||
(query.length > 0 || args.filter === 'branches')
|
||||
) {
|
||||
reads.push({ kind: 'branches', read: readBranches(args.client, args.repoId, query) })
|
||||
}
|
||||
if ((args.filter === 'all' || args.filter === 'linear') && args.availability.linear) {
|
||||
reads.push({ kind: 'linear', read: readLinear(args.client, query) })
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(reads.map((entry) => entry.read))
|
||||
const rows: NewWorkspaceSourceRow[] = []
|
||||
const warnings: string[] = []
|
||||
const errors: string[] = []
|
||||
for (const [index, result] of settled.entries()) {
|
||||
if (result.status === 'fulfilled') {
|
||||
rows.push(...result.value.rows)
|
||||
warnings.push(...result.value.warnings)
|
||||
} else {
|
||||
const detail =
|
||||
result.reason instanceof Error ? result.reason.message : 'Source search failed.'
|
||||
errors.push(
|
||||
reads[index]?.kind === 'github' ? `${detail} GitHub runs on the selected host.` : detail
|
||||
)
|
||||
}
|
||||
}
|
||||
return { rows: rows.slice(0, NEW_WORKSPACE_SOURCE_RESULT_LIMIT), warnings, errors }
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceSourcePr(args: {
|
||||
client: RpcClient
|
||||
repoId: string
|
||||
item: {
|
||||
number: number
|
||||
branchName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
}): Promise<GitHubPrStartPoint> {
|
||||
const response = await args.client.sendRequest(
|
||||
'worktree.resolvePrBase',
|
||||
{
|
||||
repo: `id:${args.repoId}`,
|
||||
prNumber: args.item.number,
|
||||
...(args.item.branchName ? { headRefName: args.item.branchName } : {}),
|
||||
...(args.item.baseRefName ? { baseRefName: args.item.baseRefName } : {}),
|
||||
...(args.item.isCrossRepository !== undefined
|
||||
? { isCrossRepository: args.item.isCrossRepository }
|
||||
: {})
|
||||
},
|
||||
{ timeoutMs: SOURCE_READ_TIMEOUT_MS }
|
||||
)
|
||||
const result = responseResult(response)
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error('The selected host returned an incompatible PR start point.')
|
||||
}
|
||||
if ('error' in result && typeof result.error === 'string') {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
if (!('baseBranch' in result) || typeof result.baseBranch !== 'string') {
|
||||
throw new Error('The selected host returned an incompatible PR start point.')
|
||||
}
|
||||
return result as GitHubPrStartPoint
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveBranchWorkspaceSource,
|
||||
resolveGitHubWorkspaceSource
|
||||
} from './workspace-source-selection'
|
||||
|
||||
describe('workspace source selection', () => {
|
||||
it('uses deterministic GitHub identity when the title is not sluggable', () => {
|
||||
const source = resolveGitHubWorkspaceSource({
|
||||
id: 'pr-4',
|
||||
type: 'pr',
|
||||
number: 4,
|
||||
title: '你好',
|
||||
url: 'https://github.com/acme/app/pull/4',
|
||||
repoId: 'repo',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null
|
||||
})
|
||||
expect(source).toMatchObject({ suggestedName: 'pr-4', displayName: 'PR 4' })
|
||||
})
|
||||
|
||||
it('offers exact reuse only for verified, non-busy local branches', () => {
|
||||
expect(
|
||||
resolveBranchWorkspaceSource({
|
||||
refName: 'feature/mobile',
|
||||
localBranchName: 'feature/mobile',
|
||||
verified: true,
|
||||
name: { value: '', owner: 'blank' },
|
||||
worktreeBranches: []
|
||||
})
|
||||
).toMatchObject({ reuseEligibleBranch: 'feature/mobile', reuseEnabled: true })
|
||||
expect(
|
||||
resolveBranchWorkspaceSource({
|
||||
refName: 'feature/mobile',
|
||||
localBranchName: 'feature/mobile',
|
||||
verified: true,
|
||||
name: { value: '', owner: 'blank' },
|
||||
worktreeBranches: ['refs/heads/feature/mobile']
|
||||
})
|
||||
).toMatchObject({ reuseEligibleBranch: null, branchNameOverride: undefined })
|
||||
expect(
|
||||
resolveBranchWorkspaceSource({
|
||||
refName: 'legacy',
|
||||
localBranchName: 'legacy',
|
||||
verified: false,
|
||||
name: { value: '', owner: 'blank' },
|
||||
worktreeBranches: []
|
||||
})
|
||||
).toMatchObject({ reuseEligibleBranch: null, branchNameOverride: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerReuseOverride
|
||||
} from '../../../src/shared/composer-branch-selection'
|
||||
import { getForkPushWarning } from '../../../src/shared/fork-push-warning'
|
||||
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../src/shared/linear-links'
|
||||
import {
|
||||
getLinearIssueWorkspaceName,
|
||||
getLinkedWorkItemWorkspaceName
|
||||
} from '../../../src/shared/workspace-name'
|
||||
import type { GitHubPrStartPoint, GitHubWorkItem, LinearIssue } from '../../../src/shared/types'
|
||||
import type { ResolvedNewWorkspaceSource } from './new-workspace-source-types'
|
||||
import type { WorkspaceNameState } from './workspace-source-name-state'
|
||||
|
||||
function githubWorkspaceIdentity(item: GitHubWorkItem): {
|
||||
seedName: string
|
||||
displayName: string
|
||||
} {
|
||||
const identity = getLinkedWorkItemWorkspaceName({
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
provider: 'github'
|
||||
})
|
||||
return (
|
||||
identity ?? {
|
||||
seedName: `${item.type}-${item.number}`,
|
||||
displayName: item.type === 'pr' ? `PR ${item.number}` : `Issue ${item.number}`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveGitHubWorkspaceSource(
|
||||
item: GitHubWorkItem,
|
||||
prStartPoint?: GitHubPrStartPoint
|
||||
): ResolvedNewWorkspaceSource {
|
||||
const identity = githubWorkspaceIdentity(item)
|
||||
return {
|
||||
kind: 'github',
|
||||
item,
|
||||
suggestedName: identity.seedName,
|
||||
displayName: identity.displayName,
|
||||
...(prStartPoint ? { prStartPoint } : {}),
|
||||
forkWarning: prStartPoint ? getForkPushWarning(prStartPoint) : null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLinearWorkspaceSource(issue: LinearIssue): ResolvedNewWorkspaceSource {
|
||||
const identity = getLinkedWorkItemWorkspaceName({
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: issue.title,
|
||||
provider: 'linear',
|
||||
linearIdentifier: issue.identifier
|
||||
})
|
||||
return {
|
||||
kind: 'linear',
|
||||
issue,
|
||||
suggestedName: getLinearIssueWorkspaceName(issue) || issue.identifier.toLowerCase(),
|
||||
displayName: identity?.displayName ?? issue.identifier,
|
||||
organizationUrlKey: getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBranchWorkspaceSource(args: {
|
||||
refName: string
|
||||
localBranchName: string
|
||||
verified: boolean
|
||||
name: WorkspaceNameState
|
||||
worktreeBranches: readonly string[]
|
||||
}): ResolvedNewWorkspaceSource {
|
||||
const selection = resolveComposerBranchSelection({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
currentName: args.name.value,
|
||||
lastAutoName: args.name.owner === 'source' ? args.name.value : ''
|
||||
})
|
||||
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
|
||||
args.localBranchName,
|
||||
args.worktreeBranches
|
||||
)
|
||||
const selectionProducedOverride =
|
||||
args.verified && args.name.owner !== 'user' && selection.branchNameOverride !== undefined
|
||||
const reuse = args.verified
|
||||
? resolveComposerBranchReuse({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
selectionProducedOverride,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
: { reuseEligibleBranch: null, defaultReuse: false }
|
||||
const branchNameOverride = args.verified
|
||||
? resolveComposerReuseOverride({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
branchNameOverride: selection.branchNameOverride,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
kind: 'branch',
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
verified: args.verified,
|
||||
branchAutoName: selection.branchAutoName,
|
||||
branchNameOverride,
|
||||
reuseEligibleBranch: reuse.reuseEligibleBranch,
|
||||
reuseEnabled: reuse.defaultReuse
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,7 @@ import {
|
||||
removeWorktree
|
||||
} from '../git/worktree'
|
||||
import * as gitRunner from '../git/runner'
|
||||
import {
|
||||
clearSubmodulePathsCacheForTests,
|
||||
listSubmodulePaths
|
||||
} from '../git/status'
|
||||
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
@@ -1412,6 +1409,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(status.capabilities).toContain('mobile.tasks.v1')
|
||||
expect(status.capabilities).toContain('project-host-setup.v1')
|
||||
expect(status.capabilities).toContain('linear.issue-attribute-filter.v1')
|
||||
expect(status.capabilities).toContain('mobile.workspace-source-selector.v1')
|
||||
expect(status.capabilities).not.toContain('browser.screencast.v1')
|
||||
expect(typeof status.protocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleMobileVersion).toBe('number')
|
||||
|
||||
@@ -5,11 +5,14 @@ import type {
|
||||
LinearCollectionResult,
|
||||
LinearIssue
|
||||
} from '../../../../shared/types'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
|
||||
import {
|
||||
WORKSPACE_SOURCE_QUERY_MAX_BYTES,
|
||||
isWorkspaceSourceQueryWithinLimit
|
||||
} from '../../../../shared/workspace-source-query'
|
||||
|
||||
export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
|
||||
|
||||
export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048
|
||||
export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = WORKSPACE_SOURCE_QUERY_MAX_BYTES
|
||||
|
||||
export type SmartWorkspaceSourceRow =
|
||||
| { kind: 'use-name'; value: string; name: string }
|
||||
@@ -38,7 +41,7 @@ export function isSmartWorkspaceSourceQueryWithinLimit(
|
||||
query: string,
|
||||
maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES
|
||||
): boolean {
|
||||
return !isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
return isWorkspaceSourceQueryWithinLimit(query, maxBytes)
|
||||
}
|
||||
|
||||
export function getBranchSearchRequest({
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
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
|
||||
}
|
||||
// Keep the renderer import stable while mobile and desktop share the predicate.
|
||||
export {
|
||||
FORK_PUSH_NO_MAINTAINER_EDIT_WARNING,
|
||||
getForkPushWarning
|
||||
} from '../../../shared/fork-push-warning'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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: only fork targets with maintainer edits explicitly disabled have the
|
||||
// GitHub rejection risk; same-repo origin pushes do not.
|
||||
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
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as cons
|
||||
// floor-taking input. Mobile must not forward replies unless advertised.
|
||||
export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY =
|
||||
'terminal.query-reply-input.v1' as const
|
||||
export const MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY =
|
||||
'mobile.workspace-source-selector.v1' as const
|
||||
|
||||
export const RUNTIME_CAPABILITIES = [
|
||||
'runtime.status.compat.v1',
|
||||
@@ -59,7 +61,8 @@ export const RUNTIME_CAPABILITIES = [
|
||||
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
|
||||
MOBILE_WORKSPACE_SOURCE_SELECTOR_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WORKSPACE_SOURCE_QUERY_MAX_BYTES,
|
||||
isWorkspaceSourceQueryWithinLimit
|
||||
} from './workspace-source-query'
|
||||
|
||||
describe('workspace source query bound', () => {
|
||||
it('measures UTF-8 bytes instead of JavaScript code units', () => {
|
||||
expect(isWorkspaceSourceQueryWithinLimit('x'.repeat(WORKSPACE_SOURCE_QUERY_MAX_BYTES))).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isWorkspaceSourceQueryWithinLimit('é'.repeat(WORKSPACE_SOURCE_QUERY_MAX_BYTES / 2))
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorkspaceSourceQueryWithinLimit(`é${'x'.repeat(WORKSPACE_SOURCE_QUERY_MAX_BYTES - 1)}`)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
|
||||
|
||||
// Why: bound provider/ref fanout before dispatch while still allowing long,
|
||||
// intentional search text across desktop and mobile clients.
|
||||
export const WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048
|
||||
|
||||
export function isWorkspaceSourceQueryWithinLimit(
|
||||
query: string,
|
||||
maxBytes = WORKSPACE_SOURCE_QUERY_MAX_BYTES
|
||||
): boolean {
|
||||
return !isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
}
|
||||
Reference in New Issue
Block a user