]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
78e5b5cb99ca3a4668f60fb34a2a9677cc465988
[pkg/ggml/sources/llama.cpp] /
1 <script lang="ts">
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';
15 import {
16 abbreviateHome,
17 buildCaseInsensitiveGlob,
18 joinPath,
19 lastPathSegment,
20 runGlobSearchWithChildren
21 } from '$lib/utils';
22
23 // Microtask delay so the popover's focus scope tears down first.
24 const FOCUS_DELAY_MS = 0;
25
26 interface Props {
27 class?: string;
28 disabled?: boolean;
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. */
32 isOpen: boolean;
33 /** Two-way bound query, kept in sync with the text after `/cwd `. */
34 query: string;
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. */
39 onClose?: () => void;
40 /** Fired when the chip is clicked so the host can open the picker. */
41 onOpen?: () => void;
42 }
43
44 let {
45 class: className = '',
46 customAnchor = null,
47 directory = null,
48 disabled = false,
49 isOpen,
50 onChange,
51 onClose,
52 onOpen,
53 query = $bindable('')
54 }: Props = $props();
55
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';
60
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)
68 );
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'
73 );
74
75 let searchInputRef: HTMLInputElement | null = $state(null);
76
77 let queryResults = $state<string[]>([]);
78 let searchError = $state<string | null>(null);
79 let listContainer = $state<HTMLDivElement | null>(null);
80
81 const nav = usePickerNavigation({
82 count: () => queryResults.length,
83 isOpen: () => isOpen,
84 onClose: closePicker,
85 onSelect: (index) => commit(queryResults[index])
86 });
87
88 let homeBase = $derived(toolsStore.serverHome);
89
90 // Resolve home eagerly so the chip can abbreviate before the picker opens.
91 $effect(() => {
92 if (typeof window === 'undefined') return;
93
94 void toolsStore.resolveServerHome();
95 });
96
97 // HTML `autofocus` is unreliable on dynamically shown elements.
98 $effect(() => {
99 if (!isOpen) return;
100
101 setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
102 });
103
104 $effect(() => {
105 if (!isOpen) return;
106
107 const q = query.trim();
108
109 nav.reset(-1);
110
111 if (q && fileSearchEnabled) {
112 search.run(q);
113 } else {
114 search.cancel();
115 queryResults = [];
116 searchError = null;
117 nav.reset(-1);
118 searchScope = homeBase ?? HOME_TILDE;
119 }
120 });
121
122 useScrollActiveRow({
123 dataIndex: 'result',
124 getContainer: () => listContainer,
125 getCount: () => queryResults.length,
126 getIndex: () => nav.hoveredIndex,
127 getTrigger: () => nav.scrollTrigger
128 });
129
130 let searchScope = $state(HOME_TILDE);
131
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();
140
141 if (!trimmed) {
142 queryResults = [];
143 searchError = null;
144 nav.reset(-1);
145 searchScope = homeBase ?? HOME_TILDE;
146
147 return;
148 }
149
150 try {
151 // Generous limit: ranking is client-side, only the top
152 // MAX_RESULTS_SHOWN are shown.
153 const res = await runGlobSearchWithChildren(
154 trimmed,
155 homeBase ?? HOME_TILDE,
156 SEARCH.MAX_DEPTH,
157 SEARCH.LIMIT,
158 signal,
159 { type: GlobSearchType.DIR }
160 );
161
162 if (!isCurrent()) return;
163
164 if (res.error) {
165 queryResults = [];
166 nav.reset(-1);
167 searchError = res.error;
168
169 return;
170 }
171
172 searchScope = res.exactDir ?? res.args.path;
173 queryResults = res.entries.map((e) => e.path).slice(0, SEARCH.MAX_RESULTS_SHOWN);
174
175 if (queryResults.length > 0) {
176 nav.reset(0);
177 nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
178 } else {
179 nav.reset(-1);
180 }
181
182 searchError = null;
183 } catch (err) {
184 if (!isCurrent() || signal.aborted) return;
185
186 queryResults = [];
187 nav.reset(-1);
188 searchError = err instanceof Error ? err.message : String(err);
189 }
190 }
191 });
192 // Single funnel for every local close so the host refocus always fires.
193 function closePicker() {
194 onClose?.();
195 }
196
197 function commit(path: string) {
198 onChange?.(path);
199 closePicker();
200 }
201
202 function setDirectory(value: string) {
203 const trimmed = value.trim();
204
205 if (!trimmed) return;
206
207 onChange?.(trimmed);
208 }
209
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> {
214 try {
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
221 });
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()
226 );
227
228 return match ? joinPath(base, match.path) : null;
229 } catch {
230 return null;
231 }
232 }
233
234 async function browseNative() {
235 if (disabled || !window.showDirectoryPicker) return;
236
237 try {
238 const handle = await window.showDirectoryPicker();
239 const path = await resolveNativeName(handle.name);
240
241 if (path) {
242 setDirectory(path);
243 closePicker();
244 } else {
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`;
248 }
249 } catch (err) {
250 // user cancelled - silently ignore; other errors are logged
251 if (err instanceof DOMException && err.name === 'AbortError') return;
252
253 console.error('[ChatFormCurrentWorkingDirectory] showDirectoryPicker failed:', err);
254 }
255 }
256
257 function handleSubmit() {
258 const value = query.trim();
259
260 if (!value) {
261 closePicker();
262
263 return;
264 }
265
266 setDirectory(value);
267 closePicker();
268 }
269
270 function handleKeydown(event: KeyboardEvent) {
271 if (event.key === KeyboardKey.ENTER) {
272 event.preventDefault();
273
274 if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
275 commit(queryResults[nav.hoveredIndex]);
276 } else if (queryResults.length === 0) {
277 handleSubmit();
278 }
279 } else if (event.key === KeyboardKey.ARROW_DOWN) {
280 if (queryResults.length > 0) {
281 event.preventDefault();
282 nav.move(1);
283 }
284 } else if (event.key === KeyboardKey.ARROW_UP) {
285 if (queryResults.length > 0) {
286 event.preventDefault();
287 nav.move(-1);
288 }
289 }
290 }
291
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();
297 onChange?.(null);
298 closePicker();
299 }
300
301 function handleDismiss(event?: MouseEvent) {
302 event?.stopPropagation();
303 event?.preventDefault();
304
305 if (directory) {
306 clearDirectory(event);
307 }
308 }
309
310 function handleOpenChange(open: boolean) {
311 if (open) {
312 void toolsStore.resolveServerHome();
313 } else {
314 search.cancel();
315 // bits-ui-initiated close (Escape on the content, outside-click) -
316 // the only path that bypasses closePicker().
317 onClose?.();
318 }
319 }
320
321 let innerWidth = $state(0);
322 const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
323 </script>
324
325 <button
326 type="button"
327 class={[
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',
329 className
330 ]}
331 onclick={onOpen}
332 {disabled}
333 >
334 <ChatFormCurrentWorkingDirectoryChip
335 {directory}
336 {homeBase}
337 {disabled}
338 {showTooltip}
339 onClear={handleDismiss}
340 />
341 </button>
342
343 <Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
344 <Popover.Trigger
345 class="pointer-events-none absolute inset-0 opacity-0"
346 tabindex={-1}
347 aria-hidden="true"
348 >
349 <span class="sr-only">Open working directory picker</span>
350 </Popover.Trigger>
351
352 <Popover.Content
353 side="top"
354 align="start"
355 sideOffset={12}
356 {customAnchor}
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"
362 >
363 <div class="p-2 min-h-22 flex flex-col justify-between">
364 <SearchInput
365 bind:ref={searchInputRef}
366 bind:value={query}
367 placeholder="Choose working directory"
368 onClose={closePicker}
369 class="w-full"
370 />
371
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}
379 error={searchError}
380 rawQuery={query}
381 bind:container={listContainer}
382 onCommit={commit}
383 onHover={(index) => nav.setHover(index)}
384 />
385 {/if}
386
387 {#if pickerSupported && fileSearchEnabled}
388 <button
389 type="button"
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}
392 >
393 <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
394 <span>Browse</span>
395 </button>
396 {/if}
397
398 {#if homeBase && fileSearchEnabled}
399 <div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
400
401 <span class="px-2 py-1.5 font-mono text-[10px]">
402 Searching in:
403
404 <span class="truncate text-muted-foreground/70" title={searchScope}
405 >{abbreviateHome(searchScope, homeBase)}</span
406 >
407 </span>
408 {/if}
409 </div>
410 </Popover.Content>
411 </Popover.Root>
412
413 <svelte:window bind:innerWidth />