@@ -4,29 +4,59 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
44import { List } from "@opencode-ai/ui/list"
55import { getDirectory , getFilename } from "@opencode-ai/util/path"
66import fuzzysort from "fuzzysort"
7- import { createMemo } from "solid-js"
7+ import { createMemo , createResource , createSignal } from "solid-js"
88import { useGlobalSDK } from "@/context/global-sdk"
99import { useGlobalSync } from "@/context/global-sync"
1010import { useLanguage } from "@/context/language"
11+ import type { ListRef } from "@opencode-ai/ui/list"
1112
1213interface 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+
1824export 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 - Z a - z ] : \/ $ / . test ( v ) ) return v
102+
103+ const i = v . lastIndexOf ( "/" )
104+ if ( i <= 0 ) return "/"
105+ if ( i === 2 && / ^ [ A - Z a - 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 - Z a - 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 >
0 commit comments