@brftech/filex-core 0.1.84 → 0.3.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 (39) hide show
  1. package/dist/{ArchiveViewer-wg4uE-3C.js → ArchiveViewer-CYrKPEbE.js} +2 -2
  2. package/dist/{ArchiveViewer-wg4uE-3C.js.map → ArchiveViewer-CYrKPEbE.js.map} +1 -1
  3. package/dist/{CsvViewer-57uz4UKk.js → CsvViewer-C3ZU4Uua.js} +2 -2
  4. package/dist/{CsvViewer-57uz4UKk.js.map → CsvViewer-C3ZU4Uua.js.map} +1 -1
  5. package/dist/{DrawioViewer-BfL9Hbyp.js → DrawioViewer-15ZL4ken.js} +2 -2
  6. package/dist/{DrawioViewer-BfL9Hbyp.js.map → DrawioViewer-15ZL4ken.js.map} +1 -1
  7. package/dist/{EpubViewer-CYG2CV_3.js → EpubViewer-CrXcDgmo.js} +2 -2
  8. package/dist/{EpubViewer-CYG2CV_3.js.map → EpubViewer-CrXcDgmo.js.map} +1 -1
  9. package/dist/{IpynbViewer-DJw34-wV.js → IpynbViewer-BAb9rV2Q.js} +2 -2
  10. package/dist/{IpynbViewer-DJw34-wV.js.map → IpynbViewer-BAb9rV2Q.js.map} +1 -1
  11. package/dist/{MermaidViewer-C86jjm27.js → MermaidViewer-BN8GN4FY.js} +2 -2
  12. package/dist/{MermaidViewer-C86jjm27.js.map → MermaidViewer-BN8GN4FY.js.map} +1 -1
  13. package/dist/{PsdViewer-DGHIqv-x.js → PsdViewer-o-kWQExw.js} +2 -2
  14. package/dist/{PsdViewer-DGHIqv-x.js.map → PsdViewer-o-kWQExw.js.map} +1 -1
  15. package/dist/{TiffViewer-gct9Ub3o.js → TiffViewer-AjMVBzxp.js} +2 -2
  16. package/dist/{TiffViewer-gct9Ub3o.js.map → TiffViewer-AjMVBzxp.js.map} +1 -1
  17. package/dist/{Viewer3D-BVt4l2a9.js → Viewer3D-BR3WDbxQ.js} +2 -2
  18. package/dist/{Viewer3D-BVt4l2a9.js.map → Viewer3D-BR3WDbxQ.js.map} +1 -1
  19. package/dist/filex-core.js +17 -15
  20. package/dist/filex-core.umd.cjs +41 -41
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-BG5upYlH.js +7392 -0
  23. package/dist/index-BG5upYlH.js.map +1 -0
  24. package/dist/index.d.ts +106 -2
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +109 -1
  28. package/src/components/CommandPalette.vue +239 -5
  29. package/src/components/ContextMenu.vue +101 -3
  30. package/src/components/ListView.vue +26 -1
  31. package/src/components/Toolbar.vue +216 -5
  32. package/src/composables/useFileApi.ts +45 -0
  33. package/src/index.ts +4 -0
  34. package/src/lib/snippet.ts +43 -0
  35. package/src/locales/en.ts +14 -0
  36. package/src/locales/tr.ts +14 -0
  37. package/src/styles/base.css +238 -0
  38. package/dist/index-BeUoODQq.js +0 -6871
  39. package/dist/index-BeUoODQq.js.map +0 -1
@@ -24,7 +24,7 @@ import type {
24
24
  Capabilities,
25
25
  } from './types/FileNode';
26
26
  import { isExternalUsable } from './types/FileNode';
27
- import { useFileApi } from './composables/useFileApi';
27
+ import { useFileApi, type GlobalSearchHit } from './composables/useFileApi';
28
28
  import { useUploadChunked, type UploadJob } from './composables/useUploadChunked';
29
29
  import { useSelection } from './composables/useSelection';
30
30
  import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
@@ -443,6 +443,40 @@ const ctxRef = ref<InstanceType<typeof ContextMenu> | null>(null);
443
443
  const rootEl = ref<HTMLElement | null>(null);
444
444
  const toolbarRef = ref<InstanceType<typeof Toolbar> | null>(null);
445
445
 
