2 import ChatFormCurrentWorkingDirectoryChip from './ChatFormCurrentWorkingDirectoryChip.svelte';
3 import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte';
4 import { FolderOpen } from '@lucide/svelte';
5 import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
6 import * as Popover from '$lib/components/ui/popover';
7 import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH } from '$lib/constants';
8 import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
9 import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
10 import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
11 import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
12 import { ToolsService } from '$lib/services/tools.service';
13 import { toolsStore } from '$lib/stores';
14 import type { GlobEntry } from '$lib/types';
17 buildCaseInsensitiveGlob,
20 runGlobSearchWithChildren
23 // Microtask delay so the popover's focus scope tears down first.
24 const FOCUS_DELAY_MS = 0;
29 directory?: string | null;
30 /** Controlled open state; the host owns it so the chip click and the
31 * `/cwd` slash command open the picker through the same path. */
33 /** Two-way bound query, kept in sync with the text after `/cwd `. */
35 /** Anchor at the form's top edge so the popover floats above the box. */
36 customAnchor?: HTMLElement | null;
37 onChange?: (directory: string | null) => void;
38 /** Lets the host refocus the chat input after the popover closes. */
40 /** Fired when the chip is clicked so the host can open the picker. */
45 class: className = '',
56 // File System Access API is opt-in (Chrome / Edge / Opera): the popover
57 // exposes a "Browse" button only when available.
58 const pickerSupported =
59 typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
61 // When the server does not serve file_glob_search or the user disabled
62 // it, the picker still opens for manual entry but explains why search is
63 // unavailable instead of firing searches that would only fail. Browse is
64 // hidden too: it resolves the picked folder name through the same tool.
65 const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
66 const fileSearchEnabled = $derived(
67 fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
69 const searchUnavailableMessage = $derived(
70 fileSearchKey === null
71 ? 'File search is unavailable on this server - type a full path and press Enter'
72 : 'File search is disabled - type a full path and press Enter, or enable "Search files" in Settings > Tools'
75 let searchInputRef: HTMLInputElement | null = $state(null);
77 let queryResults = $state<string[]>([]);
78 let searchError = $state<string | null>(null);
79 let listContainer = $state<HTMLDivElement | null>(null);
81 const nav = usePickerNavigation({
82 count: () => queryResults.length,
85 onSelect: (index) => commit(queryResults[index])
88 let homeBase = $derived(toolsStore.serverHome);
90 // Resolve home eagerly so the chip can abbreviate before the picker opens.
92 if (typeof window === 'undefined') return;
94 void toolsStore.resolveServerHome();
97 // HTML `autofocus` is unreliable on dynamically shown elements.
101 setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
107 const q = query.trim();
111 if (q && fileSearchEnabled) {
118 searchScope = homeBase ?? HOME_TILDE;
124 getContainer: () => listContainer,
125 getCount: () => queryResults.length,
126 getIndex: () => nav.hoveredIndex,
127 getTrigger: () => nav.scrollTrigger
130 let searchScope = $state(HOME_TILDE);
132 // An exactly-typed directory is "entered": the shared search lists its
133 // children too, so path navigation does not require a trailing slash.
134 const search = useDebouncedSearch({
135 canRun: () => isOpen && fileSearchEnabled,
136 debounceMs: SEARCH.DEBOUNCE_MS,
137 getQuery: () => query.trim(),
138 run: async (q, signal, isCurrent) => {
139 const trimmed = q.trim();
145 searchScope = homeBase ?? HOME_TILDE;
151 // Generous limit: ranking is client-side, only the top
152 // MAX_RESULTS_SHOWN are shown.
153 const res = await runGlobSearchWithChildren(
155 homeBase ?? HOME_TILDE,
159 { type: GlobSearchType.DIR }
162 if (!isCurrent()) return;
167 searchError = res.error;
172 searchScope = res.exactDir ?? res.args.path;
173 queryResults = res.entries.map((e) => e.path).slice(0, SEARCH.MAX_RESULTS_SHOWN);
175 if (queryResults.length > 0) {
177 nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
184 if (!isCurrent() || signal.aborted) return;
188 searchError = err instanceof Error ? err.message : String(err);
192 // Single funnel for every local close so the host refocus always fires.
193 function closePicker() {
197 function commit(path: string) {
202 function setDirectory(value: string) {
203 const trimmed = value.trim();
205 if (!trimmed) return;
210 // Resolve a browser-picked folder name (which exposes only the leaf name)
211 // to a server-side absolute path; null when the server cannot locate it,
212 // so the caller fails visibly instead of committing a bare leaf name.
213 async function resolveNativeName(name: string): Promise<string | null> {
215 const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
216 include: buildCaseInsensitiveGlob(name),
217 limit: SEARCH.NATIVE_LIMIT,
218 max_depth: SEARCH.NATIVE_MAX_DEPTH,
219 path: homeBase ?? HOME_TILDE,
220 type: GlobSearchType.DIR
222 const base = typeof res.base === 'string' ? res.base : '';
223 const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
224 const match = entries.find(
225 (e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
228 return match ? joinPath(base, match.path) : null;
234 async function browseNative() {
235 if (disabled || !window.showDirectoryPicker) return;
238 const handle = await window.showDirectoryPicker();
239 const path = await resolveNativeName(handle.name);
245 // keep the previous cwd and fail visibly instead of committing a
246 // bare leaf name that would resolve against the server cwd
247 searchError = `Could not resolve "${handle.name}" to a server path`;
250 // user cancelled - silently ignore; other errors are logged
251 if (err instanceof DOMException && err.name === 'AbortError') return;
253 console.error('[ChatFormCurrentWorkingDirectory] showDirectoryPicker failed:', err);
257 function handleSubmit() {
258 const value = query.trim();
270 function handleKeydown(event: KeyboardEvent) {
271 if (event.key === KeyboardKey.ENTER) {
272 event.preventDefault();
274 if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
275 commit(queryResults[nav.hoveredIndex]);
276 } else if (queryResults.length === 0) {
279 } else if (event.key === KeyboardKey.ARROW_DOWN) {
280 if (queryResults.length > 0) {
281 event.preventDefault();
284 } else if (event.key === KeyboardKey.ARROW_UP) {
285 if (queryResults.length > 0) {
286 event.preventDefault();
292 function clearDirectory(event?: MouseEvent) {
293 // Stop the click from bubbling into the chip button and re-opening
294 // the picker on top of the now-cleared state.
295 event?.stopPropagation();
296 event?.preventDefault();
301 function handleDismiss(event?: MouseEvent) {
302 event?.stopPropagation();
303 event?.preventDefault();
306 clearDirectory(event);
310 function handleOpenChange(open: boolean) {
312 void toolsStore.resolveServerHome();
315 // bits-ui-initiated close (Escape on the content, outside-click) -
316 // the only path that bypasses closePicker().
321 let innerWidth = $state(0);
322 const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
328 'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
334 <ChatFormCurrentWorkingDirectoryChip
339 onClear={handleDismiss}
343 <Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
345 class="pointer-events-none absolute inset-0 opacity-0"
349 <span class="sr-only">Open working directory picker</span>
357 preventScroll={false}
358 onkeydown={handleKeydown}
359 onOpenAutoFocus={(event) => event.preventDefault()}
360 onCloseAutoFocus={(event) => event.preventDefault()}
361 class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
363 <div class="p-2 min-h-22 flex flex-col justify-between">
365 bind:ref={searchInputRef}
367 placeholder="Choose working directory"
368 onClose={closePicker}
372 {#if !fileSearchEnabled}
373 <div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
374 {:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
375 <ChatFormCurrentWorkingDirectoryResultsList
376 results={queryResults}
377 hoveredIndex={nav.hoveredIndex}
378 isSearching={search.isSearching}
381 bind:container={listContainer}
383 onHover={(index) => nav.setHover(index)}
387 {#if pickerSupported && fileSearchEnabled}
390 class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
391 onclick={browseNative}
393 <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
398 {#if homeBase && fileSearchEnabled}
399 <div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
401 <span class="px-2 py-1.5 font-mono text-[10px]">
404 <span class="truncate text-muted-foreground/70" title={searchScope}
405 >{abbreviateHome(searchScope, homeBase)}</span
413 <svelte:window bind:innerWidth />