refac
This commit is contained in:
1 parent
d17f06a235
commit
7abe11346a
4 files changed
+425
-9
No files matched your search
@@ -19,6 +19,26 @@ export type TerminalFileSearchResponse = {
|
|||||||
results: TerminalFileSearchResult[];
|
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 = {
|
export type ListeningPort = {
|
||||||
port: number;
|
port: number;
|
||||||
pid: number | null;
|
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 (
|
export const readFile = async (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import {
|
import {
|
||||||
getCwd,
|
getCwd,
|
||||||
getTerminalConfig,
|
getTerminalConfig,
|
||||||
|
getFileMatches,
|
||||||
listFiles,
|
listFiles,
|
||||||
readFile,
|
readFile,
|
||||||
downloadFileBlob,
|
downloadFileBlob,
|
||||||
@@ -29,6 +30,8 @@
|
|||||||
moveEntry,
|
moveEntry,
|
||||||
setCwd,
|
setCwd,
|
||||||
type FileEntry,
|
type FileEntry,
|
||||||
|
type TerminalContentMatch,
|
||||||
|
type TerminalFileMatch,
|
||||||
type TerminalFileRoot,
|
type TerminalFileRoot,
|
||||||
type TerminalCwd
|
type TerminalCwd
|
||||||
} from '$lib/apis/terminal';
|
} from '$lib/apis/terminal';
|
||||||
@@ -106,6 +109,12 @@
|
|||||||
depth: number;
|
depth: number;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
};
|
};
|
||||||
|
type FileSearchTarget = {
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
length: number;
|
||||||
|
requestId: number;
|
||||||
|
};
|
||||||
|
|
||||||
let sortBy: SortMode = 'name';
|
let sortBy: SortMode = 'name';
|
||||||
let sortAsc = true;
|
let sortAsc = true;
|
||||||
@@ -115,6 +124,22 @@
|
|||||||
let treeCache: Map<string, FileEntry[]> = new Map();
|
let treeCache: Map<string, FileEntry[]> = new Map();
|
||||||
let loadingDirs: Set<string> = new Set();
|
let loadingDirs: Set<string> = new Set();
|
||||||
let directoryMenu: { x: number; y: number } | null = null;
|
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. */
|
/** Normalize Windows backslashes and collapse duplicate separators. */
|
||||||
const normalizePath = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
const normalizePath = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
||||||
@@ -276,6 +301,7 @@
|
|||||||
let fileDocxData: ArrayBuffer | null = null;
|
let fileDocxData: ArrayBuffer | null = null;
|
||||||
let fileLoading = false;
|
let fileLoading = false;
|
||||||
let filePreviewRef: FilePreview;
|
let filePreviewRef: FilePreview;
|
||||||
|
let fileSearchTarget: FileSearchTarget | null = null;
|
||||||
|
|
||||||
// ── Office preview state ────────────────────────────────────────────
|
// ── Office preview state ────────────────────────────────────────────
|
||||||
let fileOfficeHtml: string | null = null;
|
let fileOfficeHtml: string | null = null;
|
||||||
@@ -447,6 +473,132 @@
|
|||||||
directoryMenu = null;
|
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 labelFromPath = (path: string) => {
|
||||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||||
return parts.at(-1) ?? '/';
|
return parts.at(-1) ?? '/';
|
||||||
@@ -538,6 +690,7 @@
|
|||||||
|
|
||||||
// ── File preview management ──────────────────────────────────────────
|
// ── File preview management ──────────────────────────────────────────
|
||||||
const clearFilePreview = () => {
|
const clearFilePreview = () => {
|
||||||
|
fileSearchTarget = null;
|
||||||
fileContent = null;
|
fileContent = null;
|
||||||
if (fileImageUrl) {
|
if (fileImageUrl) {
|
||||||
URL.revokeObjectURL(fileImageUrl);
|
URL.revokeObjectURL(fileImageUrl);
|
||||||
@@ -762,6 +915,33 @@
|
|||||||
fileLoading = false;
|
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;
|
let downloading = false;
|
||||||
|
|
||||||
const downloadFile = async (path: string) => {
|
const downloadFile = async (path: string) => {
|
||||||
@@ -1247,6 +1427,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
clearMatchRequest();
|
||||||
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
|
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
|
||||||
if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl);
|
if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl);
|
||||||
if (fileAudioUrl) URL.revokeObjectURL(fileAudioUrl);
|
if (fileAudioUrl) URL.revokeObjectURL(fileAudioUrl);
|
||||||
@@ -1302,13 +1483,13 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={containerEl}
|
bind:this={containerEl}
|
||||||
class="flex flex-col h-full min-h-0 min-w-0 relative"
|
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:dragleave={() => (isDragOver = false)}
|
||||||
on:drop={handleDrop}
|
on:drop={(e) => !isSearching && handleDrop(e)}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={$i18n.t('File browser')}
|
aria-label={$i18n.t('File browser')}
|
||||||
>
|
>
|
||||||
{#if isDragOver}
|
{#if isDragOver && !isSearching}
|
||||||
<div
|
<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"
|
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>
|
</Tooltip>
|
||||||
</FileNavToolbar>
|
</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 -->
|
<!-- Bulk action bar -->
|
||||||
{#if selectedCount > 0}
|
{#if selectedCount > 0 && !isSearching}
|
||||||
<BulkActionBar
|
<BulkActionBar
|
||||||
count={selectedCount}
|
count={selectedCount}
|
||||||
canDelete={selectedEntriesWritable}
|
canDelete={selectedEntriesWritable}
|
||||||
@@ -1503,7 +1707,7 @@
|
|||||||
if (e.target === e.currentTarget && selectedCount > 0) clearSelection();
|
if (e.target === e.currentTarget && selectedCount > 0) clearSelection();
|
||||||
}}
|
}}
|
||||||
on:contextmenu={(e) => {
|
on:contextmenu={(e) => {
|
||||||
if (selectedFile || previewPort !== null) return;
|
if (selectedFile || previewPort !== null || isSearching) return;
|
||||||
if ((e.target as HTMLElement)?.closest('[data-file-row]')) return;
|
if ((e.target as HTMLElement)?.closest('[data-file-row]')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
directoryMenu = { x: e.clientX, y: e.clientY };
|
directoryMenu = { x: e.clientX, y: e.clientY };
|
||||||
@@ -1539,6 +1743,7 @@
|
|||||||
{fileOfficeSlides}
|
{fileOfficeSlides}
|
||||||
{excelSheetNames}
|
{excelSheetNames}
|
||||||
{selectedExcelSheet}
|
{selectedExcelSheet}
|
||||||
|
searchTarget={fileSearchTarget}
|
||||||
onSheetChange={async (sheet) => {
|
onSheetChange={async (sheet) => {
|
||||||
if (!excelWorkbook) return;
|
if (!excelWorkbook) return;
|
||||||
selectedExcelSheet = sheet;
|
selectedExcelSheet = sheet;
|
||||||
@@ -1564,7 +1769,125 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{: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">
|
<div class="flex items-center justify-center gap-2 p-4 text-xs text-gray-500">
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
{$i18n.t('Uploading...')}
|
{$i18n.t('Uploading...')}
|
||||||
@@ -1581,7 +1904,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)}
|
{#if !isSearching && !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)}
|
||||||
{#if creatingFolder}
|
{#if creatingFolder}
|
||||||
<div class="flex h-7 items-center gap-2 px-2">
|
<div class="flex h-7 items-center gap-2 px-2">
|
||||||
<FileTypeIcon name={newFolderName} type="directory" />
|
<FileTypeIcon name={newFolderName} type="directory" />
|
||||||
@@ -1663,7 +1986,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Port detection -->
|
<!-- 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">
|
<div class="shrink-0 border-t border-gray-50 dark:border-gray-850/30">
|
||||||
<PortList
|
<PortList
|
||||||
baseUrl={selectedTerminal.url}
|
baseUrl={selectedTerminal.url}
|
||||||
|
|||||||
@@ -12,12 +12,19 @@
|
|||||||
export let value = '';
|
export let value = '';
|
||||||
export let filePath: string | null = null;
|
export let filePath: string | null = null;
|
||||||
export let onSave: ((content: string) => Promise<void>) | 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 container: HTMLDivElement;
|
||||||
let editor: EditorView | null = null;
|
let editor: EditorView | null = null;
|
||||||
let editorTheme = new Compartment();
|
let editorTheme = new Compartment();
|
||||||
let editorLanguage = new Compartment();
|
let editorLanguage = new Compartment();
|
||||||
let internalValue = '';
|
let internalValue = '';
|
||||||
|
let lastSearchTargetRequestId = 0;
|
||||||
|
|
||||||
/** Return the current editor content */
|
/** Return the current editor content */
|
||||||
export const getValue = (): string => {
|
export const getValue = (): string => {
|
||||||
@@ -37,6 +44,20 @@
|
|||||||
editor?.focus();
|
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) => {
|
const detectLanguage = async (path: string | null) => {
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const match = LanguageDescription.matchFilename(languages, path);
|
const match = LanguageDescription.matchFilename(languages, path);
|
||||||
@@ -61,6 +82,10 @@
|
|||||||
detectLanguage(filePath);
|
detectLanguage(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: if (editor && searchTarget) {
|
||||||
|
revealSearchTarget();
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
internalValue = value;
|
internalValue = value;
|
||||||
@@ -105,6 +130,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
detectLanguage(filePath);
|
detectLanguage(filePath);
|
||||||
|
revealSearchTarget();
|
||||||
|
|
||||||
// Watch dark mode
|
// Watch dark mode
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
export let readOnly = false;
|
export let readOnly = false;
|
||||||
|
|
||||||
export let onSave: ((content: string) => Promise<void>) | null = null;
|
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;
|
export let editing = false;
|
||||||
let editContent = '';
|
let editContent = '';
|
||||||
@@ -350,7 +356,17 @@
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
{:else if fileContent !== null}
|
{: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}
|
{#if overlay}
|
||||||
<div class="absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
<div class="absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in new issue
Block a user