446
+ /* bag:b4 — narrow/embed mini mode.
447
+ * isNarrow: container width < 560px (ResizeObserver on the .fe root, so it
448
+ * tracks the EMBED container, not the viewport) → root gets `fe--narrow`,
449
+ * the toolbar collapses and the upload FAB appears.
450
+ * isCoarse: touch-first device → context menus render as a bottom sheet. */
451
+ const isNarrow = ref(false);
452
+ const isCoarse = ref(false);
453
+ let narrowRO: ResizeObserver | undefined;
454
+ let coarseMq: MediaQueryList | undefined;
455
+ function syncCoarsePointer(e?: MediaQueryListEvent | MediaQueryList) {
456
+ isCoarse.value = !!(e && 'matches' in e && e.matches);
457
+ }
458
+ onMounted(() => {
459
+ if (typeof ResizeObserver !== 'undefined' && rootEl.value) {
460
+ narrowRO = new ResizeObserver((entries) => {
461
+ const w = entries[0]?.contentRect?.width ?? rootEl.value?.clientWidth ?? 0;
462
+ isNarrow.value = w > 0 && w < 560;
463
+ });
464
+ narrowRO.observe(rootEl.value);
465
+ }
466
+ if (typeof window !== 'undefined' && window.matchMedia) {
467
+ coarseMq = window.matchMedia('(pointer: coarse)');
468
+ syncCoarsePointer(coarseMq);
469
+ coarseMq.addEventListener?.('change', syncCoarsePointer);
470
+ }
471
+ });
472
+ onBeforeUnmount(() => {
473
+ narrowRO?.disconnect();
474
+ narrowRO = undefined;
475
+ coarseMq?.removeEventListener?.('change', syncCoarsePointer);
476
+ coarseMq = undefined;
477
+ });
478
+ /* /bag:b4 */
479
+
446
480
  // Toast (tiny, no lib). Evolved into a snackbar: plain messages keep the old
447
481
  // 2.5s auto-hide; messages carrying an action ("Geri Al") stay 8s and can be
448
482
  // dismissed by click or Esc.
@@ -924,6 +958,50 @@ const showPalette = ref(false);
924
958
  const showShortcutsHelp = ref(false);
925
959
  /* /cila:c wiring */
926
960
 
