Skip to content

Commit a227263

Browse files
grichaclaude
andauthored
Add GitHub repository dropdown for workspace creation (#48)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 56e9638 commit a227263

File tree

9 files changed

+689
-58
lines changed

9 files changed

+689
-58
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { useState, useEffect } from 'react'
2+
import {
3+
View,
4+
Text,
5+
TextInput,
6+
TouchableOpacity,
7+
FlatList,
8+
StyleSheet,
9+
ActivityIndicator,
10+
Modal,
11+
} from 'react-native'
12+
import { useQuery } from '@tanstack/react-query'
13+
import { api, type GitHubRepo } from '../lib/api'
14+
import { useTheme } from '../contexts/ThemeContext'
15+
16+
interface RepoSelectorProps {
17+
value: string
18+
onChange: (value: string) => void
19+
placeholder?: string
20+
}
21+
22+
export function RepoSelector({
23+
value,
24+
onChange,
25+
placeholder = 'https://github.com/user/repo',
26+
}: RepoSelectorProps) {
27+
const { colors } = useTheme()
28+
const [mode, setMode] = useState<'github' | 'manual'>('github')
29+
const [isOpen, setIsOpen] = useState(false)
30+
const [search, setSearch] = useState('')
31+
const [debouncedSearch, setDebouncedSearch] = useState('')
32+
33+
useEffect(() => {
34+
const timer = setTimeout(() => {
35+
setDebouncedSearch(search)
36+
}, 300)
37+
return () => clearTimeout(timer)
38+
}, [search])
39+
40+
const { data, isLoading } = useQuery({
41+
queryKey: ['githubRepos', debouncedSearch],
42+
queryFn: () => api.listGitHubRepos(debouncedSearch || undefined, 20),
43+
staleTime: 60000,
44+
})
45+
46+
const isConfigured = data?.configured ?? false
47+
const repos = data?.repos ?? []
48+
49+
const handleSelect = (repo: GitHubRepo) => {
50+
onChange(repo.cloneUrl)
51+
setIsOpen(false)
52+
setSearch('')
53+
}
54+
55+
const switchToManual = () => {
56+
setMode('manual')
57+
setIsOpen(false)
58+
setSearch('')
59+
}
60+
61+
const switchToGithub = () => {
62+
setMode('github')
63+
onChange('')
64+
}
65+
66+
if (!isConfigured) {
67+
return (
68+
<View>
69+
<Text style={[styles.label, { color: colors.textMuted }]}>Repository (optional)</Text>
70+
<TextInput
71+
style={[styles.input, { backgroundColor: colors.surface, color: colors.text }]}
72+
value={value}
73+
onChangeText={onChange}
74+
placeholder={placeholder}
75+
placeholderTextColor={colors.textMuted}
76+
autoCapitalize="none"
77+
autoCorrect={false}
78+
keyboardType="url"
79+
/>
80+
</View>
81+
)
82+
}
83+
84+
if (mode === 'manual') {
85+
return (
86+
<View>
87+
<Text style={[styles.label, { color: colors.textMuted }]}>Repository (optional)</Text>
88+
<TextInput
89+
style={[styles.input, { backgroundColor: colors.surface, color: colors.text }]}
90+
value={value}
91+
onChangeText={onChange}
92+
placeholder={placeholder}
93+
placeholderTextColor={colors.textMuted}
94+
autoCapitalize="none"
95+
autoCorrect={false}
96+
keyboardType="url"
97+
/>
98+
<TouchableOpacity onPress={switchToGithub} style={styles.switchButton}>
99+
<Text style={[styles.githubIcon, { color: colors.accent }]}>GH</Text>
100+
<Text style={[styles.switchText, { color: colors.accent }]}>or select from GitHub</Text>
101+
</TouchableOpacity>
102+
</View>
103+
)
104+
}
105+
106+
return (
107+
<View>
108+
<Text style={[styles.label, { color: colors.textMuted }]}>Repository (optional)</Text>
109+
<TouchableOpacity
110+
style={[styles.searchButton, { backgroundColor: colors.surface }]}
111+
onPress={() => setIsOpen(true)}
112+
activeOpacity={0.7}
113+
>
114+
<Text style={[styles.githubIconLarge, { color: colors.textMuted }]}>GH</Text>
115+
<Text
116+
style={[styles.searchPlaceholder, { color: value ? colors.text : colors.textMuted }]}
117+
numberOfLines={1}
118+
>
119+
{value || 'Search your repositories...'}
120+
</Text>
121+
</TouchableOpacity>
122+
123+
<TouchableOpacity onPress={switchToManual} style={styles.switchButton}>
124+
<Text style={[styles.switchText, { color: colors.accent }]}>or type in any repository URL</Text>
125+
</TouchableOpacity>
126+
127+
<Modal
128+
visible={isOpen}
129+
animationType="slide"
130+
presentationStyle="pageSheet"
131+
onRequestClose={() => setIsOpen(false)}
132+
>
133+
<View style={[styles.modalContainer, { backgroundColor: colors.background }]}>
134+
<View style={[styles.modalHeader, { borderBottomColor: colors.border }]}>
135+
<TouchableOpacity onPress={() => setIsOpen(false)} style={styles.cancelBtn}>
136+
<Text style={[styles.cancelText, { color: colors.accent }]}>Cancel</Text>
137+
</TouchableOpacity>
138+
<Text style={[styles.modalTitle, { color: colors.text }]}>Select Repository</Text>
139+
<View style={styles.cancelBtn} />
140+
</View>
141+
142+
<View style={[styles.searchContainer, { borderBottomColor: colors.border }]}>
143+
<TextInput
144+
style={[styles.searchInput, { backgroundColor: colors.surface, color: colors.text }]}
145+
value={search}
146+
onChangeText={setSearch}
147+
placeholder="Search repositories..."
148+
placeholderTextColor={colors.textMuted}
149+
autoCapitalize="none"
150+
autoCorrect={false}
151+
autoFocus
152+
/>
153+
{isLoading && (
154+
<ActivityIndicator
155+
size="small"
156+
color={colors.accent}
157+
style={styles.searchLoader}
158+
/>
159+
)}
160+
</View>
161+
162+
{isLoading && !search ? (
163+
<View style={styles.loadingContainer}>
164+
<ActivityIndicator size="large" color={colors.accent} />
165+
</View>
166+
) : repos.length === 0 ? (
167+
<View style={styles.emptyContainer}>
168+
<Text style={[styles.emptyText, { color: colors.textMuted }]}>
169+
{search ? 'No repositories found' : 'Start typing to search'}
170+
</Text>
171+
</View>
172+
) : (
173+
<FlatList
174+
data={repos}
175+
keyExtractor={(item) => item.fullName}
176+
renderItem={({ item }) => (
177+
<TouchableOpacity
178+
style={[styles.repoRow, { borderBottomColor: colors.border }]}
179+
onPress={() => handleSelect(item)}
180+
>
181+
<Text style={[styles.repoIcon, { color: colors.textMuted }]}>
182+
{item.private ? '🔒' : '🌐'}
183+
</Text>
184+
<View style={styles.repoContent}>
185+
<Text style={[styles.repoName, { color: colors.text }]} numberOfLines={1}>
186+
{item.fullName}
187+
</Text>
188+
{item.description && (
189+
<Text style={[styles.repoDesc, { color: colors.textMuted }]} numberOfLines={1}>
190+
{item.description}
191+
</Text>
192+
)}
193+
</View>
194+
</TouchableOpacity>
195+
)}
196+
contentContainerStyle={styles.listContent}
197+
/>
198+
)}
199+
</View>
200+
</Modal>
201+
</View>
202+
)
203+
}
204+
205+
const styles = StyleSheet.create({
206+
label: {
207+
fontSize: 13,
208+
marginBottom: 8,
209+
textTransform: 'uppercase',
210+
letterSpacing: 0.5,
211+
},
212+
input: {
213+
borderRadius: 10,
214+
padding: 14,
215+
fontSize: 17,
216+
},
217+
searchButton: {
218+
flexDirection: 'row',
219+
alignItems: 'center',
220+
borderRadius: 10,
221+
padding: 14,
222+
gap: 10,
223+
},
224+
githubIcon: {
225+
fontSize: 12,
226+
fontWeight: '600',
227+
},
228+
githubIconLarge: {
229+
fontSize: 16,
230+
fontWeight: '600',
231+
},
232+
searchPlaceholder: {
233+
fontSize: 17,
234+
flex: 1,
235+
},
236+
switchButton: {
237+
flexDirection: 'row',
238+
alignItems: 'center',
239+
marginTop: 8,
240+
gap: 6,
241+
},
242+
switchText: {
243+
fontSize: 13,
244+
},
245+
modalContainer: {
246+
flex: 1,
247+
},
248+
modalHeader: {
249+
flexDirection: 'row',
250+
alignItems: 'center',
251+
justifyContent: 'space-between',
252+
paddingHorizontal: 16,
253+
paddingVertical: 12,
254+
borderBottomWidth: 1,
255+
},
256+
cancelBtn: {
257+
minWidth: 60,
258+
},
259+
cancelText: {
260+
fontSize: 17,
261+
},
262+
modalTitle: {
263+
fontSize: 17,
264+
fontWeight: '600',
265+
},
266+
searchContainer: {
267+
padding: 16,
268+
borderBottomWidth: 1,
269+
flexDirection: 'row',
270+
alignItems: 'center',
271+
},
272+
searchInput: {
273+
flex: 1,
274+
borderRadius: 10,
275+
padding: 12,
276+
fontSize: 16,
277+
},
278+
searchLoader: {
279+
position: 'absolute',
280+
right: 28,
281+
},
282+
loadingContainer: {
283+
flex: 1,
284+
alignItems: 'center',
285+
justifyContent: 'center',
286+
},
287+
emptyContainer: {
288+
flex: 1,
289+
alignItems: 'center',
290+
justifyContent: 'center',
291+
padding: 20,
292+
},
293+
emptyText: {
294+
fontSize: 16,
295+
},
296+
listContent: {
297+
paddingBottom: 20,
298+
},
299+
repoRow: {
300+
flexDirection: 'row',
301+
alignItems: 'center',
302+
paddingVertical: 14,
303+
paddingHorizontal: 16,
304+
borderBottomWidth: 1,
305+
},
306+
repoIcon: {
307+
fontSize: 18,
308+
marginRight: 12,
309+
},
310+
repoContent: {
311+
flex: 1,
312+
},
313+
repoName: {
314+
fontSize: 16,
315+
fontWeight: '500',
316+
},
317+
repoDesc: {
318+
fontSize: 14,
319+
marginTop: 2,
320+
},
321+
})

mobile/src/lib/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ export interface RecentSession {
9898
lastAccessed: string
9999
}
100100

101+
export interface GitHubRepo {
102+
name: string
103+
fullName: string
104+
cloneUrl: string
105+
sshUrl: string
106+
private: boolean
107+
description: string | null
108+
updatedAt: string
109+
}
110+
101111
const DEFAULT_PORT = 7391
102112
const STORAGE_KEY = 'perry_server_config'
103113

@@ -196,6 +206,13 @@ function createClient() {
196206
models: {
197207
list: (input: { agentType: 'claude-code' | 'opencode'; workspaceName?: string }) => Promise<{ models: ModelInfo[] }>
198208
}
209+
github: {
210+
listRepos: (input: { search?: string; perPage?: number; page?: number }) => Promise<{
211+
configured: boolean
212+
repos: GitHubRepo[]
213+
hasMore: boolean
214+
}>
215+
}
199216
}>(link)
200217
}
201218

