windowpp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/create.js +3 -0
- package/package.json +4 -2
- package/scripts/publish.js +4 -0
- package/scripts/sync-templates.js +238 -0
- package/templates/example/CMakeLists.txt +59 -0
- package/templates/example/frontend/index.html +20 -0
- package/templates/example/frontend/src/API.ts +56 -0
- package/templates/example/frontend/src/App.tsx +781 -0
- package/templates/example/frontend/src/Layout.tsx +5 -0
- package/templates/example/frontend/src/components/ClipboardToast.tsx +23 -0
- package/templates/example/frontend/src/components/FileSearch.tsx +936 -0
- package/templates/example/frontend/src/components/InfiniteScrollList.tsx +267 -0
- package/templates/example/frontend/src/components/index.ts +13 -0
- package/templates/example/frontend/src/filedrop.css +421 -0
- package/templates/example/frontend/src/index.css +1 -0
- package/templates/example/frontend/src/index.tsx +24 -0
- package/templates/example/frontend/src/pages/About.tsx +47 -0
- package/templates/example/frontend/src/pages/Settings.tsx +37 -0
- package/templates/example/frontend/tsconfig.json +20 -0
- package/templates/example/frontend/vite.config.ts +27 -0
- package/templates/example/main.cpp +224 -0
- package/templates/example/package.json +12 -0
- package/templates/solid/CMakeLists.txt +4 -1
- package/templates/solid/frontend/vite.config.ts +3 -1
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Show,
|
|
3
|
+
For,
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
createSignal,
|
|
7
|
+
onCleanup,
|
|
8
|
+
untrack,
|
|
9
|
+
} from 'solid-js';
|
|
10
|
+
import { filesystem } from '../API';
|
|
11
|
+
import { InfiniteScrollList } from './InfiniteScrollList';
|
|
12
|
+
import type {
|
|
13
|
+
ApplicationEntry,
|
|
14
|
+
FileStat,
|
|
15
|
+
SearchStreamHandle,
|
|
16
|
+
SearchStreamSummary,
|
|
17
|
+
} from '../API';
|
|
18
|
+
|
|
19
|
+
export type SearchPreset = {
|
|
20
|
+
label: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
note: string;
|
|
23
|
+
roots?: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SearchMode = 'all' | 'custom';
|
|
27
|
+
|
|
28
|
+
export interface FileSearchProps {
|
|
29
|
+
isOpen: () => boolean;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
availableRoots: () => string[];
|
|
32
|
+
searchPresets?: () => SearchPreset[];
|
|
33
|
+
onResultClick?: (result: FileSearchResult) => void;
|
|
34
|
+
class?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FileSearchResult =
|
|
38
|
+
| {
|
|
39
|
+
kind: 'path';
|
|
40
|
+
path: string;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
kind: 'application';
|
|
44
|
+
application: ApplicationEntry;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RankedSearchResult = {
|
|
48
|
+
item: FileSearchResult;
|
|
49
|
+
score: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type PersistedSearchSession = {
|
|
53
|
+
version: 1;
|
|
54
|
+
savedAt: number;
|
|
55
|
+
query: string;
|
|
56
|
+
includeFiles: boolean;
|
|
57
|
+
includeFolders: boolean;
|
|
58
|
+
includeApps: boolean;
|
|
59
|
+
searchStatus: string;
|
|
60
|
+
results: string[];
|
|
61
|
+
resultStats: Record<string, FileStat>;
|
|
62
|
+
searchSummary: SearchStreamSummary | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const SEARCH_SESSION_STORAGE_KEY = 'windowpp:file-search-session';
|
|
66
|
+
const SEARCH_SESSION_MAX_RESULTS = 200;
|
|
67
|
+
const SEARCH_SESSION_MAX_AGE_MS = 1000 * 60 * 60 * 12;
|
|
68
|
+
|
|
69
|
+
function loadPersistedSearchSession(): PersistedSearchSession | null {
|
|
70
|
+
try {
|
|
71
|
+
const raw = window.localStorage.getItem(SEARCH_SESSION_STORAGE_KEY);
|
|
72
|
+
if (!raw) return null;
|
|
73
|
+
|
|
74
|
+
const parsed = JSON.parse(raw) as Partial<PersistedSearchSession>;
|
|
75
|
+
if (parsed.version !== 1 || typeof parsed.savedAt !== 'number') {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Date.now() - parsed.savedAt > SEARCH_SESSION_MAX_AGE_MS) {
|
|
80
|
+
window.localStorage.removeItem(SEARCH_SESSION_STORAGE_KEY);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
version: 1,
|
|
86
|
+
savedAt: parsed.savedAt,
|
|
87
|
+
query: typeof parsed.query === 'string' ? parsed.query : '',
|
|
88
|
+
includeFiles: parsed.includeFiles ?? true,
|
|
89
|
+
includeFolders: parsed.includeFolders ?? false,
|
|
90
|
+
includeApps: parsed.includeApps ?? true,
|
|
91
|
+
searchStatus: typeof parsed.searchStatus === 'string' ? parsed.searchStatus : 'Type to search the whole system',
|
|
92
|
+
results: Array.isArray(parsed.results) ? parsed.results.filter((value): value is string => typeof value === 'string') : [],
|
|
93
|
+
resultStats: parsed.resultStats && typeof parsed.resultStats === 'object' ? parsed.resultStats : {},
|
|
94
|
+
searchSummary: parsed.searchSummary ?? null,
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function persistSearchSession(session: PersistedSearchSession) {
|
|
102
|
+
try {
|
|
103
|
+
window.localStorage.setItem(SEARCH_SESSION_STORAGE_KEY, JSON.stringify(session));
|
|
104
|
+
} catch {
|
|
105
|
+
// Ignore storage failures.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function FileTypeIcon(props: { class?: string }) {
|
|
110
|
+
return (
|
|
111
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class={props.class ?? 'h-4 w-4'} aria-hidden="true">
|
|
112
|
+
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
|
113
|
+
<path d="M14 3v5h5" />
|
|
114
|
+
<path d="M9 15h6" />
|
|
115
|
+
<path d="M9 11h2" />
|
|
116
|
+
</svg>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function FolderTypeIcon(props: { class?: string }) {
|
|
121
|
+
return (
|
|
122
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class={props.class ?? 'h-4 w-4'} aria-hidden="true">
|
|
123
|
+
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
124
|
+
</svg>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function AppTypeIcon(props: { class?: string }) {
|
|
129
|
+
return (
|
|
130
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class={props.class ?? 'h-4 w-4'} aria-hidden="true">
|
|
131
|
+
<rect x="4" y="4" width="16" height="16" rx="3" />
|
|
132
|
+
<path d="M9 4v16" />
|
|
133
|
+
<path d="M15 4v16" />
|
|
134
|
+
<path d="M4 9h16" />
|
|
135
|
+
<path d="M4 15h16" />
|
|
136
|
+
</svg>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function SearchToggleButton(props: {
|
|
141
|
+
active: boolean;
|
|
142
|
+
label: string;
|
|
143
|
+
onClick: () => void;
|
|
144
|
+
children: any;
|
|
145
|
+
}) {
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
title={props.label}
|
|
150
|
+
aria-label={props.label}
|
|
151
|
+
class={`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border transition-colors ${props.active ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-900'}`}
|
|
152
|
+
onClick={props.onClick}
|
|
153
|
+
>
|
|
154
|
+
{props.children}
|
|
155
|
+
</button>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizePath(path: string): string {
|
|
160
|
+
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function basename(path: string): string {
|
|
164
|
+
const trimmed = path.replace(/[\\/]+$/, '');
|
|
165
|
+
const segments = trimmed.split(/[\\/]/);
|
|
166
|
+
return segments[segments.length - 1] || trimmed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function displayRelativePath(root: string, fullPath: string): string {
|
|
170
|
+
const normalizedRoot = normalizePath(root).replace(/\/+$/, '');
|
|
171
|
+
const normalizedPath = normalizePath(fullPath);
|
|
172
|
+
if (!normalizedRoot) return normalizedPath;
|
|
173
|
+
const rootWithSlash = `${normalizedRoot}/`;
|
|
174
|
+
if (normalizedPath === normalizedRoot) return '.';
|
|
175
|
+
if (normalizedPath.startsWith(rootWithSlash)) {
|
|
176
|
+
return normalizedPath.slice(rootWithSlash.length);
|
|
177
|
+
}
|
|
178
|
+
return normalizedPath;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function queryToPattern(query: string): string {
|
|
182
|
+
const trimmed = query.trim();
|
|
183
|
+
if (!trimmed) return '*';
|
|
184
|
+
if (/[*?[\]]/.test(trimmed)) return trimmed;
|
|
185
|
+
return `*${trimmed}*`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isGlobQuery(query: string): boolean {
|
|
189
|
+
return /[*?[\]]/.test(query);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function splitNameParts(name: string) {
|
|
193
|
+
const lastDot = name.lastIndexOf('.');
|
|
194
|
+
if (lastDot <= 0 || lastDot === name.length - 1) {
|
|
195
|
+
return { stem: name, extension: '' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
stem: name.slice(0, lastDot),
|
|
200
|
+
extension: name.slice(lastDot + 1),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fuzzySubsequenceScore(text: string, query: string): number {
|
|
205
|
+
let score = 0;
|
|
206
|
+
let queryIndex = 0;
|
|
207
|
+
let consecutive = 0;
|
|
208
|
+
|
|
209
|
+
for (let index = 0; index < text.length && queryIndex < query.length; index += 1) {
|
|
210
|
+
if (text[index] !== query[queryIndex]) {
|
|
211
|
+
consecutive = 0;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
score += 8;
|
|
216
|
+
if (index === queryIndex) score += 10;
|
|
217
|
+
if (consecutive > 0) score += 6 * consecutive;
|
|
218
|
+
|
|
219
|
+
consecutive += 1;
|
|
220
|
+
queryIndex += 1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (queryIndex !== query.length) {
|
|
224
|
+
return -1000;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return score - Math.max(0, text.length - query.length);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function rankSearchResult(path: string, query: string, isDirectory: boolean): number {
|
|
231
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
232
|
+
if (!trimmedQuery) return 0;
|
|
233
|
+
|
|
234
|
+
const name = basename(path).toLowerCase();
|
|
235
|
+
const { stem, extension } = splitNameParts(name);
|
|
236
|
+
const normalizedExtensionQuery = trimmedQuery.startsWith('.') ? trimmedQuery.slice(1) : trimmedQuery;
|
|
237
|
+
|
|
238
|
+
let score = 0;
|
|
239
|
+
|
|
240
|
+
if (name === trimmedQuery) score += 2000;
|
|
241
|
+
if (stem === trimmedQuery) score += 1800;
|
|
242
|
+
if (name.startsWith(trimmedQuery)) score += 1400;
|
|
243
|
+
if (stem.startsWith(trimmedQuery)) score += 1300;
|
|
244
|
+
|
|
245
|
+
const nameMatchIndex = name.indexOf(trimmedQuery);
|
|
246
|
+
if (nameMatchIndex >= 0) score += 1100 - Math.min(nameMatchIndex * 20, 400);
|
|
247
|
+
|
|
248
|
+
const stemMatchIndex = stem.indexOf(trimmedQuery);
|
|
249
|
+
if (stemMatchIndex >= 0) score += 950 - Math.min(stemMatchIndex * 20, 300);
|
|
250
|
+
|
|
251
|
+
if (!isDirectory && extension) {
|
|
252
|
+
if (extension === normalizedExtensionQuery) score += 900;
|
|
253
|
+
if (extension.startsWith(normalizedExtensionQuery)) score += 500;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
score += fuzzySubsequenceScore(name, trimmedQuery);
|
|
257
|
+
score += fuzzySubsequenceScore(stem, trimmedQuery) + 50;
|
|
258
|
+
|
|
259
|
+
if (isDirectory) score += 40;
|
|
260
|
+
|
|
261
|
+
return score;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function globToRegExp(pattern: string): RegExp {
|
|
265
|
+
const escaped = pattern
|
|
266
|
+
.replace(/[.+^${}()|\\]/g, '\\$&')
|
|
267
|
+
.replace(/\*/g, '.*')
|
|
268
|
+
.replace(/\?/g, '.');
|
|
269
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function rankApplicationResult(app: ApplicationEntry, query: string): number {
|
|
273
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
274
|
+
if (!trimmedQuery) return 0;
|
|
275
|
+
|
|
276
|
+
const name = app.name.toLowerCase();
|
|
277
|
+
let score = 0;
|
|
278
|
+
if (name === trimmedQuery) score += 2400;
|
|
279
|
+
if (name.startsWith(trimmedQuery)) score += 1500;
|
|
280
|
+
|
|
281
|
+
const nameIndex = name.indexOf(trimmedQuery);
|
|
282
|
+
if (nameIndex >= 0) score += 1200 - Math.min(nameIndex * 20, 400);
|
|
283
|
+
|
|
284
|
+
score += fuzzySubsequenceScore(name, trimmedQuery) + 100;
|
|
285
|
+
return score;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function FileSearch(props: FileSearchProps) {
|
|
289
|
+
const persistedSession = loadPersistedSearchSession();
|
|
290
|
+
const [searchQuery, setSearchQuery] = createSignal(persistedSession?.query ?? '');
|
|
291
|
+
const [includeFiles, setIncludeFiles] = createSignal(persistedSession?.includeFiles ?? true);
|
|
292
|
+
const [includeFolders, setIncludeFolders] = createSignal(persistedSession?.includeFolders ?? false);
|
|
293
|
+
const [includeApps, setIncludeApps] = createSignal(persistedSession?.includeApps ?? true);
|
|
294
|
+
const [searching, setSearching] = createSignal(false);
|
|
295
|
+
const [searchStatus, setSearchStatus] = createSignal(persistedSession?.searchStatus ?? 'Type to search the whole system');
|
|
296
|
+
const [searchSummary, setSearchSummary] = createSignal<SearchStreamSummary | null>(persistedSession?.searchSummary ?? null);
|
|
297
|
+
const [searchResults, setSearchResults] = createSignal<string[]>(persistedSession?.results ?? []);
|
|
298
|
+
const [resultStats, setResultStats] = createSignal<Record<string, FileStat>>(persistedSession?.resultStats ?? {});
|
|
299
|
+
const [visibleVirtualResults, setVisibleVirtualResults] = createSignal<RankedSearchResult[]>([]);
|
|
300
|
+
const [installedApps, setInstalledApps] = createSignal<ApplicationEntry[]>([]);
|
|
301
|
+
const [appsLoaded, setAppsLoaded] = createSignal(false);
|
|
302
|
+
const [appsLoading, setAppsLoading] = createSignal(false);
|
|
303
|
+
|
|
304
|
+
let searchInputRef: HTMLInputElement | undefined;
|
|
305
|
+
let searchToken = 0;
|
|
306
|
+
let activeSearchHandle: SearchStreamHandle | null = null;
|
|
307
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
308
|
+
let flushResultsFrame: number | null = null;
|
|
309
|
+
let persistSessionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
310
|
+
let activeSearchFingerprint = '';
|
|
311
|
+
let lastSettledSearchFingerprint = '';
|
|
312
|
+
let lastIssuedQuery = persistedSession?.query.trim() ?? '';
|
|
313
|
+
let lastIssuedScopeKey = '';
|
|
314
|
+
const pendingStats = new Set<string>();
|
|
315
|
+
const seenSearchResults = new Set<string>();
|
|
316
|
+
const queuedSearchResults: string[] = [];
|
|
317
|
+
let scoreCacheQuery = '';
|
|
318
|
+
const scoreCache = new Map<string, number>();
|
|
319
|
+
|
|
320
|
+
const resetScoreCache = () => {
|
|
321
|
+
scoreCacheQuery = '';
|
|
322
|
+
scoreCache.clear();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
for (const path of searchResults()) {
|
|
326
|
+
seenSearchResults.add(path);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const scopeKeyFor = (roots: string[]) => JSON.stringify({
|
|
330
|
+
roots: roots.map((root) => normalizePath(root)),
|
|
331
|
+
includeFiles: includeFiles(),
|
|
332
|
+
includeFolders: includeFolders(),
|
|
333
|
+
includeApps: includeApps(),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const fingerprintFor = (query: string, roots: string[]) => JSON.stringify({
|
|
337
|
+
query,
|
|
338
|
+
scope: scopeKeyFor(roots),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const pruneStatsToResults = (paths: string[]) => {
|
|
342
|
+
const allowed = new Set(paths);
|
|
343
|
+
setResultStats((current) => {
|
|
344
|
+
const next: Record<string, FileStat> = {};
|
|
345
|
+
for (const [path, stat] of Object.entries(current)) {
|
|
346
|
+
if (allowed.has(path)) {
|
|
347
|
+
next[path] = stat;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return next;
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const matchesRefinedQuery = (path: string, query: string) => {
|
|
355
|
+
const name = basename(path).toLowerCase();
|
|
356
|
+
const trimmed = query.trim().toLowerCase();
|
|
357
|
+
if (!trimmed) return true;
|
|
358
|
+
if (isGlobQuery(trimmed)) {
|
|
359
|
+
return globToRegExp(trimmed).test(name);
|
|
360
|
+
}
|
|
361
|
+
return name.includes(trimmed);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const seedRefinedResults = (query: string, roots: string[]) => {
|
|
365
|
+
const nextScopeKey = scopeKeyFor(roots);
|
|
366
|
+
const canKeepCurrentResults =
|
|
367
|
+
searchResults().length > 0
|
|
368
|
+
&& query === lastIssuedQuery
|
|
369
|
+
&& (lastIssuedScopeKey.length === 0 || nextScopeKey === lastIssuedScopeKey);
|
|
370
|
+
|
|
371
|
+
if (canKeepCurrentResults) {
|
|
372
|
+
seenSearchResults.clear();
|
|
373
|
+
for (const path of searchResults()) {
|
|
374
|
+
seenSearchResults.add(path);
|
|
375
|
+
}
|
|
376
|
+
queuedSearchResults.splice(0, queuedSearchResults.length);
|
|
377
|
+
pruneStatsToResults(searchResults());
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const canReuse =
|
|
382
|
+
!isGlobQuery(query)
|
|
383
|
+
&& !isGlobQuery(lastIssuedQuery)
|
|
384
|
+
&& lastIssuedQuery.length > 0
|
|
385
|
+
&& query.startsWith(lastIssuedQuery)
|
|
386
|
+
&& nextScopeKey === lastIssuedScopeKey
|
|
387
|
+
&& searchResults().length > 0;
|
|
388
|
+
|
|
389
|
+
if (!canReuse) {
|
|
390
|
+
seenSearchResults.clear();
|
|
391
|
+
queuedSearchResults.splice(0, queuedSearchResults.length);
|
|
392
|
+
setSearchResults([]);
|
|
393
|
+
setResultStats({});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const filtered = searchResults().filter((path) => matchesRefinedQuery(path, query));
|
|
398
|
+
seenSearchResults.clear();
|
|
399
|
+
for (const path of filtered) {
|
|
400
|
+
seenSearchResults.add(path);
|
|
401
|
+
}
|
|
402
|
+
queuedSearchResults.splice(0, queuedSearchResults.length);
|
|
403
|
+
setSearchResults(filtered);
|
|
404
|
+
pruneStatsToResults(filtered);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const schedulePersistedSessionWrite = () => {
|
|
408
|
+
if (persistSessionTimer) {
|
|
409
|
+
clearTimeout(persistSessionTimer);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
persistSessionTimer = setTimeout(() => {
|
|
413
|
+
persistSessionTimer = null;
|
|
414
|
+
const persistedResults = searchResults().slice(0, SEARCH_SESSION_MAX_RESULTS);
|
|
415
|
+
const persistedStats: Record<string, FileStat> = {};
|
|
416
|
+
const stats = resultStats();
|
|
417
|
+
for (const path of persistedResults) {
|
|
418
|
+
if (stats[path]) {
|
|
419
|
+
persistedStats[path] = stats[path];
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
persistSearchSession({
|
|
424
|
+
version: 1,
|
|
425
|
+
savedAt: Date.now(),
|
|
426
|
+
query: searchQuery(),
|
|
427
|
+
includeFiles: includeFiles(),
|
|
428
|
+
includeFolders: includeFolders(),
|
|
429
|
+
includeApps: includeApps(),
|
|
430
|
+
searchStatus: searchStatus(),
|
|
431
|
+
results: persistedResults,
|
|
432
|
+
resultStats: persistedStats,
|
|
433
|
+
searchSummary: searchSummary(),
|
|
434
|
+
});
|
|
435
|
+
}, 120);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const flushQueuedResults = () => {
|
|
439
|
+
flushResultsFrame = null;
|
|
440
|
+
if (queuedSearchResults.length === 0) return;
|
|
441
|
+
|
|
442
|
+
const freshResults = queuedSearchResults.splice(0, queuedSearchResults.length);
|
|
443
|
+
untrack(() => {
|
|
444
|
+
setSearchResults(current => [...current, ...freshResults]);
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const enqueueBatch = (batch: string[]) => {
|
|
449
|
+
let appended = false;
|
|
450
|
+
for (const path of batch) {
|
|
451
|
+
if (seenSearchResults.has(path)) continue;
|
|
452
|
+
seenSearchResults.add(path);
|
|
453
|
+
queuedSearchResults.push(path);
|
|
454
|
+
appended = true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!appended || flushResultsFrame !== null) return;
|
|
458
|
+
flushResultsFrame = requestAnimationFrame(() => {
|
|
459
|
+
flushQueuedResults();
|
|
460
|
+
});
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const cancelActiveSearch = (clearDebounce = true) => {
|
|
464
|
+
if (clearDebounce && debounceTimer) {
|
|
465
|
+
clearTimeout(debounceTimer);
|
|
466
|
+
debounceTimer = null;
|
|
467
|
+
}
|
|
468
|
+
if (flushResultsFrame !== null) {
|
|
469
|
+
cancelAnimationFrame(flushResultsFrame);
|
|
470
|
+
flushResultsFrame = null;
|
|
471
|
+
}
|
|
472
|
+
if (activeSearchHandle) {
|
|
473
|
+
void activeSearchHandle.cancel();
|
|
474
|
+
activeSearchHandle = null;
|
|
475
|
+
}
|
|
476
|
+
activeSearchFingerprint = '';
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const resetSearchState = (status = 'Type to search the whole system') => {
|
|
480
|
+
seenSearchResults.clear();
|
|
481
|
+
queuedSearchResults.splice(0, queuedSearchResults.length);
|
|
482
|
+
resetScoreCache();
|
|
483
|
+
setSearching(false);
|
|
484
|
+
setSearchResults([]);
|
|
485
|
+
setSearchSummary(null);
|
|
486
|
+
setSearchStatus(status);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const currentQuery = () => searchQuery().trim();
|
|
490
|
+
|
|
491
|
+
const matchingApps = createMemo(() => {
|
|
492
|
+
if (!includeApps()) return [] as ApplicationEntry[];
|
|
493
|
+
|
|
494
|
+
const query = currentQuery();
|
|
495
|
+
if (!query) return [] as ApplicationEntry[];
|
|
496
|
+
|
|
497
|
+
if (isGlobQuery(query)) {
|
|
498
|
+
const matcher = globToRegExp(query);
|
|
499
|
+
return installedApps().filter((app) => matcher.test(app.name));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return installedApps().filter((app) => rankApplicationResult(app, query) > 0);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const rankedResults = createMemo(() => {
|
|
506
|
+
const query = currentQuery();
|
|
507
|
+
const isGlob = isGlobQuery(query);
|
|
508
|
+
const stats = resultStats();
|
|
509
|
+
const combined: RankedSearchResult[] = [];
|
|
510
|
+
|
|
511
|
+
if (query !== scoreCacheQuery) {
|
|
512
|
+
scoreCacheQuery = query;
|
|
513
|
+
scoreCache.clear();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
for (const path of searchResults()) {
|
|
517
|
+
const stat = stats[path];
|
|
518
|
+
if (!includeFiles() && !includeFolders()) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (stat) {
|
|
522
|
+
if (stat.isDir && !includeFolders()) continue;
|
|
523
|
+
if (!stat.isDir && !includeFiles()) continue;
|
|
524
|
+
} else if (!includeFiles() || !includeFolders()) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const scoreKey = `${path}:${stat?.isDir ? 'dir' : 'file'}`;
|
|
529
|
+
let score = 0;
|
|
530
|
+
if (!isGlob) {
|
|
531
|
+
const cachedScore = scoreCache.get(scoreKey);
|
|
532
|
+
if (cachedScore !== undefined) {
|
|
533
|
+
score = cachedScore;
|
|
534
|
+
} else {
|
|
535
|
+
score = rankSearchResult(path, query, stat?.isDir ?? false);
|
|
536
|
+
scoreCache.set(scoreKey, score);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
combined.push({
|
|
541
|
+
item: { kind: 'path', path },
|
|
542
|
+
score,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
for (const app of matchingApps()) {
|
|
547
|
+
combined.push({
|
|
548
|
+
item: { kind: 'application', application: app },
|
|
549
|
+
score: isGlob ? 0 : rankApplicationResult(app, query),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return combined.sort((left, right) => {
|
|
554
|
+
if (!isGlob && left.score !== right.score) {
|
|
555
|
+
return right.score - left.score;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const leftName = left.item.kind === 'application' ? left.item.application.name : left.item.path;
|
|
559
|
+
const rightName = right.item.kind === 'application' ? right.item.application.name : right.item.path;
|
|
560
|
+
|
|
561
|
+
return leftName.localeCompare(rightName);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
createEffect(() => {
|
|
566
|
+
if (!props.isOpen() || appsLoaded() || appsLoading()) return;
|
|
567
|
+
|
|
568
|
+
setAppsLoading(true);
|
|
569
|
+
void filesystem.listApplications()
|
|
570
|
+
.then((apps) => {
|
|
571
|
+
setInstalledApps(apps);
|
|
572
|
+
setAppsLoaded(true);
|
|
573
|
+
})
|
|
574
|
+
.catch(() => {
|
|
575
|
+
setInstalledApps([]);
|
|
576
|
+
setAppsLoaded(true);
|
|
577
|
+
})
|
|
578
|
+
.finally(() => {
|
|
579
|
+
setAppsLoading(false);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
createEffect(() => {
|
|
584
|
+
if (props.isOpen()) {
|
|
585
|
+
requestAnimationFrame(() => searchInputRef?.focus());
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
createEffect(() => {
|
|
590
|
+
searchQuery();
|
|
591
|
+
includeFiles();
|
|
592
|
+
includeFolders();
|
|
593
|
+
includeApps();
|
|
594
|
+
searchStatus();
|
|
595
|
+
searchSummary();
|
|
596
|
+
searchResults();
|
|
597
|
+
resultStats();
|
|
598
|
+
schedulePersistedSessionWrite();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
createEffect(() => {
|
|
602
|
+
rankedResults();
|
|
603
|
+
setVisibleVirtualResults([]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
onCleanup(() => {
|
|
607
|
+
cancelActiveSearch();
|
|
608
|
+
if (persistSessionTimer) {
|
|
609
|
+
clearTimeout(persistSessionTimer);
|
|
610
|
+
persistSessionTimer = null;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const scheduleSearch = () => {
|
|
615
|
+
const requestId = ++searchToken;
|
|
616
|
+
const trimmedQuery = searchQuery().trim();
|
|
617
|
+
const roots = props.availableRoots();
|
|
618
|
+
const nextFingerprint = fingerprintFor(trimmedQuery, roots);
|
|
619
|
+
const nextScopeKey = scopeKeyFor(roots);
|
|
620
|
+
|
|
621
|
+
if (trimmedQuery && (nextFingerprint === activeSearchFingerprint || nextFingerprint === lastSettledSearchFingerprint)) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
cancelActiveSearch();
|
|
626
|
+
|
|
627
|
+
debounceTimer = setTimeout(async () => {
|
|
628
|
+
debounceTimer = null;
|
|
629
|
+
|
|
630
|
+
if (requestId !== searchToken) return;
|
|
631
|
+
|
|
632
|
+
if (!trimmedQuery) {
|
|
633
|
+
resetSearchState();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!includeFiles() && !includeFolders() && !includeApps()) {
|
|
638
|
+
setSearchResults([]);
|
|
639
|
+
setResultStats({});
|
|
640
|
+
setSearchSummary(null);
|
|
641
|
+
setSearching(false);
|
|
642
|
+
setSearchStatus('Enable at least one search type');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const pattern = queryToPattern(trimmedQuery);
|
|
647
|
+
|
|
648
|
+
if (roots.length === 0 && !includeApps()) {
|
|
649
|
+
setSearchResults([]);
|
|
650
|
+
setResultStats({});
|
|
651
|
+
setSearchSummary(null);
|
|
652
|
+
setSearching(false);
|
|
653
|
+
setSearchStatus('No search roots available');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
seedRefinedResults(trimmedQuery, roots);
|
|
658
|
+
setSearchSummary(null);
|
|
659
|
+
resetScoreCache();
|
|
660
|
+
setSearching(roots.length > 0 && (includeFiles() || includeFolders()));
|
|
661
|
+
setSearchStatus(`Searching for ${trimmedQuery}`);
|
|
662
|
+
lastIssuedQuery = trimmedQuery;
|
|
663
|
+
lastIssuedScopeKey = nextScopeKey;
|
|
664
|
+
activeSearchFingerprint = nextFingerprint;
|
|
665
|
+
lastSettledSearchFingerprint = '';
|
|
666
|
+
|
|
667
|
+
if (roots.length === 0) {
|
|
668
|
+
activeSearchFingerprint = '';
|
|
669
|
+
lastSettledSearchFingerprint = nextFingerprint;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
activeSearchHandle = await filesystem.searchFilesStream(roots, {
|
|
675
|
+
pattern,
|
|
676
|
+
recursive: true,
|
|
677
|
+
includeHidden: false,
|
|
678
|
+
includeDirectories: includeFolders(),
|
|
679
|
+
maxResults: 0,
|
|
680
|
+
}, {
|
|
681
|
+
onBatch: (batch) => {
|
|
682
|
+
if (requestId !== searchToken) return;
|
|
683
|
+
enqueueBatch(batch);
|
|
684
|
+
},
|
|
685
|
+
onDone: (summary) => {
|
|
686
|
+
if (requestId !== searchToken) return;
|
|
687
|
+
|
|
688
|
+
activeSearchHandle = null;
|
|
689
|
+
activeSearchFingerprint = '';
|
|
690
|
+
lastSettledSearchFingerprint = nextFingerprint;
|
|
691
|
+
flushQueuedResults();
|
|
692
|
+
setSearching(false);
|
|
693
|
+
|
|
694
|
+
setSearchSummary(summary);
|
|
695
|
+
|
|
696
|
+
if (summary.error) {
|
|
697
|
+
setSearchStatus(`Error: ${summary.error}`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (summary.cancelled) {
|
|
702
|
+
if (requestId === searchToken) {
|
|
703
|
+
setSearchStatus('Cancelled');
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (summary.matchedEntries === 0) {
|
|
709
|
+
setSearchStatus(matchingApps().length > 0 ? `${matchingApps().length} app matches` : `No matches for ${trimmedQuery}`);
|
|
710
|
+
} else {
|
|
711
|
+
const total = summary.matchedEntries + matchingApps().length;
|
|
712
|
+
setSearchStatus(`${total} matches`);
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (requestId !== searchToken && activeSearchHandle) {
|
|
718
|
+
void activeSearchHandle.cancel();
|
|
719
|
+
activeSearchHandle = null;
|
|
720
|
+
activeSearchFingerprint = '';
|
|
721
|
+
}
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (requestId !== searchToken) return;
|
|
724
|
+
|
|
725
|
+
setSearchResults([]);
|
|
726
|
+
setSearchSummary(null);
|
|
727
|
+
setSearchStatus(error instanceof Error ? error.message : 'Failed');
|
|
728
|
+
activeSearchHandle = null;
|
|
729
|
+
activeSearchFingerprint = '';
|
|
730
|
+
}
|
|
731
|
+
}, 35);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
createEffect(() => {
|
|
735
|
+
if (!props.isOpen()) return;
|
|
736
|
+
searchQuery();
|
|
737
|
+
includeFiles();
|
|
738
|
+
includeFolders();
|
|
739
|
+
includeApps();
|
|
740
|
+
props.availableRoots();
|
|
741
|
+
scheduleSearch();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
createEffect(() => {
|
|
745
|
+
const stats = resultStats();
|
|
746
|
+
for (const result of visibleVirtualResults()) {
|
|
747
|
+
if (result.item.kind !== 'path') continue;
|
|
748
|
+
const path = result.item.path;
|
|
749
|
+
if (stats[path] || pendingStats.has(path)) continue;
|
|
750
|
+
|
|
751
|
+
pendingStats.add(path);
|
|
752
|
+
void filesystem.stat(path)
|
|
753
|
+
.then((stat) => {
|
|
754
|
+
setResultStats((current) => {
|
|
755
|
+
if (current[path]) return current;
|
|
756
|
+
return { ...current, [path]: stat };
|
|
757
|
+
});
|
|
758
|
+
})
|
|
759
|
+
.finally(() => {
|
|
760
|
+
pendingStats.delete(path);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const resultCount = () => rankedResults().length;
|
|
766
|
+
const labelForResult = (result: FileSearchResult) => {
|
|
767
|
+
if (result.kind === 'application') return 'App';
|
|
768
|
+
|
|
769
|
+
const path = result.path;
|
|
770
|
+
const stat = resultStats()[path];
|
|
771
|
+
if (stat?.isDir) return 'Folder';
|
|
772
|
+
|
|
773
|
+
const extension = splitNameParts(basename(path)).extension;
|
|
774
|
+
return extension ? `${extension.toUpperCase()} file` : 'File';
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const nameForResult = (result: FileSearchResult) => {
|
|
778
|
+
return result.kind === 'application' ? result.application.name : basename(result.path);
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const pathForResult = (result: FileSearchResult) => {
|
|
782
|
+
return result.kind === 'application' ? result.application.path : displayRelativePath('', result.path);
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const iconForResult = (result: FileSearchResult) => {
|
|
786
|
+
if (result.kind === 'application') {
|
|
787
|
+
return <AppTypeIcon class="h-4 w-4" />;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const stat = resultStats()[result.path];
|
|
791
|
+
if (stat?.isDir) {
|
|
792
|
+
return <FolderTypeIcon class="h-4 w-4" />;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return <FileTypeIcon class="h-4 w-4" />;
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
return (
|
|
799
|
+
<Show when={props.isOpen()}>
|
|
800
|
+
<div class={`fixed inset-0 z-40 flex items-start justify-center bg-slate-950/40 px-4 py-12 backdrop-blur-sm ${props.class ?? ''}`} onClick={props.onClose}>
|
|
801
|
+
<div class="w-full max-w-4xl overflow-hidden rounded-3xl border border-white/60 bg-white shadow-2xl shadow-slate-950/30" onClick={(e) => e.stopPropagation()}>
|
|
802
|
+
<div class="flex h-128 flex-col">
|
|
803
|
+
<div class="border-b border-slate-100 px-4 py-4">
|
|
804
|
+
<div class="flex items-center gap-3">
|
|
805
|
+
<input
|
|
806
|
+
class="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-800 outline-none transition-colors focus:border-slate-400"
|
|
807
|
+
ref={searchInputRef}
|
|
808
|
+
value={searchQuery()}
|
|
809
|
+
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
|
810
|
+
placeholder="Type a filename or pattern like *.ts or *config*"
|
|
811
|
+
/>
|
|
812
|
+
<div class="flex items-center gap-2">
|
|
813
|
+
<SearchToggleButton active={includeFiles()} label="Toggle files" onClick={() => setIncludeFiles((value) => !value)}>
|
|
814
|
+
<FileTypeIcon class="h-4 w-4" />
|
|
815
|
+
</SearchToggleButton>
|
|
816
|
+
<SearchToggleButton active={includeFolders()} label="Toggle folders" onClick={() => setIncludeFolders((value) => !value)}>
|
|
817
|
+
<FolderTypeIcon class="h-4 w-4" />
|
|
818
|
+
</SearchToggleButton>
|
|
819
|
+
<SearchToggleButton active={includeApps()} label="Toggle applications" onClick={() => setIncludeApps((value) => !value)}>
|
|
820
|
+
<AppTypeIcon class="h-4 w-4" />
|
|
821
|
+
</SearchToggleButton>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
827
|
+
<div class="flex-1 overflow-y-auto px-2 py-1">
|
|
828
|
+
<Show when={resultCount() > 0} fallback={
|
|
829
|
+
<div class="flex h-full items-center justify-center text-slate-400 text-sm">
|
|
830
|
+
{searching() ? (
|
|
831
|
+
<div class="flex items-center gap-2">
|
|
832
|
+
<span class="animate-spin inline-block">⏳</span>
|
|
833
|
+
<span>Searching...</span>
|
|
834
|
+
</div>
|
|
835
|
+
) : (
|
|
836
|
+
<div class="text-center">
|
|
837
|
+
<div>{searchStatus()}</div>
|
|
838
|
+
</div>
|
|
839
|
+
)}
|
|
840
|
+
</div>
|
|
841
|
+
}>
|
|
842
|
+
<InfiniteScrollList
|
|
843
|
+
items={rankedResults}
|
|
844
|
+
hasMore={() => false}
|
|
845
|
+
onLoadMore={() => {}}
|
|
846
|
+
isLoading={searching}
|
|
847
|
+
itemHeight={62}
|
|
848
|
+
overscan={10}
|
|
849
|
+
class="h-full"
|
|
850
|
+
endOfListMessage={`${resultCount()} results`}
|
|
851
|
+
onVisibleRangeChange={({ items }) => setVisibleVirtualResults([...items])}
|
|
852
|
+
>
|
|
853
|
+
{(result, index) => (
|
|
854
|
+
<div class="cursor-pointer border-b border-slate-100 px-3 py-2.5 transition-colors hover:bg-slate-50" onClick={() => props.onResultClick?.(result.item)}>
|
|
855
|
+
<div class="flex items-center gap-3">
|
|
856
|
+
<span class="w-5 shrink-0 text-right font-mono text-xs text-slate-400">
|
|
857
|
+
{untrack(() => index()) + 1}
|
|
858
|
+
</span>
|
|
859
|
+
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
|
860
|
+
{iconForResult(result.item)}
|
|
861
|
+
</span>
|
|
862
|
+
<span class="truncate font-medium text-slate-800 flex-1 min-w-0">
|
|
863
|
+
{nameForResult(result.item)}
|
|
864
|
+
</span>
|
|
865
|
+
<span class="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600">
|
|
866
|
+
{labelForResult(result.item)}
|
|
867
|
+
</span>
|
|
868
|
+
</div>
|
|
869
|
+
<div class="pl-8 pt-1 font-mono text-xs text-slate-500">
|
|
870
|
+
{pathForResult(result.item)}
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
)}
|
|
874
|
+
</InfiniteScrollList>
|
|
875
|
+
</Show>
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
</Show>
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export interface FileSearchButtonProps {
|
|
886
|
+
onClick?: () => void;
|
|
887
|
+
label?: string;
|
|
888
|
+
class?: string;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function FileSearchButton(props: FileSearchButtonProps) {
|
|
892
|
+
const shortcutLabel = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Cmd+K' : 'Ctrl+K';
|
|
893
|
+
|
|
894
|
+
return (
|
|
895
|
+
<button class={`fixed right-4 top-4 z-30 flex items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 py-1.5 text-sm font-medium text-slate-600 shadow-sm backdrop-blur hover:border-slate-300 hover:text-slate-900 transition-all ${props.class ?? ''}`} onClick={props.onClick} type="button">
|
|
896
|
+
<span>{props.label ?? 'Search'}</span>
|
|
897
|
+
<kbd class="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-mono text-slate-500">
|
|
898
|
+
{shortcutLabel}
|
|
899
|
+
</kbd>
|
|
900
|
+
</button>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export interface PresetsPanelProps {
|
|
905
|
+
presets: () => SearchPreset[];
|
|
906
|
+
onPresetClick: (preset: SearchPreset) => void;
|
|
907
|
+
class?: string;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export function PresetsPanel(props: PresetsPanelProps) {
|
|
911
|
+
return (
|
|
912
|
+
<div class={`rounded-2xl border border-slate-800 bg-slate-900 p-5 text-slate-100 shadow-xl ${props.class ?? ''}`}>
|
|
913
|
+
<div class="mb-3 flex items-center justify-between">
|
|
914
|
+
<h3 class="font-semibold">Quick Access</h3>
|
|
915
|
+
<span class="text-xs bg-white/10 px-2 py-0.5 rounded font-mono">LIVE</span>
|
|
916
|
+
</div>
|
|
917
|
+
<div class="space-y-2">
|
|
918
|
+
<For each={props.presets()}>
|
|
919
|
+
{(preset) => (
|
|
920
|
+
<button class="w-full flex items-center justify-between px-3 py-2 text-left text-sm rounded-lg border border-slate-200 hover:border-slate-300 bg-slate-50 hover:bg-slate-100 transition-all" onClick={() => props.onPresetClick(preset)} type="button">
|
|
921
|
+
<span class="font-medium">{preset.label}</span>
|
|
922
|
+
<span class="text-xs font-mono opacity-70">{preset.path ?? preset.roots?.length ?? ''}</span>
|
|
923
|
+
</button>
|
|
924
|
+
)}
|
|
925
|
+
</For>
|
|
926
|
+
</div>
|
|
927
|
+
<div class="mt-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-300">
|
|
928
|
+
<div class="font-semibold">Tips</div>
|
|
929
|
+
<ul class="mt-2 space-y-1 text-xs">
|
|
930
|
+
<li>Search updates as you type</li>
|
|
931
|
+
<li>Use glob: *.ts, *config*</li>
|
|
932
|
+
</ul>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
);
|
|
936
|
+
}
|