mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
refactor(mobile): match desktop project targets
This commit is contained in:
@@ -46,15 +46,26 @@ vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
|
||||
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
|
||||
const repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
]
|
||||
|
||||
function repoPickerItems(renderer: ReactTestRenderer): { label: string; detail: string }[] {
|
||||
function pickerItems(
|
||||
renderer: ReactTestRenderer,
|
||||
title: string
|
||||
): { label: string; detail: string }[] {
|
||||
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
|
||||
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
|
||||
return repoPicker?.props.items ?? []
|
||||
const picker = pickers.find((node) => node.props.title === title)
|
||||
return picker?.props.items ?? []
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal repo list', () => {
|
||||
describe('NewWorktreeModal project targets', () => {
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -91,12 +102,15 @@ describe('NewWorktreeModal repo list', () => {
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('repo.list')
|
||||
expect(repoPickerItems(renderer)).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'Local · /src/orca' })
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
||||
])
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: 'Local Mac', detail: '/src/orca' })
|
||||
])
|
||||
})
|
||||
|
||||
it('labels same-name local and SSH repositories with their locations', async () => {
|
||||
it('groups same-name checkouts under one project with separate run targets', async () => {
|
||||
const listedRepos = [
|
||||
...repos,
|
||||
{
|
||||
@@ -104,7 +118,8 @@ describe('NewWorktreeModal repo list', () => {
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
kind: 'git'
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
]
|
||||
const client = {
|
||||
@@ -130,11 +145,14 @@ describe('NewWorktreeModal repo list', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(repoPickerItems(renderer)).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'Local · /src/orca' }),
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
||||
])
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: 'Local Mac', detail: '/src/orca' }),
|
||||
expect.objectContaining({
|
||||
label: 'orca',
|
||||
detail: 'SSH · build-server · /home/dev/orca'
|
||||
label: 'SSH · build-server',
|
||||
detail: '/home/dev/orca'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Keyboard
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
type SetupHookTrust
|
||||
} from '../tasks/setup-hook-trust'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
|
||||
import type {
|
||||
PersistedTrustedOrcaHooks,
|
||||
Repo as SharedRepo,
|
||||
TuiAgent
|
||||
} from '../../../src/shared/types'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import {
|
||||
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
|
||||
@@ -63,19 +67,27 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import { getNewWorkspaceRepoDetail } from './new-workspace-repo-detail'
|
||||
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
|
||||
import {
|
||||
buildNewWorkspaceProjectOptions,
|
||||
buildNewWorkspaceRunTargetOptions,
|
||||
getNewWorkspaceProjectId,
|
||||
getNewWorkspaceRunTarget
|
||||
} from './new-workspace-project-targets'
|
||||
|
||||
type Repo = {
|
||||
id: string
|
||||
displayName: string
|
||||
path: string
|
||||
badgeColor?: string
|
||||
connectionId?: string | null
|
||||
executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null
|
||||
kind?: 'git' | 'folder'
|
||||
upstream?: { owner: string; repo: string } | null
|
||||
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
|
||||
}
|
||||
type Repo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<
|
||||
SharedRepo,
|
||||
| 'badgeColor'
|
||||
| 'connectionId'
|
||||
| 'executionHostId'
|
||||
| 'kind'
|
||||
| 'upstream'
|
||||
| 'repoIcon'
|
||||
| 'gitRemoteIdentity'
|
||||
>
|
||||
>
|
||||
|
||||
type SetupDecision = 'inherit' | 'run' | 'skip'
|
||||
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
||||
@@ -696,16 +708,15 @@ function NewWorktreeModalContent({
|
||||
isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
|
||||
)
|
||||
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
|
||||
const repoPickerItems = useMemo(
|
||||
() =>
|
||||
repos.map((repo) => ({
|
||||
id: repo.id,
|
||||
label: repo.displayName,
|
||||
detail: getNewWorkspaceRepoDetail(repo),
|
||||
repo
|
||||
})),
|
||||
[repos]
|
||||
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
|
||||
const selectedProjectId = selectedRepo ? getNewWorkspaceProjectId(selectedRepo) : null
|
||||
const selectedProject =
|
||||
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
|
||||
const runTargetPickerItems = useMemo(
|
||||
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId),
|
||||
[repos, selectedProjectId]
|
||||
)
|
||||
const selectedRunTarget = selectedRepo ? getNewWorkspaceRunTarget(selectedRepo) : null
|
||||
|
||||
function prepareSelectionPickerOpen(): void {
|
||||
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
|
||||
@@ -793,10 +804,7 @@ function NewWorktreeModalContent({
|
||||
>
|
||||
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Pick a repository and agent to spin up a new workspace.
|
||||
</Text>
|
||||
<Text style={styles.title}>Create worktree</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -805,40 +813,23 @@ function NewWorktreeModalContent({
|
||||
</View>
|
||||
) : repos.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.emptyText}>No repositories found</Text>
|
||||
<Text style={styles.emptyText}>No projects found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Repository</Text>
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('repo')
|
||||
}}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
<View
|
||||
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.fieldButtonCopy}>
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedRepo?.displayName ?? 'Select a repository'}
|
||||
</Text>
|
||||
{selectedRepo ? (
|
||||
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
|
||||
{getNewWorkspaceRepoDetail(selectedRepo)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<NewWorktreeProjectTargetFields
|
||||
project={selectedProject}
|
||||
runTarget={selectedRunTarget}
|
||||
projectBadgeColor={selectedRepo ? repoBadgeColor(selectedRepo) : null}
|
||||
onOpenProject={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('project')
|
||||
}}
|
||||
onOpenRunTarget={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('runTarget')
|
||||
}}
|
||||
/>
|
||||
|
||||
<SmartWorkspaceSourceField
|
||||
composer={composer}
|
||||
@@ -908,7 +899,7 @@ function NewWorktreeModalContent({
|
||||
>
|
||||
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
|
||||
{sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
@@ -1010,7 +1001,7 @@ function NewWorktreeModalContent({
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>
|
||||
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
|
||||
{sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -1039,10 +1030,10 @@ function NewWorktreeModalContent({
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'repo'}
|
||||
title="Repository"
|
||||
items={repoPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
visible={visible && drawerView === 'project'}
|
||||
title="Project"
|
||||
items={projectPickerItems}
|
||||
selectedId={selectedProjectId ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(item) => {
|
||||
@@ -1050,6 +1041,16 @@ function NewWorktreeModalContent({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'runTarget'}
|
||||
title="Run on"
|
||||
items={runTargetPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'agent'}
|
||||
title="Agent"
|
||||
@@ -1086,11 +1087,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: spacing.xl,
|
||||
alignItems: 'center'
|
||||
@@ -1127,15 +1123,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
fieldButtonDetail: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
|
||||
import { ChevronDown, Monitor } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
type Selection = { label: string; detail?: string }
|
||||
|
||||
export function NewWorktreeProjectTargetFields({
|
||||
project,
|
||||
runTarget,
|
||||
projectBadgeColor,
|
||||
onOpenProject,
|
||||
onOpenRunTarget
|
||||
}: {
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
projectBadgeColor: string | null
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenProject}>
|
||||
{projectBadgeColor ? (
|
||||
<View style={[styles.projectDot, { backgroundColor: projectBadgeColor }]} />
|
||||
) : null}
|
||||
<SelectionCopy selection={project} placeholder="Select a project" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Run on</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
|
||||
<Monitor size={14} color={colors.textMuted} />
|
||||
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionCopy({
|
||||
selection,
|
||||
placeholder
|
||||
}: {
|
||||
selection: Selection | null
|
||||
placeholder: string
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.fieldButtonCopy}>
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selection && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selection?.label ?? placeholder}
|
||||
</Text>
|
||||
{selection?.detail ? (
|
||||
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
|
||||
{selection.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
field: {
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
|
||||
},
|
||||
projectDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
fieldButtonCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
fieldButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonDetail: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildNewWorkspaceProjectOptions,
|
||||
buildNewWorkspaceRunTargetOptions,
|
||||
getNewWorkspaceRunTarget
|
||||
} from './new-workspace-project-targets'
|
||||
|
||||
describe('new workspace project targets', () => {
|
||||
it('groups local and SSH checkouts of the same project', () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca' }
|
||||
const options = buildNewWorkspaceProjectOptions([
|
||||
{ id: 'local', displayName: 'orca', path: '/src/orca', upstream },
|
||||
{
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
upstream
|
||||
}
|
||||
])
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('shows the provider slug recovered from canonical git identity', () => {
|
||||
const options = buildNewWorkspaceProjectOptions([
|
||||
{
|
||||
id: 'local',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/stablyai/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@github.com:stablyai/orca.git'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('labels local, SSH, and paired runtime targets', () => {
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' })
|
||||
).toEqual({ label: 'Local Mac', detail: '/src/orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: 'C:\\src\\orca',
|
||||
executionHostId: 'ssh:Windows%20VM'
|
||||
})
|
||||
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'runtime',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
executionHostId: 'runtime:devbox'
|
||||
})
|
||||
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
|
||||
})
|
||||
|
||||
it('shows one target per host when the project has multiple local worktrees', () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca' }
|
||||
const repos = [
|
||||
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
|
||||
{ id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream },
|
||||
{
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
upstream
|
||||
}
|
||||
]
|
||||
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
|
||||
|
||||
expect(buildNewWorkspaceRunTargetOptions(repos, projectId)).toEqual([
|
||||
expect.objectContaining({ id: 'local-a', label: 'Local Mac', detail: '/src/orca-a' }),
|
||||
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Repo } from '../../../src/shared/types'
|
||||
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
|
||||
|
||||
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
|
||||
>
|
||||
|
||||
export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
label: string
|
||||
detail?: string
|
||||
repo: TRepo
|
||||
}
|
||||
|
||||
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
label: string
|
||||
detail: string
|
||||
repo: TRepo
|
||||
}
|
||||
|
||||
function executionHostName(executionHostId: string): string {
|
||||
const encodedName = executionHostId.slice(executionHostId.indexOf(':') + 1)
|
||||
try {
|
||||
return decodeURIComponent(encodedName)
|
||||
} catch {
|
||||
return encodedName
|
||||
}
|
||||
}
|
||||
|
||||
export function getNewWorkspaceProjectId(repo: WorkspaceRepo): string {
|
||||
return getProjectIdentityKey(repo)
|
||||
}
|
||||
|
||||
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[]
|
||||
): NewWorkspaceProjectOption<TRepo>[] {
|
||||
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
|
||||
const hostIdsByProject = new Map<string, Set<string>>()
|
||||
for (const repo of repos) {
|
||||
const id = getNewWorkspaceProjectId(repo)
|
||||
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
hostIdsByProject.set(id, hostIds)
|
||||
if (!options.has(id)) {
|
||||
options.set(id, {
|
||||
id,
|
||||
label: repo.displayName,
|
||||
repo
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...options.values()].map((option) => {
|
||||
const providerParts = option.id.startsWith('github:')
|
||||
? option.id.slice('github:'.length).split('/')
|
||||
: []
|
||||
const providerDetail = providerParts.slice(-2).join('/')
|
||||
const hostCount = hostIdsByProject.get(option.id)?.size ?? 0
|
||||
const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '')
|
||||
return detail ? { ...option, detail } : option
|
||||
})
|
||||
}
|
||||
|
||||
export function getNewWorkspaceRunTarget(repo: WorkspaceRepo): {
|
||||
label: string
|
||||
detail: string
|
||||
} {
|
||||
const connectionId = repo.connectionId?.trim()
|
||||
if (connectionId) {
|
||||
return { label: `SSH · ${connectionId}`, detail: repo.path }
|
||||
}
|
||||
if (repo.executionHostId?.startsWith('ssh:')) {
|
||||
return { label: `SSH · ${executionHostName(repo.executionHostId)}`, detail: repo.path }
|
||||
}
|
||||
if (repo.executionHostId?.startsWith('runtime:')) {
|
||||
return { label: `Remote · ${executionHostName(repo.executionHostId)}`, detail: repo.path }
|
||||
}
|
||||
return { label: 'Local Mac', detail: repo.path }
|
||||
}
|
||||
|
||||
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[],
|
||||
projectId: string | null
|
||||
): NewWorkspaceRunTargetOption<TRepo>[] {
|
||||
if (!projectId) {
|
||||
return []
|
||||
}
|
||||
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
|
||||
for (const repo of repos) {
|
||||
if (getNewWorkspaceProjectId(repo) !== projectId) {
|
||||
continue
|
||||
}
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
if (!options.has(hostId)) {
|
||||
options.set(hostId, { id: repo.id, ...getNewWorkspaceRunTarget(repo), repo })
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getNewWorkspaceRepoDetail } from './new-workspace-repo-detail'
|
||||
|
||||
describe('new workspace repository detail', () => {
|
||||
it('identifies local repositories', () => {
|
||||
expect(getNewWorkspaceRepoDetail({ path: '/src/orca' })).toBe('Local · /src/orca')
|
||||
})
|
||||
|
||||
it('identifies SSH repositories by connection and path', () => {
|
||||
expect(getNewWorkspaceRepoDetail({ path: '/src/orca', connectionId: 'build-server' })).toBe(
|
||||
'SSH · build-server · /src/orca'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the execution host when an SSH connection ID is absent', () => {
|
||||
expect(
|
||||
getNewWorkspaceRepoDetail({ path: 'C:\\src\\orca', executionHostId: 'ssh:Windows%20VM' })
|
||||
).toBe('SSH · Windows VM · C:\\src\\orca')
|
||||
})
|
||||
|
||||
it('distinguishes paired runtime repositories from local repositories', () => {
|
||||
expect(
|
||||
getNewWorkspaceRepoDetail({ path: '/src/orca', executionHostId: 'runtime:devbox' })
|
||||
).toBe('Remote · devbox · /src/orca')
|
||||
})
|
||||
|
||||
it('prefers the explicit execution host over stale SSH metadata', () => {
|
||||
expect(
|
||||
getNewWorkspaceRepoDetail({
|
||||
path: '/src/orca',
|
||||
connectionId: 'old-ssh-target',
|
||||
executionHostId: 'runtime:devbox'
|
||||
})
|
||||
).toBe('Remote · devbox · /src/orca')
|
||||
expect(
|
||||
getNewWorkspaceRepoDetail({
|
||||
path: '/src/orca',
|
||||
connectionId: 'old-ssh-target',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
).toBe('Local · /src/orca')
|
||||
})
|
||||
|
||||
it('falls back when an execution host label is malformed', () => {
|
||||
expect(
|
||||
getNewWorkspaceRepoDetail({
|
||||
path: '/src/orca',
|
||||
connectionId: 'build-server',
|
||||
executionHostId: 'ssh:%E0%A4%A'
|
||||
})
|
||||
).toBe('SSH · build-server · /src/orca')
|
||||
expect(getNewWorkspaceRepoDetail({ path: '/src/orca', executionHostId: 'runtime:' })).toBe(
|
||||
'Local · /src/orca'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
type RepoLocation = {
|
||||
path: string
|
||||
connectionId?: string | null
|
||||
executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null
|
||||
}
|
||||
|
||||
function executionHostName(executionHostId: string): string | null {
|
||||
const encodedName = executionHostId.slice(executionHostId.indexOf(':') + 1)
|
||||
if (!encodedName) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(encodedName) || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getNewWorkspaceRepoDetail(repo: RepoLocation): string {
|
||||
const executionHostId = repo.executionHostId?.trim()
|
||||
if (executionHostId === 'local') {
|
||||
return `Local · ${repo.path}`
|
||||
}
|
||||
if (executionHostId?.startsWith('ssh:')) {
|
||||
const hostName = executionHostName(executionHostId)
|
||||
if (hostName) {
|
||||
return `SSH · ${hostName} · ${repo.path}`
|
||||
}
|
||||
}
|
||||
if (executionHostId?.startsWith('runtime:')) {
|
||||
const hostName = executionHostName(executionHostId)
|
||||
if (hostName) {
|
||||
return `Remote · ${hostName} · ${repo.path}`
|
||||
}
|
||||
}
|
||||
const connectionId = repo.connectionId?.trim()
|
||||
if (connectionId) {
|
||||
return `SSH · ${connectionId} · ${repo.path}`
|
||||
}
|
||||
return `Local · ${repo.path}`
|
||||
}
|
||||
@@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => {
|
||||
expect(
|
||||
resolveNewWorktreeFormSheetVisible({
|
||||
modalVisible: true,
|
||||
drawerView: 'repo',
|
||||
drawerView: 'project',
|
||||
formPinnedUnderSource: false
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
|
||||
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
|
||||
|
||||
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
|
||||
export type NewWorktreeDrawerView =
|
||||
| 'form'
|
||||
| 'transition'
|
||||
| 'source'
|
||||
| 'project'
|
||||
| 'runTarget'
|
||||
| 'agent'
|
||||
| 'trust'
|
||||
|
||||
// Why: iOS cannot reliably present a second native modal until the first drawer's
|
||||
// exit commits; one extra frame keeps transitions sequential on slower devices.
|
||||
|
||||
Reference in New Issue
Block a user