@brftech/filex-core 0.1.84 → 0.2.0
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/dist/{ArchiveViewer-wg4uE-3C.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
- package/dist/{ArchiveViewer-wg4uE-3C.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
- package/dist/{CsvViewer-57uz4UKk.js → CsvViewer-DMvnp82x.js} +2 -2
- package/dist/{CsvViewer-57uz4UKk.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
- package/dist/{DrawioViewer-BfL9Hbyp.js → DrawioViewer-B5Io0yTO.js} +2 -2
- package/dist/{DrawioViewer-BfL9Hbyp.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
- package/dist/{EpubViewer-CYG2CV_3.js → EpubViewer-z6RZd3K8.js} +2 -2
- package/dist/{EpubViewer-CYG2CV_3.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
- package/dist/{IpynbViewer-DJw34-wV.js → IpynbViewer-DfL4EauH.js} +2 -2
- package/dist/{IpynbViewer-DJw34-wV.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
- package/dist/{MermaidViewer-C86jjm27.js → MermaidViewer-QNl_o7V-.js} +2 -2
- package/dist/{MermaidViewer-C86jjm27.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
- package/dist/{PsdViewer-DGHIqv-x.js → PsdViewer-Btmpr0Fz.js} +2 -2
- package/dist/{PsdViewer-DGHIqv-x.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
- package/dist/{TiffViewer-gct9Ub3o.js → TiffViewer-CMLpwds5.js} +2 -2
- package/dist/{TiffViewer-gct9Ub3o.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
- package/dist/{Viewer3D-BVt4l2a9.js → Viewer3D-gf3cb2gv.js} +2 -2
- package/dist/{Viewer3D-BVt4l2a9.js.map → Viewer3D-gf3cb2gv.js.map} +1 -1
- package/dist/filex-core.js +17 -15
- package/dist/filex-core.umd.cjs +38 -38
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-D49iw00E.js +7119 -0
- package/dist/index-D49iw00E.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +47 -1
- package/src/components/CommandPalette.vue +239 -5
- package/src/components/ListView.vue +26 -1
- package/src/composables/useFileApi.ts +45 -0
- package/src/index.ts +4 -0
- package/src/lib/snippet.ts +43 -0
- package/src/locales/en.ts +8 -0
- package/src/locales/tr.ts +8 -0
- package/src/styles/base.css +101 -0
- package/dist/index-BeUoODQq.js +0 -6871
- package/dist/index-BeUoODQq.js.map +0 -1
|
@@ -12,10 +12,20 @@
|
|
|
12
12
|
* Keyboard: ↑/↓ move, Enter selects, Esc closes; the listener sits on the
|
|
13
13
|
* document in CAPTURE phase so it wins over useKeyboardShortcuts' window
|
|
14
14
|
* handler (otherwise Enter would also fire the explorer's own onOpen).
|
|
15
|
+
*
|
|
16
|
+
* bul:s3 additions:
|
|
17
|
+
* 4. "Everywhere" group — ≥3-char queries also hit the global files-search
|
|
18
|
+
* endpoint (debounced 250ms, max 8 rows) via the `globalSearch` prop.
|
|
19
|
+
* Content matches get an "İçerikte" badge + a «»-highlighted snippet
|
|
20
|
+
* rendered as pure text nodes (no innerHTML).
|
|
21
|
+
* 5. Saved searches — localStorage-backed (`filex.saved-searches`, max 10);
|
|
22
|
+
* "save current query" command + per-row delete.
|
|
15
23
|
*/
|
|
16
24
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
17
25
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
18
26
|
import type { FileNode, ViewMode } from '../types/FileNode';
|
|
27
|
+
import type { GlobalSearchHit } from '../composables/useFileApi';
|
|
28
|
+
import { matchedInContent, snippetSegments } from '../lib/snippet';
|
|
19
29
|
import { useLocale } from '../composables/useLocale';
|
|
20
30
|
|
|
21
31
|
const props = defineProps<{
|
|
@@ -27,6 +37,8 @@ const props = defineProps<{
|
|
|
27
37
|
canWrite?: boolean;
|
|
28
38
|
/** Gates the "up one level" command. */
|
|
29
39
|
canGoUp?: boolean;
|
|
40
|
+
/* bul:s3 — global search callback; absent = older host, group hidden. */
|
|
41
|
+
globalSearch?: (q: string) => Promise<GlobalSearchHit[]>;
|
|
30
42
|
}>();
|
|
31
43
|
|
|
32
44
|
const emit = defineEmits<{
|
|
@@ -39,6 +51,8 @@ const emit = defineEmits<{
|
|
|
39
51
|
(e: 'open-trash'): void;
|
|
40
52
|
(e: 'refresh'): void;
|
|
41
53
|
(e: 'go-up'): void;
|
|
54
|
+
/* bul:s3 — an "everywhere" hit was chosen. */
|
|
55
|
+
(e: 'open-hit', hit: GlobalSearchHit): void;
|
|
42
56
|
}>();
|
|
43
57
|
|
|
44
58
|
const { t, nodeDisplayName } = useLocale(() => props.locale);
|
|
@@ -51,7 +65,15 @@ const listEl = ref<HTMLElement | null>(null);
|
|
|
51
65
|
type PaletteItem =
|
|
52
66
|
| { kind: 'goto'; id: string; label: string; icon: string; path: string }
|
|
53
67
|
| { kind: 'file'; id: string; label: string; icon: string; node: FileNode }
|
|
54
|
-
| { kind: 'command'; id: string; label: string; icon: string; command: string }
|
|
68
|
+
| { kind: 'command'; id: string; label: string; icon: string; command: string }
|
|
69
|
+
/* bul:s3 — everywhere hit / saved search / save-current-query. */
|
|
70
|
+
| { kind: 'hit'; id: string; label: string; icon: string; hit: GlobalSearchHit; crumb: string; inContent: boolean; snippet: string }
|
|
71
|
+
| { kind: 'saved'; id: string; label: string; icon: string; query: string }
|
|
72
|
+
| { kind: 'save'; id: string; label: string; icon: string; query: string };
|
|
73
|
+
|
|
74
|
+
/* bul:s3 — narrowed aliases so the template doesn't need kind-guards. */
|
|
75
|
+
type HitItem = Extract<PaletteItem, { kind: 'hit' }>;
|
|
76
|
+
type SavedGroupItem = Extract<PaletteItem, { kind: 'saved' | 'save' }>;
|
|
55
77
|
|
|
56
78
|
/**
|
|
57
79
|
* Scored includes: -1 = no match; otherwise earlier + prefix matches rank
|
|
@@ -110,16 +132,141 @@ const commandItems = computed<PaletteItem[]>(() => {
|
|
|
110
132
|
.map((d) => ({ kind: 'command' as const, id: `cmd:${d.command}`, label: d.label, icon: d.icon, command: d.command }));
|
|
111
133
|
});
|
|
112
134
|
|
|
135
|
+
/* === bul:s3 — "everywhere" global search ================================ */
|
|
136
|
+
|
|
137
|
+
const EVERYWHERE_MIN_CHARS = 3;
|
|
138
|
+
const EVERYWHERE_LIMIT = 8;
|
|
139
|
+
const EVERYWHERE_DEBOUNCE_MS = 250;
|
|
140
|
+
|
|
141
|
+
const everywhere = ref<GlobalSearchHit[]>([]);
|
|
142
|
+
const everywhereLoading = ref(false);
|
|
143
|
+
let everywhereTimer: ReturnType<typeof setTimeout> | undefined;
|
|
144
|
+
let everywhereSeq = 0;
|
|
145
|
+
|
|
146
|
+
function scheduleEverywhere(q: string) {
|
|
147
|
+
if (everywhereTimer) clearTimeout(everywhereTimer);
|
|
148
|
+
const fn = props.globalSearch;
|
|
149
|
+
if (!fn || q.length < EVERYWHERE_MIN_CHARS) {
|
|
150
|
+
everywhere.value = [];
|
|
151
|
+
everywhereLoading.value = false;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
everywhereLoading.value = true;
|
|
155
|
+
everywhereTimer = setTimeout(async () => {
|
|
156
|
+
const seq = ++everywhereSeq;
|
|
157
|
+
try {
|
|
158
|
+
const hits = await fn(q);
|
|
159
|
+
if (seq !== everywhereSeq) return; // stale response — a newer query won
|
|
160
|
+
everywhere.value = Array.isArray(hits) ? hits.slice(0, EVERYWHERE_LIMIT) : [];
|
|
161
|
+
} catch {
|
|
162
|
+
if (seq === everywhereSeq) everywhere.value = [];
|
|
163
|
+
} finally {
|
|
164
|
+
if (seq === everywhereSeq) everywhereLoading.value = false;
|
|
165
|
+
}
|
|
166
|
+
}, EVERYWHERE_DEBOUNCE_MS);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Parent-path crumb for a hit row (storage label when the hit carries one). */
|
|
170
|
+
function hitCrumb(h: GlobalSearchHit): string {
|
|
171
|
+
const rel = String(h.path ?? '').replace(/^\/+|\/+$/g, '');
|
|
172
|
+
const slash = rel.lastIndexOf('/');
|
|
173
|
+
const parent = slash === -1 ? '' : rel.slice(0, slash);
|
|
174
|
+
const storage =
|
|
175
|
+
(typeof h.storage === 'string' && h.storage) ||
|
|
176
|
+
(typeof h.storage_name === 'string' && h.storage_name) ||
|
|
177
|
+
'';
|
|
178
|
+
if (storage && parent) return `${storage}/${parent}`;
|
|
179
|
+
if (storage) return storage;
|
|
180
|
+
return parent || '/';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hitItems = computed<HitItem[]>(() =>
|
|
184
|
+
everywhere.value.map((h) => ({
|
|
185
|
+
kind: 'hit' as const,
|
|
186
|
+
id: `hit:${h.storage_id ?? ''}:${h.path ?? ''}:${h.id ?? ''}`,
|
|
187
|
+
label: String(h.name ?? h.path ?? ''),
|
|
188
|
+
icon: h.type === 'dir' ? '📁' : '📄',
|
|
189
|
+
hit: h,
|
|
190
|
+
crumb: hitCrumb(h),
|
|
191
|
+
inContent: matchedInContent(h.matched),
|
|
192
|
+
snippet: typeof h.snippet === 'string' ? h.snippet : '',
|
|
193
|
+
})),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
/* === bul:s3 — saved searches (localStorage) ============================= */
|
|
197
|
+
|
|
198
|
+
const SAVED_LS_KEY = 'filex.saved-searches';
|
|
199
|
+
const SAVED_MAX = 10;
|
|
200
|
+
|
|
201
|
+
const savedSearches = ref<string[]>([]);
|
|
202
|
+
|
|
203
|
+
function readSavedSearches(): string[] {
|
|
204
|
+
try {
|
|
205
|
+
const raw = localStorage.getItem(SAVED_LS_KEY);
|
|
206
|
+
if (!raw) return [];
|
|
207
|
+
const parsed: unknown = JSON.parse(raw);
|
|
208
|
+
if (!Array.isArray(parsed)) return [];
|
|
209
|
+
return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '').slice(0, SAVED_MAX);
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function writeSavedSearches(list: string[]) {
|
|
216
|
+
savedSearches.value = list.slice(0, SAVED_MAX);
|
|
217
|
+
try {
|
|
218
|
+
localStorage.setItem(SAVED_LS_KEY, JSON.stringify(savedSearches.value));
|
|
219
|
+
} catch {
|
|
220
|
+
/* private mode / quota — the in-memory list still works this session */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function saveSearch(q: string) {
|
|
225
|
+
const clean = q.trim();
|
|
226
|
+
if (!clean) return;
|
|
227
|
+
const list = savedSearches.value.filter((s) => s !== clean);
|
|
228
|
+
list.unshift(clean); // newest first; cap enforced by writeSavedSearches
|
|
229
|
+
writeSavedSearches(list);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function removeSavedSearch(q: string) {
|
|
233
|
+
writeSavedSearches(savedSearches.value.filter((s) => s !== q));
|
|
234
|
+
if (active.value >= flat.value.length) active.value = Math.max(0, flat.value.length - 1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const savedItems = computed<SavedGroupItem[]>(() => {
|
|
238
|
+
const q = query.value.trim();
|
|
239
|
+
const out: SavedGroupItem[] = [];
|
|
240
|
+
// "Save this query" command — only when there IS a query and it isn't saved yet.
|
|
241
|
+
if (q && !savedSearches.value.includes(q)) {
|
|
242
|
+
out.push({ kind: 'save', id: `save:${q}`, label: `${t('palette.save')}: ${q}`, icon: '💾', query: q });
|
|
243
|
+
}
|
|
244
|
+
const scored = savedSearches.value
|
|
245
|
+
.map((s) => ({ s, sc: matchScore(s, q) }))
|
|
246
|
+
.filter((r) => r.sc >= 0 || !q);
|
|
247
|
+
for (const r of scored) {
|
|
248
|
+
out.push({ kind: 'saved', id: `saved:${r.s}`, label: r.s, icon: '🔖', query: r.s });
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/* ======================================================================== */
|
|
254
|
+
|
|
113
255
|
const flat = computed<PaletteItem[]>(() => [
|
|
114
256
|
...gotoItems.value,
|
|
115
257
|
...fileItems.value,
|
|
258
|
+
...hitItems.value,
|
|
259
|
+
...savedItems.value,
|
|
116
260
|
...commandItems.value,
|
|
117
261
|
]);
|
|
118
262
|
const fileOffset = computed(() => gotoItems.value.length);
|
|
119
|
-
const
|
|
263
|
+
const hitOffset = computed(() => fileOffset.value + fileItems.value.length);
|
|
264
|
+
const savedOffset = computed(() => hitOffset.value + hitItems.value.length);
|
|
265
|
+
const cmdOffset = computed(() => savedOffset.value + savedItems.value.length);
|
|
120
266
|
|
|
121
|
-
watch(query, () => {
|
|
267
|
+
watch(query, (q) => {
|
|
122
268
|
active.value = 0;
|
|
269
|
+
scheduleEverywhere(q.trim()); /* bul:s3 */
|
|
123
270
|
});
|
|
124
271
|
|
|
125
272
|
function move(delta: number) {
|
|
@@ -134,7 +281,23 @@ function move(delta: number) {
|
|
|
134
281
|
function choose(item?: PaletteItem) {
|
|
135
282
|
const it = item ?? flat.value[active.value];
|
|
136
283
|
if (!it) return;
|
|
284
|
+
/* bul:s3 — saved-search rows keep the palette OPEN: picking one re-runs
|
|
285
|
+
* the palette with that query; saving just persists it. */
|
|
286
|
+
if (it.kind === 'saved') {
|
|
287
|
+
query.value = it.query;
|
|
288
|
+
void nextTick(() => inputEl.value?.focus());
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (it.kind === 'save') {
|
|
292
|
+
saveSearch(it.query);
|
|
293
|
+
void nextTick(() => inputEl.value?.focus());
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
137
296
|
emit('close');
|
|
297
|
+
if (it.kind === 'hit') {
|
|
298
|
+
emit('open-hit', it.hit);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
138
301
|
if (it.kind === 'file') {
|
|
139
302
|
emit('open-node', it.node);
|
|
140
303
|
} else if (it.kind === 'goto') {
|
|
@@ -183,15 +346,23 @@ watch(
|
|
|
183
346
|
if (v) {
|
|
184
347
|
query.value = '';
|
|
185
348
|
active.value = 0;
|
|
349
|
+
/* bul:s3 — fresh session state for the new groups. */
|
|
350
|
+
everywhere.value = [];
|
|
351
|
+
everywhereLoading.value = false;
|
|
352
|
+
savedSearches.value = readSavedSearches();
|
|
186
353
|
document.addEventListener('keydown', onDocKeydown, true);
|
|
187
354
|
void nextTick(() => inputEl.value?.focus());
|
|
188
355
|
} else {
|
|
356
|
+
if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
|
|
189
357
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
190
358
|
}
|
|
191
359
|
},
|
|
192
360
|
);
|
|
193
361
|
|
|
194
|
-
onBeforeUnmount(() =>
|
|
362
|
+
onBeforeUnmount(() => {
|
|
363
|
+
if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
|
|
364
|
+
document.removeEventListener('keydown', onDocKeydown, true);
|
|
365
|
+
});
|
|
195
366
|
</script>
|
|
196
367
|
|
|
197
368
|
<template>
|
|
@@ -269,6 +440,69 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onDocKeydown, true
|
|
|
269
440
|
</button>
|
|
270
441
|
</template>
|
|
271
442
|
|
|
443
|
+
<!-- bul:s3 — "Everywhere" global-search hits -->
|
|
444
|
+
<template v-if="hitItems.length || everywhereLoading">
|
|
445
|
+
<div class="fe-cmdp__group">{{ t('palette.everywhere') }}</div>
|
|
446
|
+
<div v-if="everywhereLoading && !hitItems.length" class="fe-cmdp__loading">
|
|
447
|
+
{{ t('palette.searching') }}
|
|
448
|
+
</div>
|
|
449
|
+
<button
|
|
450
|
+
v-for="(it, i) in hitItems"
|
|
451
|
+
:key="it.id"
|
|
452
|
+
type="button"
|
|
453
|
+
class="fe-cmdp__item fe-cmdp__item--hit"
|
|
454
|
+
:class="{ 'is-active': hitOffset + i === active }"
|
|
455
|
+
role="option"
|
|
456
|
+
:aria-selected="hitOffset + i === active"
|
|
457
|
+
@mouseenter="active = hitOffset + i"
|
|
458
|
+
@click="choose(it)"
|
|
459
|
+
>
|
|
460
|
+
<span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
|
|
461
|
+
<span class="fe-cmdp__hitbody">
|
|
462
|
+
<span class="fe-cmdp__hitline">
|
|
463
|
+
<span class="fe-cmdp__label">{{ it.label }}</span>
|
|
464
|
+
<span v-if="it.inContent" class="fe-cmdp__badge">{{ t('search.in_content') }}</span>
|
|
465
|
+
</span>
|
|
466
|
+
<span v-if="it.crumb" class="fe-cmdp__crumb" :title="it.crumb">{{ it.crumb }}</span>
|
|
467
|
+
<!-- Snippet: «»-highlights become <mark> via TEXT segments — never innerHTML. -->
|
|
468
|
+
<span v-if="it.snippet" class="fe-cmdp__snippet">
|
|
469
|
+
<template v-for="(seg, si) in snippetSegments(it.snippet)" :key="si">
|
|
470
|
+
<mark v-if="seg.match" class="fe-cmdp__mark">{{ seg.text }}</mark>
|
|
471
|
+
<template v-else>{{ seg.text }}</template>
|
|
472
|
+
</template>
|
|
473
|
+
</span>
|
|
474
|
+
</span>
|
|
475
|
+
</button>
|
|
476
|
+
</template>
|
|
477
|
+
|
|
478
|
+
<!-- bul:s3 — saved searches -->
|
|
479
|
+
<template v-if="savedItems.length">
|
|
480
|
+
<div class="fe-cmdp__group">{{ t('palette.saved') }}</div>
|
|
481
|
+
<div
|
|
482
|
+
v-for="(it, i) in savedItems"
|
|
483
|
+
:key="it.id"
|
|
484
|
+
class="fe-cmdp__item fe-cmdp__item--saved"
|
|
485
|
+
:class="{ 'is-active': savedOffset + i === active }"
|
|
486
|
+
role="option"
|
|
487
|
+
:aria-selected="savedOffset + i === active"
|
|
488
|
+
tabindex="-1"
|
|
489
|
+
@mouseenter="active = savedOffset + i"
|
|
490
|
+
@click="choose(it)"
|
|
491
|
+
@keydown.enter.prevent="choose(it)"
|
|
492
|
+
>
|
|
493
|
+
<span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
|
|
494
|
+
<span class="fe-cmdp__label">{{ it.label }}</span>
|
|
495
|
+
<button
|
|
496
|
+
v-if="it.kind === 'saved'"
|
|
497
|
+
type="button"
|
|
498
|
+
class="fe-cmdp__del"
|
|
499
|
+
:title="t('palette.saved.delete')"
|
|
500
|
+
:aria-label="t('palette.saved.delete')"
|
|
501
|
+
@click.stop="removeSavedSearch(it.query)"
|
|
502
|
+
>🗑</button>
|
|
503
|
+
</div>
|
|
504
|
+
</template>
|
|
505
|
+
|
|
272
506
|
<template v-if="commandItems.length">
|
|
273
507
|
<div class="fe-cmdp__group">{{ t('palette.commands') }}</div>
|
|
274
508
|
<button
|
|
@@ -287,7 +521,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onDocKeydown, true
|
|
|
287
521
|
</button>
|
|
288
522
|
</template>
|
|
289
523
|
|
|
290
|
-
<div v-if="flat.length === 0" class="fe-cmdp__empty">
|
|
524
|
+
<div v-if="flat.length === 0 && !everywhereLoading" class="fe-cmdp__empty">
|
|
291
525
|
{{ t('palette.empty') }}
|
|
292
526
|
</div>
|
|
293
527
|
</div>
|
|
@@ -11,6 +11,7 @@ import type { FileNode } from '../types/FileNode';
|
|
|
11
11
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
12
12
|
import { useLocale } from '../composables/useLocale';
|
|
13
13
|
import { fileIconSvg } from '../lib/fileIcons';
|
|
14
|
+
import { matchedInContent, snippetSegments } from '../lib/snippet'; /* bul:s3 */
|
|
14
15
|
import StarButton from './StarButton.vue';
|
|
15
16
|
|
|
16
17
|
const props = defineProps<{
|
|
@@ -137,6 +138,19 @@ function parentDir(path: string): string {
|
|
|
137
138
|
return stripped.slice(0, idx);
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/* bul:s3 — search-result enrichment. The v0.2 backend inlines `snippet`
|
|
142
|
+
* (plain text, «» highlights) + `matched` on search hits; regular listings
|
|
143
|
+
* never carry them, so presence-gating keeps normal rows untouched and an
|
|
144
|
+
* older backend simply renders nothing extra. */
|
|
145
|
+
function rowSnippet(n: FileNode): string {
|
|
146
|
+
const s = (n as Record<string, unknown>).snippet;
|
|
147
|
+
return typeof s === 'string' ? s : '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rowInContent(n: FileNode): boolean {
|
|
151
|
+
return matchedInContent((n as Record<string, unknown>).matched);
|
|
152
|
+
}
|
|
153
|
+
|
|
140
154
|
// ------------------------------------------------------------------
|
|
141
155
|
// Column sorting — local to the list view. Default (null) keeps the
|
|
142
156
|
// backend order, i.e. exactly the pre-existing behavior.
|
|
@@ -342,12 +356,23 @@ const segments = computed<Segment[]>(() => {
|
|
|
342
356
|
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
343
357
|
<span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></span>
|
|
344
358
|
<div class="fe-list__name-wrap">
|
|
345
|
-
<span class="fe-list__name" :title="n.basename">
|
|
359
|
+
<span class="fe-list__name" :title="n.basename">
|
|
360
|
+
{{ nodeDisplayName(n) }}
|
|
361
|
+
<!-- bul:s3 — content-match badge -->
|
|
362
|
+
<span v-if="rowInContent(n)" class="fe-list__badge">{{ t('search.in_content') }}</span>
|
|
363
|
+
</span>
|
|
346
364
|
<span
|
|
347
365
|
v-if="showParentPath"
|
|
348
366
|
class="fe-list__parent"
|
|
349
367
|
:title="parentDir(n.path)"
|
|
350
368
|
>{{ parentDir(n.path) || '—' }}</span>
|
|
369
|
+
<!-- bul:s3 — content snippet («» → <mark> via TEXT segments, no innerHTML) -->
|
|
370
|
+
<span v-if="rowSnippet(n)" class="fe-list__snippet">
|
|
371
|
+
<template v-for="(seg, si) in snippetSegments(rowSnippet(n))" :key="si">
|
|
372
|
+
<mark v-if="seg.match" class="fe-list__mark">{{ seg.text }}</mark>
|
|
373
|
+
<template v-else>{{ seg.text }}</template>
|
|
374
|
+
</template>
|
|
375
|
+
</span>
|
|
351
376
|
</div>
|
|
352
377
|
</div>
|
|
353
378
|
<div class="fe-list__col fe-list__col--size">
|
|
@@ -101,6 +101,34 @@ export interface InviteResponse {
|
|
|
101
101
|
emailed: boolean;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/* === bul:s3 — global search (GET /api/files/search) === */
|
|
105
|
+
|
|
106
|
+
export type GlobalSearchScope = 'name' | 'content' | 'all';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One hit from the dedicated files-search endpoint. The backend returns raw
|
|
110
|
+
* node rows (`{results: [...]}`), so `path` is the IN-STORAGE relative path
|
|
111
|
+
* (no `adapter://` prefix) and the storage comes back as a numeric
|
|
112
|
+
* `storage_id`. `snippet`/`matched` are the v0.2 "Bul" contract additions —
|
|
113
|
+
* older backends simply omit them, so every consumer must stay
|
|
114
|
+
* undefined-safe.
|
|
115
|
+
*/
|
|
116
|
+
export interface GlobalSearchHit {
|
|
117
|
+
id?: number;
|
|
118
|
+
storage_id?: number;
|
|
119
|
+
name?: string;
|
|
120
|
+
path?: string;
|
|
121
|
+
/** `file` | `dir` (backend NodeType). */
|
|
122
|
+
type?: string;
|
|
123
|
+
size?: number;
|
|
124
|
+
mime?: string;
|
|
125
|
+
/** Plain-text content snippet; matches wrapped in «» (never HTML). */
|
|
126
|
+
snippet?: string;
|
|
127
|
+
/** Where the hit matched: name | content | both. */
|
|
128
|
+
matched?: 'name' | 'content' | 'both';
|
|
129
|
+
[k: string]: unknown;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* Resolve a Vuefinder-compatible endpoint map from the user's config.
|
|
106
134
|
* Either `apiBase` is set (auto-derive everything) or each route is
|
|
@@ -340,6 +368,22 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
340
368
|
return jsonFetch<ManagerResponse>(managerUrl('search', { path, filter }));
|
|
341
369
|
}
|
|
342
370
|
|
|
371
|
+
/* === bul:s3 — global "search everywhere" ===
|
|
372
|
+
* Derived from the manager endpoint by swapping `/manager` for `/search`
|
|
373
|
+
* (same trick permissionsUrl uses), so embedded proxies that forward the
|
|
374
|
+
* whole /api/files/* subtree keep working. Errors and legacy backends
|
|
375
|
+
* degrade to an empty result list — the palette just shows nothing. */
|
|
376
|
+
async function globalSearch(
|
|
377
|
+
query: string,
|
|
378
|
+
opts: { limit?: number; scope?: GlobalSearchScope } = {},
|
|
379
|
+
): Promise<GlobalSearchHit[]> {
|
|
380
|
+
const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/search');
|
|
381
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
382
|
+
const url = `${base}${sep}${qs({ q: query, limit: opts.limit, scope: opts.scope })}`;
|
|
383
|
+
const data = await jsonFetch<{ results?: GlobalSearchHit[] | null }>(url);
|
|
384
|
+
return Array.isArray(data?.results) ? data.results : [];
|
|
385
|
+
}
|
|
386
|
+
|
|
343
387
|
async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
|
|
344
388
|
return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
|
|
345
389
|
}
|
|
@@ -661,6 +705,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
661
705
|
// Manager
|
|
662
706
|
index,
|
|
663
707
|
search,
|
|
708
|
+
globalSearch /* bul:s3 */,
|
|
664
709
|
subfolders,
|
|
665
710
|
newFolder,
|
|
666
711
|
rename,
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,10 @@ export { isExternalUsable } from './types/FileNode';
|
|
|
57
57
|
// ——— Composables (consumers can roll their own UI on top) ———
|
|
58
58
|
export { useFileApi, resolveEndpoints } from './composables/useFileApi';
|
|
59
59
|
export type { FileApi, ManagerResponse, PendingOpDto } from './composables/useFileApi';
|
|
60
|
+
/* bul:s3 — global-search contract types + snippet helpers */
|
|
61
|
+
export type { GlobalSearchHit, GlobalSearchScope } from './composables/useFileApi';
|
|
62
|
+
export { snippetSegments, matchedInContent } from './lib/snippet';
|
|
63
|
+
export type { SnippetSegment, SearchMatched } from './lib/snippet';
|
|
60
64
|
|
|
61
65
|
export { useUploadChunked } from './composables/useUploadChunked';
|
|
62
66
|
export type { UploadJob, UploadOptions } from './composables/useUploadChunked';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* bul:s3 */
|
|
2
|
+
/**
|
|
3
|
+
* Snippet segment parser — the search backend returns content snippets as
|
|
4
|
+
* PLAIN TEXT with matched words wrapped in «guillemets» (contract: no HTML
|
|
5
|
+
* ever crosses the wire). The UI renders highlights by splitting the string
|
|
6
|
+
* into segments and emitting each one as a TEXT node (a <mark> wrapper for
|
|
7
|
+
* matches) — never via innerHTML, so a file whose content contains markup
|
|
8
|
+
* can't inject anything into the page.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SnippetSegment {
|
|
12
|
+
/** Literal text of this segment. Always rendered as a text node. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** True when the segment was «wrapped» — render inside <mark>. */
|
|
15
|
+
match: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Split a `«»`-annotated snippet into render segments. An unpaired `«`
|
|
20
|
+
* degrades gracefully: the rest of the string is treated as plain text.
|
|
21
|
+
*/
|
|
22
|
+
export function snippetSegments(snippet: string): SnippetSegment[] {
|
|
23
|
+
const out: SnippetSegment[] = [];
|
|
24
|
+
if (!snippet) return out;
|
|
25
|
+
const re = /«([^«»]*)»/g;
|
|
26
|
+
let last = 0;
|
|
27
|
+
let m: RegExpExecArray | null;
|
|
28
|
+
while ((m = re.exec(snippet)) !== null) {
|
|
29
|
+
if (m.index > last) out.push({ text: snippet.slice(last, m.index), match: false });
|
|
30
|
+
if (m[1]) out.push({ text: m[1], match: true });
|
|
31
|
+
last = m.index + m[0].length;
|
|
32
|
+
}
|
|
33
|
+
if (last < snippet.length) out.push({ text: snippet.slice(last), match: false });
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Matched-in values the search contract allows on a hit. */
|
|
38
|
+
export type SearchMatched = 'name' | 'content' | 'both';
|
|
39
|
+
|
|
40
|
+
/** True when the hit matched (at least partly) inside file CONTENT. */
|
|
41
|
+
export function matchedInContent(matched: unknown): boolean {
|
|
42
|
+
return matched === 'content' || matched === 'both';
|
|
43
|
+
}
|
package/src/locales/en.ts
CHANGED
|
@@ -187,4 +187,12 @@ export const en: Record<string, string> = {
|
|
|
187
187
|
'toast.trashed': 'Moved to trash',
|
|
188
188
|
'conn.offline': 'No live connection — changes may be delayed',
|
|
189
189
|
'conn.tooltip': 'Live connection to the server is unavailable; the list refreshes periodically. This notice disappears once the connection is back.',
|
|
190
|
+
|
|
191
|
+
/* === bul:s3 === */
|
|
192
|
+
'palette.everywhere': 'Everywhere',
|
|
193
|
+
'palette.searching': 'Searching…',
|
|
194
|
+
'palette.saved': 'Saved searches',
|
|
195
|
+
'palette.save': 'Save search',
|
|
196
|
+
'palette.saved.delete': 'Delete saved search',
|
|
197
|
+
'search.in_content': 'In content',
|
|
190
198
|
};
|
package/src/locales/tr.ts
CHANGED
|
@@ -187,4 +187,12 @@ export const tr: Record<string, string> = {
|
|
|
187
187
|
'toast.trashed': 'Çöpe taşındı',
|
|
188
188
|
'conn.offline': 'Canlı bağlantı yok — değişiklikler gecikebilir',
|
|
189
189
|
'conn.tooltip': 'Sunucuyla canlı bağlantı kurulamadı; liste belirli aralıklarla otomatik yenilenir. Bağlantı geri gelince bu uyarı kaybolur.',
|
|
190
|
+
|
|
191
|
+
/* === bul:s3 === */
|
|
192
|
+
'palette.everywhere': 'Her yerde',
|
|
193
|
+
'palette.searching': 'Aranıyor…',
|
|
194
|
+
'palette.saved': 'Kayıtlı aramalar',
|
|
195
|
+
'palette.save': 'Aramayı kaydet',
|
|
196
|
+
'palette.saved.delete': 'Kayıtlı aramayı sil',
|
|
197
|
+
'search.in_content': 'İçerikte',
|
|
190
198
|
};
|
package/src/styles/base.css
CHANGED
|
@@ -1830,3 +1830,104 @@ filex-explorer {
|
|
|
1830
1830
|
border-radius: 50%;
|
|
1831
1831
|
background: var(--fe-warning, #f59e0b);
|
|
1832
1832
|
}
|
|
1833
|
+
|
|
1834
|
+
/* === bul:s3 === */
|
|
1835
|
+
|
|
1836
|
+
/* Palette — "Everywhere" hit rows (name + crumb + snippet stack). */
|
|
1837
|
+
.fe-cmdp__item--hit {
|
|
1838
|
+
align-items: flex-start;
|
|
1839
|
+
}
|
|
1840
|
+
.fe-cmdp__item--hit .fe-cmdp__icon {
|
|
1841
|
+
margin-top: 1px;
|
|
1842
|
+
}
|
|
1843
|
+
.fe-cmdp__hitbody {
|
|
1844
|
+
flex: 1 1 auto;
|
|
1845
|
+
min-width: 0;
|
|
1846
|
+
display: flex;
|
|
1847
|
+
flex-direction: column;
|
|
1848
|
+
gap: 2px;
|
|
1849
|
+
}
|
|
1850
|
+
.fe-cmdp__hitline {
|
|
1851
|
+
display: flex;
|
|
1852
|
+
align-items: center;
|
|
1853
|
+
gap: 8px;
|
|
1854
|
+
min-width: 0;
|
|
1855
|
+
}
|
|
1856
|
+
.fe-cmdp__badge,
|
|
1857
|
+
.fe-list__badge {
|
|
1858
|
+
flex: 0 0 auto;
|
|
1859
|
+
display: inline-block;
|
|
1860
|
+
padding: 1px 6px;
|
|
1861
|
+
border-radius: 999px;
|
|
1862
|
+
border: 1px solid color-mix(in srgb, var(--fe-primary) 35%, transparent);
|
|
1863
|
+
background: color-mix(in srgb, var(--fe-primary) 12%, transparent);
|
|
1864
|
+
color: var(--fe-primary);
|
|
1865
|
+
font-size: 10px;
|
|
1866
|
+
font-weight: 600;
|
|
1867
|
+
line-height: 1.5;
|
|
1868
|
+
white-space: nowrap;
|
|
1869
|
+
vertical-align: middle;
|
|
1870
|
+
}
|
|
1871
|
+
.fe-cmdp__crumb {
|
|
1872
|
+
overflow: hidden;
|
|
1873
|
+
text-overflow: ellipsis;
|
|
1874
|
+
white-space: nowrap;
|
|
1875
|
+
font-size: 11.5px;
|
|
1876
|
+
color: var(--fe-text-muted);
|
|
1877
|
+
}
|
|
1878
|
+
.fe-cmdp__snippet,
|
|
1879
|
+
.fe-list__snippet {
|
|
1880
|
+
display: block;
|
|
1881
|
+
overflow: hidden;
|
|
1882
|
+
text-overflow: ellipsis;
|
|
1883
|
+
white-space: nowrap;
|
|
1884
|
+
font-size: 12px;
|
|
1885
|
+
color: var(--fe-text-muted);
|
|
1886
|
+
}
|
|
1887
|
+
.fe-cmdp__mark,
|
|
1888
|
+
.fe-list__mark {
|
|
1889
|
+
background: color-mix(in srgb, var(--fe-primary) 22%, transparent);
|
|
1890
|
+
color: var(--fe-text);
|
|
1891
|
+
border-radius: 2px;
|
|
1892
|
+
padding: 0 1px;
|
|
1893
|
+
font-weight: 600;
|
|
1894
|
+
}
|
|
1895
|
+
.fe-cmdp__loading {
|
|
1896
|
+
padding: 8px 12px;
|
|
1897
|
+
font-size: 13px;
|
|
1898
|
+
color: var(--fe-text-muted);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/* Palette — saved-search rows (div-based so the delete button nests legally). */
|
|
1902
|
+
.fe-cmdp__item--saved {
|
|
1903
|
+
user-select: none;
|
|
1904
|
+
}
|
|
1905
|
+
.fe-cmdp__del {
|
|
1906
|
+
flex: 0 0 auto;
|
|
1907
|
+
background: transparent;
|
|
1908
|
+
border: 0;
|
|
1909
|
+
border-radius: var(--fe-radius-sm);
|
|
1910
|
+
padding: 2px 6px;
|
|
1911
|
+
font: inherit;
|
|
1912
|
+
font-size: 13px;
|
|
1913
|
+
color: var(--fe-text-muted);
|
|
1914
|
+
cursor: pointer;
|
|
1915
|
+
opacity: 0.55;
|
|
1916
|
+
}
|
|
1917
|
+
.fe-cmdp__item--saved:hover .fe-cmdp__del,
|
|
1918
|
+
.fe-cmdp__item--saved.is-active .fe-cmdp__del {
|
|
1919
|
+
opacity: 1;
|
|
1920
|
+
}
|
|
1921
|
+
.fe-cmdp__del:hover {
|
|
1922
|
+
background: var(--fe-bg-elev);
|
|
1923
|
+
color: var(--fe-danger, #dc2626);
|
|
1924
|
+
}
|
|
1925
|
+
.fe-cmdp__del:focus-visible {
|
|
1926
|
+
outline: 2px solid var(--fe-primary);
|
|
1927
|
+
outline-offset: -2px;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/* List view — search-result snippet line under the filename. */
|
|
1931
|
+
.fe-list__snippet {
|
|
1932
|
+
max-width: 100%;
|
|
1933
|
+
}
|