Skip to content

Commit 3274faa

Browse files
authored
feat(router-app): add autocomplete for repo and branch selection (#34)
* feat(router-app): add autocomplete for repo and branch selection - Add custom Autocomplete component with keyboard navigation - Add API endpoints for listing user's GitHub repos and branches - Integrate autocomplete into SessionInputBar for better UX The autocomplete shows user's repos when they focus the repo field and loads branches when a repo is selected.
1 parent d57c486 commit 3274faa

5 files changed

Lines changed: 582 additions & 10 deletions

File tree

packages/opencode-router-app/src/api.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,27 @@ export async function suggestBranch(repoUrl: string): Promise<{ branch: string }
8383
if (!res.ok) throw new Error(`Failed to suggest branch: ${res.status}`)
8484
return res.json()
8585
}
86+
87+
export interface Repo {
88+
name: string
89+
fullName: string
90+
url: string
91+
isPrivate: boolean
92+
}
93+
94+
export interface Branch {
95+
name: string
96+
}
97+
98+
export async function listUserRepos(): Promise<Repo[]> {
99+
const res = await fetch("/api/user/repos", { signal: AbortSignal.timeout(TIMEOUT_MS) })
100+
if (!res.ok) throw new Error(`Failed to list repos: ${res.status}`)
101+
return res.json()
102+
}
103+
104+
export async function listRepoBranches(repoFullName: string): Promise<Branch[]> {
105+
const params = new URLSearchParams({ repo: repoFullName })
106+
const res = await fetch(`/api/user/repos/branches?${params}`, { signal: AbortSignal.timeout(TIMEOUT_MS) })
107+
if (!res.ok) throw new Error(`Failed to list branches: ${res.status}`)
108+
return res.json()
109+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { createSignal, createEffect, Show, For, onCleanup, onMount } from "solid-js"
2+
import type { Repo, Branch } from "./api"
3+
4+
type Props = {
5+
/** Placeholder text when empty */
6+
placeholder?: string
7+
/** Current value */
8+
value: string
9+
/** Called when user selects an item */
10+
onSelect: (value: string) => void
11+
/** Items fetched from API */
12+
items?: { label: string; value: string }[]
13+
}
14+
15+
const DROPDOWN_STYLE: JSX.CSSProperties = {
16+
position: "absolute",
17+
top: "100%",
18+
left: "0",
19+
right: "0",
20+
"margin-top": "4px",
21+
background: "var(--background-surface)",
22+
border: "1px solid var(--border-base)",
23+
"border-radius": "6px",
24+
"max-height": "240px",
25+
overflow: "auto",
26+
"z-index": "50",
27+
background: "var(--background-surface)",
28+
}
29+
30+
const ITEM_STYLE: JSX.CSSProperties = {
31+
padding: "8px 10px",
32+
cursor: "pointer",
33+
"font-size": "13px",
34+
color: "var(--text-base)",
35+
}
36+
37+
export function Autocomplete(props: Props) {
38+
const [isOpen, setIsOpen] = createSignal(false)
39+
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
40+
41+
let containerRef: HTMLDivElement | undefined
42+
43+
const displayItems = () => props.items ?? []
44+
const filteredItems = () => {
45+
const query = props.value.toLowerCase().trim()
46+
if (!query) return displayItems()
47+
return displayItems().filter((item) => item.label.toLowerCase().includes(query))
48+
}
49+
50+
const handleKeyDown = (e: KeyboardEvent) => {
51+
const items = filteredItems()
52+
if (!isOpen() && items.length > 0) {
53+
setIsOpen(true)
54+
return
55+
}
56+
57+
if (e.key === "ArrowDown") {
58+
e.preventDefault()
59+
setHighlightedIndex((i) => Math.min(i + 1, items.length - 1))
60+
} else if (e.key === "ArrowUp") {
61+
e.preventDefault()
62+
setHighlightedIndex((i) => Math.max(i - 1, 0))
63+
} else if (e.key === "Enter") {
64+
e.preventDefault()
65+
const item = items[highlightedIndex()]
66+
if (item) {
67+
props.onSelect(item.value)
68+
setIsOpen(false)
69+
}
70+
} else if (e.key === "Escape") {
71+
setIsOpen(false)
72+
}
73+
}
74+
75+
const handleSelect = (value: string) => {
76+
props.onSelect(value)
77+
setIsOpen(false)
78+
}
79+
80+
// Close on click outside
81+
const handleClickOutside = (e: MouseEvent) => {
82+
if (containerRef && !containerRef.contains(e.target as Node)) {
83+
setIsOpen(false)
84+
}
85+
}
86+
87+
onMount(() => {
88+
document.addEventListener("click", handleClickOutside)
89+
onCleanup(() => document.removeEventListener("click", handleClickOutside))
90+
})
91+
92+
createEffect(() => {
93+
// Reset highlight when items change
94+
const items = filteredItems()
95+
if (highlightedIndex() >= items.length) {
96+
setHighlightedIndex(0)
97+
}
98+
})
99+
100+
return (
101+
<div
102+
ref={containerRef}
103+
style={{ position: "relative", flex: props.placeholder?.includes("repo") ? "2" : "1", "min-width": "0" }}
104+
>
105+
<input
106+
type="text"
107+
placeholder={props.placeholder}
108+
value={props.value}
109+
onInput={(e) => {
110+
const v = e.currentTarget.value
111+
props.onSelect(v)
112+
if (v.trim() && displayItems().length > 0) {
113+
setIsOpen(true)
114+
}
115+
}}
116+
onFocus={() => {
117+
if (displayItems().length > 0) {
118+
setIsOpen(true)
119+
}
120+
}}
121+
onKeyDown={handleKeyDown}
122+
style={{
123+
background: "var(--background-base)",
124+
border: "1px solid var(--border-base)",
125+
color: "var(--text-base)",
126+
"border-radius": "6px",
127+
padding: "8px 10px",
128+
"font-size": "13px",
129+
outline: "none",
130+
width: "100%",
131+
}}
132+
/>
133+
134+
<Show when={isOpen() && filteredItems().length > 0}>
135+
<div style={DROPDOWN_STYLE}>
136+
<For each={filteredItems()}>
137+
{(item, index) => (
138+
<div
139+
style={{
140+
...ITEM_STYLE,
141+
background: index() === highlightedIndex() ? "var(--background-base)" : "transparent",
142+
}}
143+
onMouseEnter={() => setHighlightedIndex(index())}
144+
onClick={() => handleSelect(item.value)}
145+
onMouseDown={(e) => e.preventDefault()}
146+
>
147+
<div style={{ "font-weight": "500" }}>{item.label}</div>
148+
<Show when={item.value !== item.label}>
149+
<div style={{ color: "var(--text-dimmed-base)", "font-size": "11px" }}>{item.value}</div>
150+
</Show>
151+
</div>
152+
)}
153+
</For>
154+
</div>
155+
</Show>
156+
</div>
157+
)
158+
}

packages/opencode-router-app/src/session-input-bar.tsx

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { Show } from "solid-js"
1+
import { Show, createSignal, onMount } from "solid-js"
22
import { useI18n } from "@opencode-ai/ui/context"
33
import { Button } from "@opencode-ai/ui/button"
44
import { useT } from "./i18n"
55
import { GIT_URL_PATTERN } from "./setup-form-utils"
6+
import { Autocomplete } from "./autocomplete"
7+
import type { Repo } from "./api"
68

79
type Props = {
810
repoUrl: string
@@ -20,6 +22,21 @@ type Props = {
2022

2123
import type { DictKey } from "./i18n/en"
2224

25+
// Load user repos on mount (lazy loaded)
26+
let userRepos: Repo[] = []
27+
let reposLoaded = false
28+
async function loadUserRepos(): Promise<Repo[]> {
29+
if (reposLoaded) return userRepos
30+
try {
31+
const { listUserRepos } = await import("./api")
32+
userRepos = await listUserRepos()
33+
reposLoaded = true
34+
} catch {
35+
userRepos = []
36+
}
37+
return userRepos
38+
}
39+
2340
function disabledReason(props: Props, t: (key: DictKey) => string): string | null {
2441
if (!GIT_URL_PATTERN.test(props.repoUrl.trim())) return t("form.error.repoUrl.invalid")
2542
if (!props.sourceBranch.trim()) return t("form.error.sourceBranch.required")
@@ -41,6 +58,39 @@ const inputStyle = {
4158

4259
export function SessionInputBar(props: Props) {
4360
const t = useT(useI18n())
61+
const [repoItems, setRepoItems] = createSignal<{ label: string; value: string }[]>([])
62+
const [branchItems, setBranchItems] = createSignal<{ label: string; value: string }[]>([])
63+
const [reposLoading, setReposLoading] = createSignal(false)
64+
65+
// Load repos on first focus
66+
const ensureReposLoaded = async () => {
67+
if (repoItems().length > 0 || reposLoading()) return
68+
setReposLoading(true)
69+
const repos = await loadUserRepos()
70+
setRepoItems(repos.map((r) => ({ label: r.name, value: r.url })))
71+
setReposLoading(false)
72+
}
73+
74+
// Load branches when repo is selected
75+
const loadBranchesForRepo = async (url: string) => {
76+
try {
77+
const { listRepoBranches } = await import("./api")
78+
const repoFullName = url.replace(/^https?:\/\//, "").replace(/\.git$/, "")
79+
const repoParts = repoFullName.split("/")
80+
if (repoParts.length >= 2) {
81+
const branches = await listRepoBranches(`${repoParts[repoParts.length - 2]}/${repoParts[repoParts.length - 1]}`)
82+
setBranchItems(branches.map((b) => ({ label: b.name, value: b.name })))
83+
}
84+
} catch {
85+
setBranchItems([])
86+
}
87+
}
88+
89+
onMount(() => {
90+
// Pre-load repos in background when component mounts
91+
ensureReposLoaded()
92+
})
93+
4494
const canSubmit = () =>
4595
GIT_URL_PATTERN.test(props.repoUrl.trim()) &&
4696
props.sourceBranch.trim().length > 0 &&
@@ -63,19 +113,20 @@ export function SessionInputBar(props: Props) {
63113
}}
64114
>
65115
<div class="flex gap-2 flex-wrap">
66-
<input
67-
type="text"
116+
<Autocomplete
68117
placeholder={t("app.newSession.repoUrl.placeholder")}
69118
value={props.repoUrl}
70-
onInput={(e) => props.onRepoUrlChange(e.currentTarget.value)}
71-
style={{ ...inputStyle, flex: "2 1 180px" }}
119+
onSelect={(v) => {
120+
props.onRepoUrlChange(v)
121+
loadBranchesForRepo(v)
122+
}}
123+
items={repoItems()}
72124
/>
73-
<input
74-
type="text"
125+
<Autocomplete
75126
placeholder={t("app.newSession.sourceBranch.placeholder")}
76127
value={props.sourceBranch}
77-
onInput={(e) => props.onSourceBranchChange(e.currentTarget.value)}
78-
style={{ ...inputStyle, flex: "1 1 100px" }}
128+
onSelect={props.onSourceBranchChange}
129+
items={branchItems()}
79130
/>
80131
</div>
81132

0 commit comments

Comments
 (0)