@@ -260,4 +277,6 @@ export const api = {
260277
updateAgents: (data: CodingAgents) => client.config.agents.update(data),
261278
listModels: (agentType: 'claude-code' | 'opencode', workspaceName?: string) =>
262279
client.models.list({ agentType, workspaceName }),
280+
listGitHubRepos: (search?: string, perPage?: number, page?: number) =>
281+
client.github.listRepos({ search, perPage, page }),
263282
}

mobile/src/screens/HomeScreen.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
1919
import { api, WorkspaceInfo, HOST_WORKSPACE_NAME, CreateWorkspaceRequest } from '../lib/api'
2020
import { useNetwork, parseNetworkError } from '../lib/network'
2121
import { useTheme } from '../contexts/ThemeContext'
22+
import { RepoSelector } from '../components/RepoSelector'
2223

2324
function StatusDot({ status }: { status: WorkspaceInfo['status'] | 'host' }) {
2425
const colors = {
@@ -271,19 +272,11 @@ export function HomeScreen() {
271272
autoFocus
272273
/>
273274
</View>
274-
<View style={styles.inputGroup}>
275-
<Text style={[styles.inputLabel, { color: colors.textMuted }]}>Repository (optional)</Text>
276-
<TextInput
277-
style={[styles.modalInput, { backgroundColor: colors.surface, color: colors.text }]}
278-
value={newRepo}
279-
onChangeText={setNewRepo}
280-
placeholder="https://github.com/user/repo"
281-
placeholderTextColor={colors.textMuted}
282-
autoCapitalize="none"
283-
autoCorrect={false}
284-
keyboardType="url"
285-
/>
286-
</View>
275+
<RepoSelector
276+
value={newRepo}
277+
onChange={setNewRepo}
278+
placeholder="https://github.com/user/repo"
279+
/>
287280
</View>
288281
</KeyboardAvoidingView>
289282
</Modal>

0 commit comments

Comments
 (0)