@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.
Files changed (37) hide show
  1. package/dist/{ArchiveViewer-wg4uE-3C.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
  2. package/dist/{ArchiveViewer-wg4uE-3C.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
  3. package/dist/{CsvViewer-57uz4UKk.js → CsvViewer-DMvnp82x.js} +2 -2
  4. package/dist/{CsvViewer-57uz4UKk.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
  5. package/dist/{DrawioViewer-BfL9Hbyp.js → DrawioViewer-B5Io0yTO.js} +2 -2
  6. package/dist/{DrawioViewer-BfL9Hbyp.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
  7. package/dist/{EpubViewer-CYG2CV_3.js → EpubViewer-z6RZd3K8.js} +2 -2
  8. package/dist/{EpubViewer-CYG2CV_3.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
  9. package/dist/{IpynbViewer-DJw34-wV.js → IpynbViewer-DfL4EauH.js} +2 -2
  10. package/dist/{IpynbViewer-DJw34-wV.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
  11. package/dist/{MermaidViewer-C86jjm27.js → MermaidViewer-QNl_o7V-.js} +2 -2
  12. package/dist/{MermaidViewer-C86jjm27.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
  13. package/dist/{PsdViewer-DGHIqv-x.js → PsdViewer-Btmpr0Fz.js} +2 -2
  14. package/dist/{PsdViewer-DGHIqv-x.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
  15. package/dist/{TiffViewer-gct9Ub3o.js → TiffViewer-CMLpwds5.js} +2 -2
  16. package/dist/{TiffViewer-gct9Ub3o.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
  17. package/dist/{Viewer3D-BVt4l2a9.js → Viewer3D-gf3cb2gv.js} +2 -2
  18. package/dist/{Viewer3D-BVt4l2a9.js.map → Viewer3D-gf3cb2gv.js.map} +1 -1
  19. package/dist/filex-core.js +17 -15
  20. package/dist/filex-core.umd.cjs +38 -38
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-D49iw00E.js +7119 -0
  23. package/dist/index-D49iw00E.js.map +1 -0
  24. package/dist/index.d.ts +57 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +47 -1
  28. package/src/components/CommandPalette.vue +239 -5
  29. package/src/components/ListView.vue +26 -1
  30. package/src/composables/useFileApi.ts +45 -0
  31. package/src/index.ts +4 -0
  32. package/src/lib/snippet.ts +43 -0
  33. package/src/locales/en.ts +8 -0
  34. package/src/locales/tr.ts +8 -0
  35. package/src/styles/base.css +101 -0
  36. package/dist/index-BeUoODQq.js +0 -6871
  37. 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 cmdOffset = computed(() => gotoItems.value.length + fileItems.value.length);
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(() => document.removeEventListener('keydown', onDocKeydown, true));
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">{{ nodeDisplayName(n) }}</span>
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
  };
@@ -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
+ }