diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index dd53620fd3..a8f310a962 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -19,6 +19,26 @@ export type TerminalFileSearchResponse = { results: TerminalFileSearchResult[]; }; +export type TerminalContentMatch = { + line: number; + column: number; + text: string; +}; + +export type TerminalFileMatch = { + path: string; + relative_path: string; + name: string; + type: 'file' | 'directory'; + name_match: boolean; + content_matches: TerminalContentMatch[]; +}; + +export type TerminalFileMatchesResponse = { + results: TerminalFileMatch[]; + next_offset: number | null; +}; + export type ListeningPort = { port: number; pid: number | null; @@ -194,6 +214,37 @@ export const searchFiles = async ( }; }; +export const getFileMatches = async ( + baseUrl: string, + apiKey: string, + query: string, + path: string = '.', + showHidden: boolean = false, + offset: number = 0, + sessionId?: string, + signal?: AbortSignal +): Promise => { + const headers: Record = bearerHeaders(apiKey); + if (sessionId) headers['X-Session-Id'] = sessionId; + + const params = new URLSearchParams({ + query, + path, + show_hidden: String(showHidden), + offset: String(offset) + }); + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/files/matches?${params.toString()}`, { + headers, + signal + }).catch((err) => { + if (err?.name !== 'AbortError') console.error('open-terminal getFileMatches error:', err); + return null; + }); + if (!res?.ok) return null; + const json = await res.json().catch(() => null); + return Array.isArray(json?.results) ? json : null; +}; + export const readFile = async ( baseUrl: string, apiKey: string, diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 8223b78cb9..56e423ff63 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -19,6 +19,7 @@ import { getCwd, getTerminalConfig, + getFileMatches, listFiles, readFile, downloadFileBlob, @@ -29,6 +30,8 @@ moveEntry, setCwd, type FileEntry, + type TerminalContentMatch, + type TerminalFileMatch, type TerminalFileRoot, type TerminalCwd } from '$lib/apis/terminal'; @@ -106,6 +109,12 @@ depth: number; rowIndex: number; }; + type FileSearchTarget = { + line: number; + column: number; + length: number; + requestId: number; + }; let sortBy: SortMode = 'name'; let sortAsc = true; @@ -115,6 +124,22 @@ let treeCache: Map = new Map(); let loadingDirs: Set = new Set(); let directoryMenu: { x: number; y: number } | null = null; + let searchQuery = ''; + let matchResults: TerminalFileMatch[] | null = null; + let matchLoading = false; + let matchLoadingMore = false; + let matchError: string | null = null; + let matchLoadMoreError = false; + let nextMatchOffset: number | null = null; + let matchTimer: ReturnType | null = null; + let matchController: AbortController | null = null; + let matchRequestId = 0; + let searchTargetRequestId = 0; + + $: searchText = searchQuery.trim(); + $: isSearching = Boolean(searchText); + $: filenameMatches = matchResults?.filter((match) => match.name_match) ?? []; + $: contentOnlyMatches = matchResults?.filter((match) => !match.name_match) ?? []; /** Normalize Windows backslashes and collapse duplicate separators. */ const normalizePath = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); @@ -276,6 +301,7 @@ let fileDocxData: ArrayBuffer | null = null; let fileLoading = false; let filePreviewRef: FilePreview; + let fileSearchTarget: FileSearchTarget | null = null; // ── Office preview state ──────────────────────────────────────────── let fileOfficeHtml: string | null = null; @@ -447,6 +473,132 @@ directoryMenu = null; }; + const parentDirectoryPath = (path: string) => { + const normalized = normalizePath(path); + const slash = normalized.lastIndexOf('/'); + return slash > 0 ? asDirectoryPath(normalized.slice(0, slash)) : '/'; + }; + + const relativeParentPath = (path: string) => { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '' : path.slice(0, slash); + }; + + const clearMatchRequest = () => { + if (matchTimer) { + clearTimeout(matchTimer); + matchTimer = null; + } + matchController?.abort(); + matchController = null; + }; + + const resetMatches = () => { + matchResults = null; + matchLoading = false; + matchLoadingMore = false; + matchError = null; + matchLoadMoreError = false; + nextMatchOffset = null; + }; + + const queueFileSearch = ( + query: string, + terminal: { url: string; key: string } | null, + path: string, + hiddenVisible: boolean, + activeFile: string | null, + activePort: number | null + ) => { + clearMatchRequest(); + matchRequestId += 1; + const requestId = matchRequestId; + if (!query || !terminal || activeFile || activePort !== null) { + resetMatches(); + return; + } + + clearSelection(); + closeDirectoryMenu(); + creatingFolder = false; + creatingFile = false; + matchLoading = true; + matchLoadingMore = false; + matchError = null; + matchLoadMoreError = false; + nextMatchOffset = null; + matchResults = null; + const controller = new AbortController(); + matchController = controller; + matchTimer = setTimeout(async () => { + const data = await getFileMatches( + terminal.url, + terminal.key, + query, + path, + hiddenVisible, + 0, + chatId ?? undefined, + controller.signal + ); + if (requestId !== matchRequestId) return; + if (data) { + matchResults = data.results; + nextMatchOffset = data.next_offset; + } else if (!controller.signal.aborted) { + matchError = $i18n.t('Failed to search files'); + matchResults = []; + } + matchLoading = false; + }, 200); + }; + + $: queueFileSearch(searchText, selectedTerminal, currentPath, showHidden, selectedFile, previewPort); + + const loadMoreMatches = async () => { + const offset = nextMatchOffset; + if (offset === null || !isSearching || matchLoading || matchLoadingMore || matchLoadMoreError) { + return; + } + const terminal = selectedTerminal; + if (!terminal) return; + + const requestId = matchRequestId; + matchLoadingMore = true; + const controller = new AbortController(); + matchController = controller; + const data = await getFileMatches( + terminal.url, + terminal.key, + searchText, + currentPath, + showHidden, + offset, + chatId ?? undefined, + controller.signal + ); + if (requestId === matchRequestId) { + if (data) { + matchResults = [...(matchResults ?? []), ...data.results]; + nextMatchOffset = data.next_offset; + } else if (!controller.signal.aborted) { + matchLoadMoreError = true; + } + matchLoadingMore = false; + } + }; + + const loadMoreOnVisible = (node: HTMLElement) => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) void loadMoreMatches(); + }, + { rootMargin: '160px' } + ); + observer.observe(node); + return { destroy: () => observer.disconnect() }; + }; + const labelFromPath = (path: string) => { const parts = normalizePath(path).split('/').filter(Boolean); return parts.at(-1) ?? '/'; @@ -538,6 +690,7 @@ // ── File preview management ────────────────────────────────────────── const clearFilePreview = () => { + fileSearchTarget = null; fileContent = null; if (fileImageUrl) { URL.revokeObjectURL(fileImageUrl); @@ -762,6 +915,33 @@ fileLoading = false; }; + const openFileMatch = async (match: TerminalFileMatch) => { + if (match.type === 'directory') { + searchQuery = ''; + await loadDir(match.path); + return; + } + await openEntry({ + name: match.name, + type: 'file', + size: 0, + fullPath: match.path, + parentPath: parentDirectoryPath(match.path), + depth: 0, + rowIndex: -1 + } as BrowserRow); + }; + + const openContentMatch = async (match: TerminalFileMatch, contentMatch: TerminalContentMatch) => { + await openFileMatch(match); + fileSearchTarget = { + line: contentMatch.line, + column: contentMatch.column, + length: searchText.length, + requestId: ++searchTargetRequestId + }; + }; + let downloading = false; const downloadFile = async (path: string) => { @@ -1247,6 +1427,7 @@ }); onDestroy(() => { + clearMatchRequest(); if (fileImageUrl) URL.revokeObjectURL(fileImageUrl); if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl); if (fileAudioUrl) URL.revokeObjectURL(fileAudioUrl); @@ -1302,13 +1483,13 @@
!isSearching && handleDragOver(e)} on:dragleave={() => (isDragOver = false)} - on:drop={handleDrop} + on:drop={(e) => !isSearching && handleDrop(e)} role="region" aria-label={$i18n.t('File browser')} > - {#if isDragOver} + {#if isDragOver && !isSearching}
@@ -1479,8 +1660,31 @@ + {#if !selectedFile} +
+ + + {#if searchQuery} + + {/if} +
+ {/if} + - {#if selectedCount > 0} + {#if selectedCount > 0 && !isSearching} 0) clearSelection(); }} on:contextmenu={(e) => { - if (selectedFile || previewPort !== null) return; + if (selectedFile || previewPort !== null || isSearching) return; if ((e.target as HTMLElement)?.closest('[data-file-row]')) return; e.preventDefault(); directoryMenu = { x: e.clientX, y: e.clientY }; @@ -1539,6 +1743,7 @@ {fileOfficeSlides} {excelSheetNames} {selectedExcelSheet} + searchTarget={fileSearchTarget} onSheetChange={async (sheet) => { if (!excelWorkbook) return; selectedExcelSheet = sheet; @@ -1564,7 +1769,125 @@ }} /> {:else} - {#if uploading} + {#if isSearching} + {#if matchLoading} +
+ {:else if matchError} +
+
{matchError}
+
+ {:else if !matchResults?.length} +
+
{$i18n.t('No matches')}
+
+ {:else} + {#if filenameMatches.length > 0} +
+ {$i18n.t('Filename matches')} +
+ {#each filenameMatches as match (match.path)} + + {#if match.content_matches.length > 0} + {@const preview = match.content_matches[0]} + + {/if} + {/each} + {/if} + + {#if contentOnlyMatches.length > 0} +
+ {$i18n.t('Content matches')} +
+ {#each contentOnlyMatches as match (match.path)} + + {#if match.content_matches.length > 0} + {@const preview = match.content_matches[0]} + + {/if} + {/each} + {/if} + + {#if nextMatchOffset !== null} +
+ {#if matchLoadingMore} + + {:else if matchLoadMoreError} + + {/if} +
+ {/if} + {/if} + {:else if uploading}
{$i18n.t('Uploading...')} @@ -1581,7 +1904,7 @@
{/if} - {#if !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)} + {#if !isSearching && !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)} {#if creatingFolder}
@@ -1663,7 +1986,7 @@
- {#if selectedTerminal && !selectedFile && previewPort === null} + {#if selectedTerminal && !selectedFile && previewPort === null && !isSearching}
Promise) | null = null; + export let searchTarget: { + line: number; + column: number; + length: number; + requestId: number; + } | null = null; let container: HTMLDivElement; let editor: EditorView | null = null; let editorTheme = new Compartment(); let editorLanguage = new Compartment(); let internalValue = ''; + let lastSearchTargetRequestId = 0; /** Return the current editor content */ export const getValue = (): string => { @@ -37,6 +44,20 @@ editor?.focus(); }; + const revealSearchTarget = () => { + if (!editor || !searchTarget || searchTarget.requestId === lastSearchTargetRequestId) return; + lastSearchTargetRequestId = searchTarget.requestId; + const lineNumber = Math.min(Math.max(searchTarget.line, 1), editor.state.doc.lines); + const line = editor.state.doc.line(lineNumber); + const from = line.from + Math.min(Math.max(searchTarget.column - 1, 0), line.length); + const to = Math.min(from + Math.max(searchTarget.length, 1), line.to); + editor.dispatch({ + selection: { anchor: from, head: to }, + effects: EditorView.scrollIntoView(from, { y: 'center' }) + }); + editor.focus(); + }; + const detectLanguage = async (path: string | null) => { if (!path) return; const match = LanguageDescription.matchFilename(languages, path); @@ -61,6 +82,10 @@ detectLanguage(filePath); } + $: if (editor && searchTarget) { + revealSearchTarget(); + } + onMount(() => { const isDark = document.documentElement.classList.contains('dark'); internalValue = value; @@ -105,6 +130,7 @@ }); detectLanguage(filePath); + revealSearchTarget(); // Watch dark mode const observer = new MutationObserver(() => { diff --git a/src/lib/components/chat/FileNav/FilePreview.svelte b/src/lib/components/chat/FileNav/FilePreview.svelte index f04b75a049..02e6e53975 100644 --- a/src/lib/components/chat/FileNav/FilePreview.svelte +++ b/src/lib/components/chat/FileNav/FilePreview.svelte @@ -47,6 +47,12 @@ export let readOnly = false; export let onSave: ((content: string) => Promise) | null = null; + export let searchTarget: { + line: number; + column: number; + length: number; + requestId: number; + } | null = null; export let editing = false; let editContent = ''; @@ -350,7 +356,17 @@ className="w-full h-full" /> {:else if fileContent !== null} - {#if isHtml && !showRaw && serveUrl} + {#if searchTarget} +
+ +
+ {:else if isHtml && !showRaw && serveUrl} {#if overlay}
{/if}