Skip to content

Commit 3db2392

Browse files
neriousyishaksebsib
authored andcommitted
fix(app): open project search (anomalyco#11783)
1 parent 80c855d commit 3db2392

2 files changed

Lines changed: 179 additions & 48 deletions

File tree

packages/app/src/components/dialog-select-directory.tsx

Lines changed: 150 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,59 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
44
import { List } from "@opencode-ai/ui/list"
55
import { getDirectory, getFilename } from "@opencode-ai/util/path"
66
import fuzzysort from "fuzzysort"
7-
import { createMemo } from "solid-js"
7+
import { createMemo, createResource, createSignal } from "solid-js"
88
import { useGlobalSDK } from "@/context/global-sdk"
99
import { useGlobalSync } from "@/context/global-sync"
1010
import { useLanguage } from "@/context/language"
11+
import type { ListRef } from "@opencode-ai/ui/list"
1112

1213
interface DialogSelectDirectoryProps {
1314
title?: string
1415
multiple?: boolean
1516
onSelect: (result: string | string[] | null) => void
1617
}
1718

19+
type Row = {
20+
absolute: string
21+
search: string
22+
}
23+
1824
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
1925
const sync = useGlobalSync()
2026
const sdk = useGlobalSDK()
2127
const dialog = useDialog()
2228
const language = useLanguage()
2329

24-
const home = createMemo(() => sync.data.path.home)
30+
const [filter, setFilter] = createSignal("")
31+
32+
let list: ListRef | undefined
33+
34+
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
35+
36+
const [fallbackPath] = createResource(
37+
() => (missingBase() ? true : undefined),
38+
async () => {
39+
return sdk.client.path
40+
.get()
41+
.then((x) => x.data)
42+
.catch(() => undefined)
43+
},
44+
{ initialValue: undefined },
45+
)
46+
47+
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
2548

26-
const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
49+
const start = createMemo(
50+
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
51+
)
2752

2853
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
2954

55+
const clean = (value: string) => {
56+
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
57+
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
58+
}
59+
3060
function normalize(input: string) {
3161
const v = input.replaceAll("\\", "/")
3262
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
6494
return ""
6595
}
6696

67-
function display(path: string) {
97+
function parentOf(input: string) {
98+
const v = trimTrailing(input)
99+
if (v === "/") return v
100+
if (v === "//") return v
101+
if (/^[A-Za-z]:\/$/.test(v)) return v
102+
103+
const i = v.lastIndexOf("/")
104+
if (i <= 0) return "/"
105+
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
106+
return v.slice(0, i)
107+
}
108+
109+
function modeOf(input: string) {
110+
const raw = normalizeDriveRoot(input.trim())
111+
if (!raw) return "relative" as const
112+
if (raw.startsWith("~")) return "tilde" as const
113+
if (rootOf(raw)) return "absolute" as const
114+
return "relative" as const
115+
}
116+
117+
function display(path: string, input: string) {
68118
const full = trimTrailing(path)
119+
if (modeOf(input) === "absolute") return full
120+
121+
return tildeOf(full) || full
122+
}
123+
124+
function tildeOf(absolute: string) {
125+
const full = trimTrailing(absolute)
69126
const h = home()
70-
if (!h) return full
127+
if (!h) return ""
71128

72129
const hn = trimTrailing(h)
73130
const lc = full.toLowerCase()
74131
const hc = hn.toLowerCase()
75132
if (lc === hc) return "~"
76133
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
77-
return full
134+
return ""
135+
}
136+
137+
function row(absolute: string): Row {
138+
const full = trimTrailing(absolute)
139+
const tilde = tildeOf(full)
140+
141+
const withSlash = (value: string) => {
142+
if (!value) return ""
143+
if (value.endsWith("/")) return value
144+
return value + "/"
145+
}
146+
147+
const search = Array.from(
148+
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
149+
).join("\n")
150+
return { absolute: full, search }
78151
}
79152

80-
function scoped(filter: string) {
153+
function scoped(value: string) {
81154
const base = start()
82155
if (!base) return
83156

84-
const raw = normalizeDriveRoot(filter.trim())
157+
const raw = normalizeDriveRoot(value)
85158
if (!raw) return { directory: trimTrailing(base), path: "" }
86159

87160
const h = home()
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
122195
}
123196

124197
const directories = async (filter: string) => {
125-
const input = scoped(filter)
126-
if (!input) return [] as string[]
198+
const value = clean(filter)
199+
const scopedInput = scoped(value)
200+
if (!scopedInput) return [] as string[]
127201

128-
const raw = normalizeDriveRoot(filter.trim())
202+
const raw = normalizeDriveRoot(value)
129203
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
130204

131-
const query = normalizeDriveRoot(input.path)
205+
const query = normalizeDriveRoot(scopedInput.path)
132206

133-
if (!isPath) {
134-
const results = await sdk.client.find
135-
.files({ directory: input.directory, query, type: "directory", limit: 50 })
207+
const find = () =>
208+
sdk.client.find
209+
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
136210
.then((x) => x.data ?? [])
137211
.catch(() => [])
138212

139-
return results.map((rel) => join(input.directory, rel)).slice(0, 50)
213+
if (!isPath) {
214+
const results = await find()
215+
216+
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
140217
}
141218

142219
const segments = query.replace(/^\/+/, "").split("/")
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
145222

146223
const cap = 12
147224
const branch = 4
148-
let paths = [input.directory]
225+
let paths = [scopedInput.directory]
149226
for (const part of head) {
150227
if (part === "..") {
151-
paths = paths.map((p) => {
152-
const v = trimTrailing(p)
153-
if (v === "/") return v
154-
if (/^[A-Za-z]:\/$/.test(v)) return v
155-
const i = v.lastIndexOf("/")
156-
if (i <= 0) return "/"
157-
return v.slice(0, i)
158-
})
228+
paths = paths.map(parentOf)
159229
continue
160230
}
161231

@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
165235
}
166236

167237
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
168-
return Array.from(new Set(out)).slice(0, 50)
238+
const deduped = Array.from(new Set(out))
239+
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
240+
const expand = !raw.endsWith("/")
241+
if (!expand || !tail) {
242+
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
243+
return items.slice(0, 50)
244+
}
245+
246+
const needle = tail.toLowerCase()
247+
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
248+
const target = exact[0]
249+
if (!target) return deduped.slice(0, 50)
250+
251+
const children = await match(target, "", 30)
252+
const items = Array.from(new Set([...deduped, ...children]))
253+
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
254+
}
255+
256+
const items = async (value: string) => {
257+
const results = await directories(value)
258+
return results.map(row)
169259
}
170260

171261
function resolve(absolute: string) {
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
179269
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
180270
emptyMessage={language.t("dialog.directory.empty")}
181271
loadingMessage={language.t("common.loading")}
182-
items={directories}
183-
key={(x) => x}
272+
items={items}
273+
key={(x) => x.absolute}
274+
filterKeys={["search"]}
275+
ref={(r) => (list = r)}
276+
onFilter={(value) => setFilter(clean(value))}
277+
onKeyEvent={(e, item) => {
278+
if (e.key !== "Tab") return
279+
if (e.shiftKey) return
280+
if (!item) return
281+
282+
e.preventDefault()
283+
e.stopPropagation()
284+
285+
const value = display(item.absolute, filter())
286+
list?.setFilter(value.endsWith("/") ? value : value + "/")
287+
}}
184288
onSelect={(path) => {
185289
if (!path) return
186-
resolve(path)
290+
resolve(path.absolute)
187291
}}
188292
>
189-
{(absolute) => {
190-
const path = display(absolute)
293+
{(item) => {
294+
const path = display(item.absolute, filter())
295+
if (path === "~") {
296+
return (
297+
<div class="w-full flex items-center justify-between rounded-md">
298+
<div class="flex items-center gap-x-3 grow min-w-0">
299+
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
300+
<div class="flex items-center text-14-regular min-w-0">
301+
<span class="text-text-strong whitespace-nowrap">~</span>
302+
<span class="text-text-weak whitespace-nowrap">/</span>
303+
</div>
304+
</div>
305+
</div>
306+
)
307+
}
191308
return (
192309
<div class="w-full flex items-center justify-between rounded-md">
193310
<div class="flex items-center gap-x-3 grow min-w-0">
194-
<FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
311+
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
195312
<div class="flex items-center text-14-regular min-w-0">
196313
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
197314
{getDirectory(path)}
198315
</span>
199316
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
317+
<span class="text-text-weak whitespace-nowrap">/</span>
200318
</div>
201319
</div>
202320
</div>

packages/ui/src/components/list.tsx

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
5151
export interface ListRef {
5252
onKeyDown: (e: KeyboardEvent) => void
5353
setScrollRef: (el: HTMLDivElement | undefined) => void
54+
setFilter: (value: string) => void
5455
}
5556

5657
export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
@@ -80,7 +81,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
8081
container.scrollTop = Math.max(0, Math.min(target, max))
8182
}
8283

83-
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
84+
const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList<T>(props)
8485

8586
const searchProps = () => (typeof props.search === "object" ? props.search : {})
8687
const searchAction = () => searchProps().action
@@ -89,21 +90,29 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
8990

9091
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
9192

92-
createEffect(() => {
93-
if (props.filter !== undefined) {
94-
onInput(props.filter)
95-
}
96-
})
93+
const applyFilter = (value: string, options?: { ref?: boolean }) => {
94+
const prev = filter()
95+
setInternalFilter(value)
96+
onInput(value)
97+
props.onFilter?.(value)
9798

98-
createEffect((prev) => {
99-
if (!props.search) return
100-
const current = internalFilter()
101-
if (prev !== current) {
102-
onInput(current)
103-
props.onFilter?.(current)
99+
if (!options?.ref) return
100+
101+
// Force a refetch even if the value is unchanged.
102+
// This is important for programmatic changes like Tab completion.
103+
if (prev === value) {
104+
refetch()
105+
return
104106
}
105-
return current
106-
}, "")
107+
queueMicrotask(() => refetch())
108+
}
109+
110+
createEffect(() => {
111+
if (props.filter === undefined) return
112+
if (props.filter === internalFilter()) return
113+
setInternalFilter(props.filter)
114+
onInput(props.filter)
115+
})
107116

108117
createEffect(
109118
on(
@@ -163,6 +172,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
163172
const index = selected ? all.indexOf(selected) : -1
164173
props.onKeyEvent?.(e, selected)
165174

175+
if (e.defaultPrevented) return
176+
166177
if (e.key === "Enter" && !e.isComposing) {
167178
e.preventDefault()
168179
if (selected) handleSelect(selected, index)
@@ -174,6 +185,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
174185
props.ref?.({
175186
onKeyDown: handleKey,
176187
setScrollRef,
188+
setFilter: (value) => applyFilter(value, { ref: true }),
177189
})
178190

179191
const renderAdd = () => {
@@ -247,7 +259,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
247259
data-slot="list-search-input"
248260
type="text"
249261
value={internalFilter()}
250-
onChange={setInternalFilter}
262+
onChange={(value) => applyFilter(value)}
251263
onKeyDown={handleKey}
252264
placeholder={searchProps().placeholder}
253265
spellcheck={false}
@@ -260,7 +272,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
260272
<IconButton
261273
icon="circle-x"
262274
variant="ghost"
263-
onClick={() => setInternalFilter("")}
275+
onClick={() => applyFilter("")}
264276
aria-label={i18n.t("ui.list.clearFilter")}
265277
/>
266278
</Show>
@@ -295,6 +307,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
295307
data-active={props.key(item) === active()}
296308
data-selected={item === props.current}
297309
onClick={() => handleSelect(item, i())}
310+
onKeyDown={handleKey}
298311
type="button"
299312
onMouseMove={(event) => {
300313
if (!moved(event)) return

0 commit comments

Comments
 (0)