This commit is contained in:
Timothy Jaeryang Baek
2026-08-22 09:13:13 -04:00
parent d17f06a235
commit 7abe11346a
4 changed files with 425 additions and 9 deletions

View File

@@ -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<TerminalFileMatchesResponse | null> => {
const headers: Record<string, string> = 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,

View File

@@ -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<string, FileEntry[]> = new Map();
let loadingDirs: Set<string> = 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<typeof setTimeout> | 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 @@
<div
bind:this={containerEl}
class="flex flex-col h-full min-h-0 min-w-0 relative"
on:dragover={handleDragOver}
on:dragover={(e) => !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}
<div
class="absolute inset-1 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/10 dark:border-blue-500 pointer-events-none"
>
@@ -1479,8 +1660,31 @@
</Tooltip>
</FileNavToolbar>
{#if !selectedFile}
<div
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-gray-50 px-3 dark:border-gray-850/30"
>
<Icon name="search" size={13} strokeWidth={1.5} class="shrink-0 text-gray-400" />
<input
type="text"
class="min-w-0 flex-1 border-none bg-transparent text-xs text-gray-800 outline-none placeholder:text-gray-400 dark:text-gray-200 dark:placeholder:text-gray-600"
placeholder={$i18n.t('Search files and contents')}
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="flex shrink-0 items-center text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-300"
on:click={() => (searchQuery = '')}
aria-label={$i18n.t('Clear search')}
>
<Icon name="xmark" size={11} strokeWidth={1.5} />
</button>
{/if}
</div>
{/if}
<!-- Bulk action bar -->
{#if selectedCount > 0}
{#if selectedCount > 0 && !isSearching}
<BulkActionBar
count={selectedCount}
canDelete={selectedEntriesWritable}
@@ -1503,7 +1707,7 @@
if (e.target === e.currentTarget && selectedCount > 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}
<div class="flex justify-center pt-8"><Spinner className="size-4" /></div>
{:else if matchError}
<div class="flex items-center justify-center py-12">
<div class="text-xs text-gray-400 dark:text-gray-500">{matchError}</div>
</div>
{:else if !matchResults?.length}
<div class="flex items-center justify-center py-12">
<div class="text-xs text-gray-400 dark:text-gray-500">{$i18n.t('No matches')}</div>
</div>
{:else}
{#if filenameMatches.length > 0}
<div
class="px-2 pt-1 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600"
>
{$i18n.t('Filename matches')}
</div>
{#each filenameMatches as match (match.path)}
<button
class="flex h-7 w-full items-center gap-2 rounded-lg px-2 text-left transition-colors duration-75 hover:bg-gray-50 dark:hover:bg-white/4"
on:click={() => openFileMatch(match)}
>
<FileTypeIcon name={match.name} type={match.type} />
<span class="min-w-0 flex-1 truncate text-xs text-gray-800 dark:text-gray-200">
{match.name}
{#if relativeParentPath(match.relative_path)}
<span class="ml-1.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{relativeParentPath(match.relative_path)}
</span>
{/if}
</span>
</button>
{#if match.content_matches.length > 0}
{@const preview = match.content_matches[0]}
<button
class="flex h-6 w-full items-center gap-2 rounded-lg pl-8 pr-2 text-left transition-colors duration-75 hover:bg-gray-50 dark:hover:bg-white/4"
on:click={() => openContentMatch(match, preview)}
>
<span
class="w-6 shrink-0 text-right font-mono text-[0.625rem] text-gray-400 dark:text-gray-600"
>{preview.line}</span
>
<span
class="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-gray-500 dark:text-gray-500"
>{preview.text}</span
>
{#if match.content_matches.length > 1}
<span class="shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600">
+{match.content_matches.length - 1}
</span>
{/if}
</button>
{/if}
{/each}
{/if}
{#if contentOnlyMatches.length > 0}
<div
class="px-2 pt-2 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600"
>
{$i18n.t('Content matches')}
</div>
{#each contentOnlyMatches as match (match.path)}
<button
class="flex h-7 w-full items-center gap-2 rounded-lg px-2 text-left transition-colors duration-75 hover:bg-gray-50 dark:hover:bg-white/4"
on:click={() => openFileMatch(match)}
>
<FileTypeIcon name={match.name} type={match.type} />
<span class="min-w-0 flex-1 truncate text-xs text-gray-800 dark:text-gray-200">
{match.name}
{#if relativeParentPath(match.relative_path)}
<span class="ml-1.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{relativeParentPath(match.relative_path)}
</span>
{/if}
</span>
</button>
{#if match.content_matches.length > 0}
{@const preview = match.content_matches[0]}
<button
class="flex h-6 w-full items-center gap-2 rounded-lg pl-8 pr-2 text-left transition-colors duration-75 hover:bg-gray-50 dark:hover:bg-white/4"
on:click={() => openContentMatch(match, preview)}
>
<span
class="w-6 shrink-0 text-right font-mono text-[0.625rem] text-gray-400 dark:text-gray-600"
>{preview.line}</span
>
<span
class="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-gray-500 dark:text-gray-500"
>{preview.text}</span
>
{#if match.content_matches.length > 1}
<span class="shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600">
+{match.content_matches.length - 1}
</span>
{/if}
</button>
{/if}
{/each}
{/if}
{#if nextMatchOffset !== null}
<div use:loadMoreOnVisible class="flex h-8 items-center justify-center">
{#if matchLoadingMore}
<Spinner className="size-3" />
{:else if matchLoadMoreError}
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-75 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-400"
on:click={() => {
matchLoadMoreError = false;
void loadMoreMatches();
}}>{$i18n.t('Retry')}</button
>
{/if}
</div>
{/if}
{/if}
{:else if uploading}
<div class="flex items-center justify-center gap-2 p-4 text-xs text-gray-500">
<Spinner className="size-4" />
{$i18n.t('Uploading...')}
@@ -1581,7 +1904,7 @@
</div>
{/if}
{#if !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)}
{#if !isSearching && !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)}
{#if creatingFolder}
<div class="flex h-7 items-center gap-2 px-2">
<FileTypeIcon name={newFolderName} type="directory" />
@@ -1663,7 +1986,7 @@
</div>
<!-- Port detection -->
{#if selectedTerminal && !selectedFile && previewPort === null}
{#if selectedTerminal && !selectedFile && previewPort === null && !isSearching}
<div class="shrink-0 border-t border-gray-50 dark:border-gray-850/30">
<PortList
baseUrl={selectedTerminal.url}

View File

@@ -12,12 +12,19 @@
export let value = '';
export let filePath: string | null = null;
export let onSave: ((content: string) => Promise<void>) | 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(() => {

View File

@@ -47,6 +47,12 @@
export let readOnly = false;
export let onSave: ((content: string) => Promise<void>) | 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}
<div class="absolute inset-0">
<FileCodeEditor
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
{searchTarget}
/>
</div>
{:else if isHtml && !showRaw && serveUrl}
{#if overlay}
<div class="absolute top-0 left-0 right-0 bottom-0 z-10"></div>
{/if}