961
+ /* bul:s3 — palette "everywhere" search + open-hit navigation */
962
+
963
+ // Debounce/min-chars live in the palette; this is just the API call.
964
+ function paletteGlobalSearch(q: string): Promise<GlobalSearchHit[]> {
965
+ return api.globalSearch(q, { limit: 8, scope: 'all' });
966
+ }
967
+
968
+ /**
969
+ * Open a global-search hit: navigate to the file's folder, then select +
970
+ * preview it through the existing openNode mechanics. Hits come back as raw
971
+ * node rows (in-storage relative `path`, numeric `storage_id`), so the
972
+ * storage segment for multi-storage mode is resolved best-effort: an
973
+ * explicit name on the hit (future backends) > the only configured storage
974
+ * > the storage currently open. A wrong guess lands on the existing
975
+ * "folder not found" state, which is already a graceful dead-end.
976
+ */
977
+ async function openSearchHit(hit: GlobalSearchHit) {
978
+ const rel = String(hit.path ?? '').replace(/^\/+|\/+$/g, '');
979
+ if (!rel) return;
980
+ const isDir = hit.type === 'dir';
981
+ const slash = rel.lastIndexOf('/');
982
+ const targetRel = isDir ? rel : slash === -1 ? '' : rel.slice(0, slash);
983
+ let target = targetRel;
984
+ if (multiStorageRoot.value) {
985
+ const configured = props.config.storages ?? [];
986
+ const storageName =
987
+ (typeof hit.storage === 'string' && hit.storage) ||
988
+ (typeof hit.storage_name === 'string' && hit.storage_name) ||
989
+ (configured.length === 1 ? configured[0].name : '') ||
990
+ adapter.value;
991
+ if (!storageName) return;
992
+ target = targetRel ? `${storageName}/${targetRel}` : storageName;
993
+ }
994
+ await load(target);
995
+ if (isDir) return;
996
+ const name = String(hit.name ?? rel.slice(slash + 1));
997
+ const node = files.value.find((f) => f.type === 'file' && f.basename === name);
998
+ if (node) {
999
+ selection.click(node.path);
1000
+ openNode(node);
1001
+ }
1002
+ }
1003
+ /* /bul:s3 */
1004
+
927
1005
  useKeyboardShortcuts(rootEl, {
928
1006
  onDelete: () => {
929
1007
  if (!selection.isEmpty.value) showDelete.value = true;
@@ -1920,6 +1998,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1920
1998
  'fe--theme-dark': config.theme === 'dark',
1921
1999
  'fe--is-dragover': dragOver,
1922
2000
  'fe--density-compact': density === 'compact' /* cila:a density */,
2001
+ 'fe--narrow': isNarrow /* bag:b4 */,
1923
2002
  }"
1924
2003
  tabindex="-1"
1925
2004
  @dragenter="onDragEnter"
@@ -1941,6 +2020,8 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1941
2020
  :at-virtual-root="atVirtualRoot"
1942
2021
  :can-write="canWriteHere"
1943
2022
  :locale="locale"
2023
+ :narrow="isNarrow /* bag:b4 */"
2024
+ :theme="config.theme || 'auto' /* bag:b4 */"
1944
2025
  @update:view-mode="viewMode = $event"
1945
2026
  @update:search-query="searchQuery = $event"
1946
2027
  @update:density="density = $event"
@@ -2186,10 +2267,35 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2186
2267
  @dismiss="(id) => pendingOps.dismiss(id)"
2187
2268
  />
2188
2269
 
2270
+ <!-- bag:b4 — narrow-mode upload FAB (hidden in trash / read-only /
2271
+ virtual root; PendingOpsTray+UploadProgress shift up via CSS). -->
2272
+ <button
2273
+ v-if="isNarrow && emptyCanUpload"
2274
+ type="button"
2275
+ class="fe-fab"
2276
+ :title="t('toolbar.upload')"
2277
+ :aria-label="t('toolbar.upload')"
2278
+ @click="triggerUpload"
2279
+ >
2280
+ <svg
2281
+ class="fe-ficon"
2282
+ viewBox="0 0 24 24"
2283
+ fill="none"
2284
+ stroke="currentColor"
2285
+ stroke-width="2.2"
2286
+ stroke-linecap="round"
2287
+ aria-hidden="true"
2288
+ focusable="false"
2289
+ >
2290
+ <path d="M12 5v14M5 12h14" />
2291
+ </svg>
2292
+ </button>
2293
+
2189
2294
  <ContextMenu
2190
2295
  ref="ctxRef"
2191
2296
  :locale="locale"
2192
2297
  :theme="config.theme || 'auto'"
2298
+ :sheet="isCoarse /* bag:b4 */"
2193
2299
  :actions="contextActions"
2194
2300
  @select="onContextAction"
2195
2301
  />
@@ -2326,7 +2432,9 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2326
2432
  :view-mode="viewMode"
2327
2433
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
2328
2434
  :can-go-up="canGoUp"
2435
+ :global-search="paletteGlobalSearch"
2329
2436
  @close="showPalette = false"
2437
+ @open-hit="openSearchHit"
2330
2438
  @open-node="openNode"
2331
2439
  @navigate="(p: string) => load(p)"
2332
2440
  @new-folder="showNewFolder = true"
@@ -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>
@@ -31,13 +31,22 @@ const props = defineProps<{
31
31
  * explorer tree.
32
32
  */
33
33
  theme?: ThemeMode;
34
+ /**
35
+ * bag:b4 — bottom-sheet presentation for coarse-pointer (touch)
36
+ * contexts. When true the menu renders as a full-width sheet sliding
37
+ * up from the bottom edge (grab handle, ≥44px touch targets; closed
38
+ * by overlay tap, dragging the handle down, or Esc). The position
39
+ * passed to show() is ignored. When absent/false the classic anchored
40
+ * menu renders — desktop right-click behavior is untouched.
41
+ */
42
+ sheet?: boolean;
34
43
  }>();
35
44
 
36
45
  const emit = defineEmits<{
37
46
  (e: 'select', action: ContextAction, target: FileNode[]): void;
38
47
  }>();
39
48
 
40
- void useLocale(() => props.locale); // eager-instantiate t/lookup so future templates can reuse
49
+ const { t } = useLocale(() => props.locale); // bag:b4 sheet aria labels need t()
41
50
 
42
51
  const open = ref(false);
43
52
  const x = ref(0);
@@ -54,6 +63,12 @@ async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[])
54
63
  // bottom/right edge doesn't push the menu off-screen (it opens down-right
55
64
  // from the cursor by default).
56
65
  await nextTick();
66
+ if (props.sheet) {
67
+ // bag:b4 — sheet mode: no anchoring; focus the panel so Esc closes it
68
+ // without requiring a prior click inside.
69
+ sheetEl.value?.focus();
70
+ return;
71
+ }
57
72
  clampToViewport();
58
73
  }
59
74
 
@@ -74,7 +89,48 @@ function clampToViewport() {
74
89
 
75
90
  function hide() {
76
91
  open.value = false;
92
+ /* bag:b4 — reset any in-flight sheet drag so reopening starts clean. */
93
+ sheetDragging.value = false;
94
+ sheetDragY.value = 0;
95
+ }
96
+
97
+ /* bag:b4 — sheet drag-to-dismiss. Bound to the grab handle only, so the
98
+ * scrollable item list keeps its native touch scrolling. Dragging past the
99
+ * threshold closes; anything less snaps back. */
100
+ const sheetEl = ref<HTMLElement | null>(null);
101
+ const sheetDragging = ref(false);
102
+ const sheetDragY = ref(0);
103
+ let sheetDragStart = 0;
104
+
105
+ function onSheetDragStart(e: TouchEvent) {
106
+ const t0 = e.touches[0];
107
+ if (!t0) return;
108
+ sheetDragging.value = true;
109
+ sheetDragStart = t0.clientY;
110
+ sheetDragY.value = 0;
111
+ }
112
+ function onSheetDragMove(e: TouchEvent) {
113
+ if (!sheetDragging.value) return;
114
+ const t0 = e.touches[0];
115
+ if (!t0) return;
116
+ sheetDragY.value = Math.max(0, t0.clientY - sheetDragStart);
77
117
  }
118
+ function onSheetDragEnd() {
119
+ if (!sheetDragging.value) return;
120
+ const shouldClose = sheetDragY.value > 72;
121
+ sheetDragging.value = false;
122
+ if (shouldClose) {
123
+ hide();
124
+ } else {
125
+ sheetDragY.value = 0;
126
+ }
127
+ }
128
+ const sheetStyle = computed(() =>
129
+ sheetDragY.value > 0
130
+ ? { transform: `translateY(${sheetDragY.value}px)`, transition: 'none' }
131
+ : undefined,
132
+ );
133
+ /* /bag:b4 */
78
134
 
79
135
  function pick(a: ContextAction) {
80
136
  if (a.disabled) return;
@@ -113,17 +169,59 @@ defineExpose({ show, hide });
113
169
 
114
170
  <template>
115
171
  <Teleport to="body">
116
- <transition name="fe-ctx">
172
+ <transition :name="sheet ? 'fe-sheet' : 'fe-ctx'">
117
173
  <div
118
174
  v-if="open"
119
175
  class="fe-ctx-backdrop"
120
- :class="themeClass"
176
+ :class="[themeClass, { 'fe-ctx-backdrop--sheet': sheet }]"
121
177
  :data-prefers-dark="prefersDark ? '1' : '0'"
122
178
  @click="hide"
123
179
  @contextmenu.prevent="hide"
124
180
  @keydown="onKey"
125
181
  >
182
+ <!-- bag:b4 — bottom-sheet variant (coarse pointer / touch) -->
183
+ <div
184
+ v-if="sheet"
185
+ ref="sheetEl"
186
+ class="fe-sheet"
187
+ role="menu"
188
+ :aria-label="t('sheet.menu')"
189
+ tabindex="-1"
190
+ :style="sheetStyle"
191
+ @click.stop
192
+ >
193
+ <div
194
+ class="fe-sheet__handle"
195
+ role="button"
196
+ tabindex="0"
197
+ :aria-label="t('sheet.close')"
198
+ @click="hide"
199
+ @keydown.enter.prevent="hide"
200
+ @touchstart.passive="onSheetDragStart"
201
+ @touchmove.passive="onSheetDragMove"
202
+ @touchend="onSheetDragEnd"
203
+ @touchcancel="onSheetDragEnd"
204
+ />
205
+ <div class="fe-sheet__items">
206
+ <template v-for="(a, i) in visibleActions" :key="a.key || i">
207
+ <div v-if="a.divider" class="fe-ctx__sep" />
208
+ <button
209
+ v-else
210
+ type="button"
211
+ class="fe-ctx__item fe-sheet__item"
212
+ :class="{ 'is-danger': a.danger, 'is-disabled': a.disabled }"
213
+ :disabled="a.disabled"
214
+ role="menuitem"
215
+ @click="pick(a)"
216
+ >
217
+ <span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
218
+ <span class="fe-ctx__label">{{ a.label }}</span>
219
+ </button>
220
+ </template>
221
+ </div>
222
+ </div>
126
223
  <div
224
+ v-else
127
225
  ref="menuEl"
128
226
  class="fe-ctx"
129
227
  role="menu"
@@ -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">