@brftech/filex-core 0.1.83 → 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 (45) hide show
  1. package/dist/{ArchiveViewer-BRASXNIS.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
  2. package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
  3. package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-DMvnp82x.js} +2 -2
  4. package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
  5. package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-B5Io0yTO.js} +2 -2
  6. package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
  7. package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-z6RZd3K8.js} +2 -2
  8. package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
  9. package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DfL4EauH.js} +2 -2
  10. package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
  11. package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-QNl_o7V-.js} +2 -2
  12. package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
  13. package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-Btmpr0Fz.js} +2 -2
  14. package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
  15. package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-CMLpwds5.js} +2 -2
  16. package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
  17. package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-gf3cb2gv.js} +2 -2
  18. package/dist/{Viewer3D-B3kLZrwO.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 +63 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +440 -32
  28. package/src/components/Breadcrumb.vue +100 -5
  29. package/src/components/CommandPalette.vue +533 -0
  30. package/src/components/GridView.vue +8 -13
  31. package/src/components/ListView.vue +199 -21
  32. package/src/components/ShortcutsHelp.vue +59 -0
  33. package/src/components/Toolbar.vue +65 -1
  34. package/src/composables/useFileApi.ts +45 -0
  35. package/src/composables/useKeyboardShortcuts.ts +38 -0
  36. package/src/composables/useRealtime.ts +9 -1
  37. package/src/index.ts +4 -0
  38. package/src/lib/fileIcons.ts +127 -0
  39. package/src/lib/snippet.ts +43 -0
  40. package/src/locales/en.ts +62 -0
  41. package/src/locales/tr.ts +62 -0
  42. package/src/styles/base.css +650 -0
  43. package/src/styles/variables.css +70 -0
  44. package/dist/index-DL6_eaM3.js +0 -5888
  45. package/dist/index-DL6_eaM3.js.map +0 -1
@@ -19,7 +19,7 @@
19
19
  * The ✏ button swaps the crumbs for a free-form input; Enter
20
20
  * navigates, Escape cancels.
21
21
  */
22
- import { computed, nextTick, ref } from 'vue';
22
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
23
23
  import type { LocaleCode } from '../types/ExplorerConfig';
24
24
  import { useLocale } from '../composables/useLocale';
25
25
 
@@ -125,6 +125,64 @@ const crumbs = computed<Crumb[]>(() => {
125
125
  return out;
126
126
  });
127
127
 
128
+ // ------------------------------------------------------------------
129
+ // Overflow collapse (cila:c / I5) — when the trail grows past the
130
+ // threshold, show `root › … › parent › current`; the middle crumbs move
131
+ // into a dropdown. Visible crumbs keep their full click/drag-drop/context
132
+ // behavior; dropdown entries only navigate.
133
+ // ------------------------------------------------------------------
134
+ const OVERFLOW_THRESHOLD = 4;
135
+ const collapsed = computed(() => crumbs.value.length > OVERFLOW_THRESHOLD);
136
+ const leadCrumbs = computed<Crumb[]>(() =>
137
+ collapsed.value ? crumbs.value.slice(0, 1) : crumbs.value,
138
+ );
139
+ const middleCrumbs = computed<Crumb[]>(() =>
140
+ collapsed.value ? crumbs.value.slice(1, -2) : [],
141
+ );
142
+ const tailCrumbs = computed<Crumb[]>(() =>
143
+ collapsed.value ? crumbs.value.slice(-2) : [],
144
+ );
145
+
146
+ const overflowOpen = ref(false);
147
+ const moreWrapEl = ref<HTMLElement | null>(null);
148
+
149
+ function onOverflowPick(crumb: Crumb) {
150
+ overflowOpen.value = false;
151
+ emit('navigate', crumb.adapterPath);
152
+ }
153
+
154
+ function onDocClick(e: MouseEvent) {
155
+ const wrap = moreWrapEl.value;
156
+ if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
157
+ overflowOpen.value = false;
158
+ }
159
+
160
+ function onDocKeydown(e: KeyboardEvent) {
161
+ if (e.key === 'Escape') {
162
+ e.stopPropagation();
163
+ overflowOpen.value = false;
164
+ }
165
+ }
166
+
167
+ watch(overflowOpen, (v) => {
168
+ if (v) {
169
+ document.addEventListener('click', onDocClick, true);
170
+ document.addEventListener('keydown', onDocKeydown, true);
171
+ } else {
172
+ document.removeEventListener('click', onDocClick, true);
173
+ document.removeEventListener('keydown', onDocKeydown, true);
174
+ }
175
+ });
176
+ // Navigation rebuilds the trail — a stale open menu would list crumbs of
177
+ // the previous folder.
178
+ watch(crumbs, () => {
179
+ overflowOpen.value = false;
180
+ });
181
+ onBeforeUnmount(() => {
182
+ document.removeEventListener('click', onDocClick, true);
183
+ document.removeEventListener('keydown', onDocKeydown, true);
184
+ });
185
+
128
186
  function onClick(crumb: Crumb) {
129
187
  emit('navigate', crumb.adapterPath);
130
188
  }
@@ -242,19 +300,56 @@ function cancelEdit() {
242
300
  <nav class="fe-breadcrumb" aria-label="Breadcrumb">
243
301
  <template v-if="!editing">
244
302
  <button
245
- v-for="(c, i) in crumbs"
303
+ v-for="(c, i) in leadCrumbs"
304
+ :key="c.adapterPath"
305
+ class="fe-breadcrumb__crumb"
306
+ :class="{ 'is-last': !collapsed && i === leadCrumbs.length - 1 }"
307
+ type="button"
308
+ :aria-current="!collapsed && i === leadCrumbs.length - 1 ? 'page' : undefined"
309
+ @click="onClick(c)"
310
+ @contextmenu="onContext($event, c)"
311
+ @dragover="onCrumbDragOver"
312
+ @drop="onCrumbDrop($event, c)"
313
+ >
314
+ <span>{{ c.label }}</span>
315
+ <span v-if="collapsed || i < leadCrumbs.length - 1" class="fe-breadcrumb__sep" aria-hidden="true">›</span>
316
+ </button>
317
+ <span v-if="middleCrumbs.length" ref="moreWrapEl" class="fe-breadcrumb__more-wrap">
318
+ <button
319
+ type="button"
320
+ class="fe-breadcrumb__more"
321
+ aria-haspopup="menu"
322
+ :aria-expanded="overflowOpen"
323
+ :aria-label="t('breadcrumb.more')"
324
+ :title="t('breadcrumb.more')"
325
+ @click="overflowOpen = !overflowOpen"
326
+ >…</button>
327
+ <span class="fe-breadcrumb__sep" aria-hidden="true">›</span>
328
+ <div v-if="overflowOpen" class="fe-breadcrumb__menu" role="menu">
329
+ <button
330
+ v-for="c in middleCrumbs"
331
+ :key="c.adapterPath"
332
+ type="button"
333
+ class="fe-breadcrumb__menu-item"
334
+ role="menuitem"
335
+ @click="onOverflowPick(c)"
336
+ >{{ c.label }}</button>
337
+ </div>
338
+ </span>
339
+ <button
340
+ v-for="(c, i) in tailCrumbs"
246
341
  :key="c.adapterPath"
247
342
  class="fe-breadcrumb__crumb"
248
- :class="{ 'is-last': i === crumbs.length - 1 }"
343
+ :class="{ 'is-last': i === tailCrumbs.length - 1 }"
249
344
  type="button"
250
- :aria-current="i === crumbs.length - 1 ? 'page' : undefined"
345
+ :aria-current="i === tailCrumbs.length - 1 ? 'page' : undefined"
251
346
  @click="onClick(c)"
252
347
  @contextmenu="onContext($event, c)"
253
348
  @dragover="onCrumbDragOver"
254
349
  @drop="onCrumbDrop($event, c)"
255
350
  >
256
351
  <span>{{ c.label }}</span>
257
- <span v-if="i < crumbs.length - 1" class="fe-breadcrumb__sep" aria-hidden="true">›</span>
352
+ <span v-if="i < tailCrumbs.length - 1" class="fe-breadcrumb__sep" aria-hidden="true">›</span>
258
353
  </button>
259
354
  <button
260
355
  type="button"
@@ -0,0 +1,533 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * CommandPalette — Ctrl/Cmd+K quick launcher.
4
+ *
5
+ * Sources (no new backend calls — everything comes from props):
6
+ * 1. Files/folders of the CURRENT listing (`files` prop), filtered by a
7
+ * tiny scored-includes matcher (no fuzzy-search dependency).
8
+ * 2. Commands mapped 1:1 onto existing FileExplorer actions, surfaced as
9
+ * individual emits so the host wires each to code it already has.
10
+ * 3. Path jump: any query containing `/` offers a "go to path" entry.
11
+ *
12
+ * Keyboard: ↑/↓ move, Enter selects, Esc closes; the listener sits on the
13
+ * document in CAPTURE phase so it wins over useKeyboardShortcuts' window
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.
23
+ */
24
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
25
+ import type { LocaleCode } from '../types/ExplorerConfig';
26
+ import type { FileNode, ViewMode } from '../types/FileNode';
27
+ import type { GlobalSearchHit } from '../composables/useFileApi';
28
+ import { matchedInContent, snippetSegments } from '../lib/snippet';
29
+ import { useLocale } from '../composables/useLocale';
30
+
31
+ const props = defineProps<{
32
+ open: boolean;
33
+ locale: LocaleCode;
34
+ files: FileNode[];
35
+ viewMode: ViewMode;
36
+ /** Gates the mutating commands (new folder / upload). */
37
+ canWrite?: boolean;
38
+ /** Gates the "up one level" command. */
39
+ canGoUp?: boolean;
40
+ /* bul:s3 — global search callback; absent = older host, group hidden. */
41
+ globalSearch?: (q: string) => Promise<GlobalSearchHit[]>;
42
+ }>();
43
+
44
+ const emit = defineEmits<{
45
+ (e: 'close'): void;
46
+ (e: 'open-node', node: FileNode): void;
47
+ (e: 'navigate', path: string): void;
48
+ (e: 'new-folder'): void;
49
+ (e: 'upload'): void;
50
+ (e: 'toggle-view'): void;
51
+ (e: 'open-trash'): void;
52
+ (e: 'refresh'): void;
53
+ (e: 'go-up'): void;
54
+ /* bul:s3 — an "everywhere" hit was chosen. */
55
+ (e: 'open-hit', hit: GlobalSearchHit): void;
56
+ }>();
57
+
58
+ const { t, nodeDisplayName } = useLocale(() => props.locale);
59
+
60
+ const query = ref('');
61
+ const active = ref(0);
62
+ const inputEl = ref<HTMLInputElement | null>(null);
63
+ const listEl = ref<HTMLElement | null>(null);
64
+
65
+ type PaletteItem =
66
+ | { kind: 'goto'; id: string; label: string; icon: string; path: string }
67
+ | { kind: 'file'; id: string; label: string; icon: string; node: FileNode }
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' }>;
77
+
78
+ /**
79
+ * Scored includes: -1 = no match; otherwise earlier + prefix matches rank
80
+ * higher and shorter names get a small edge. Good enough without a fuzzy
81
+ * dependency.
82
+ */
83
+ function matchScore(label: string, q: string): number {
84
+ if (!q) return 0;
85
+ const n = label.toLocaleLowerCase();
86
+ const s = q.toLocaleLowerCase();
87
+ const i = n.indexOf(s);
88
+ if (i === -1) return -1;
89
+ let sc = 100 - Math.min(i, 50);
90
+ if (i === 0) sc += 40;
91
+ sc -= Math.min(n.length, 40) / 4;
92
+ return sc;
93
+ }
94
+
95
+ const gotoItems = computed<PaletteItem[]>(() => {
96
+ const q = query.value.trim();
97
+ if (!q.includes('/')) return [];
98
+ const clean = q.replace(/^\/+|\/+$/g, '');
99
+ if (!clean) return [];
100
+ return [
101
+ { kind: 'goto', id: `goto:${clean}`, label: `${t('palette.goto')}: ${clean}`, icon: '➜', path: clean },
102
+ ];
103
+ });
104
+
105
+ const fileItems = computed<PaletteItem[]>(() => {
106
+ const q = query.value.trim();
107
+ const scored = props.files
108
+ .map((f) => ({ f, name: nodeDisplayName(f), s: matchScore(nodeDisplayName(f), q) }))
109
+ .filter((r) => r.s >= 0);
110
+ if (q) scored.sort((a, b) => b.s - a.s);
111
+ return scored.slice(0, 8).map((r) => ({
112
+ kind: 'file' as const,
113
+ id: `file:${r.f.path}`,
114
+ label: r.name,
115
+ icon: r.f.type === 'dir' ? '📁' : '📄',
116
+ node: r.f,
117
+ }));
118
+ });
119
+
120
+ const commandItems = computed<PaletteItem[]>(() => {
121
+ const q = query.value.trim();
122
+ const defs: Array<{ command: string; label: string; icon: string; enabled: boolean }> = [
123
+ { command: 'new-folder', label: t('toolbar.new_folder'), icon: '📁', enabled: props.canWrite !== false },
124
+ { command: 'upload', label: t('toolbar.upload'), icon: '⬆', enabled: props.canWrite !== false },
125
+ { command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : '☰', enabled: true },
126
+ { command: 'open-trash', label: t('cmd.trash'), icon: '🗑', enabled: true },
127
+ { command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
128
+ { command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
129
+ ];
130
+ return defs
131
+ .filter((d) => d.enabled && matchScore(d.label, q) >= 0)
132
+ .map((d) => ({ kind: 'command' as const, id: `cmd:${d.command}`, label: d.label, icon: d.icon, command: d.command }));
133
+ });
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
+
255
+ const flat = computed<PaletteItem[]>(() => [
256
+ ...gotoItems.value,
257
+ ...fileItems.value,
258
+ ...hitItems.value,
259
+ ...savedItems.value,
260
+ ...commandItems.value,
261
+ ]);
262
+ const fileOffset = computed(() => gotoItems.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);
266
+
267
+ watch(query, (q) => {
268
+ active.value = 0;
269
+ scheduleEverywhere(q.trim()); /* bul:s3 */
270
+ });
271
+
272
+ function move(delta: number) {
273
+ const n = flat.value.length;
274
+ if (!n) return;
275
+ active.value = (active.value + delta + n) % n;
276
+ void nextTick(() => {
277
+ listEl.value?.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
278
+ });
279
+ }
280
+
281
+ function choose(item?: PaletteItem) {
282
+ const it = item ?? flat.value[active.value];
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
+ }
296
+ emit('close');
297
+ if (it.kind === 'hit') {
298
+ emit('open-hit', it.hit);
299
+ return;
300
+ }
301
+ if (it.kind === 'file') {
302
+ emit('open-node', it.node);
303
+ } else if (it.kind === 'goto') {
304
+ emit('navigate', it.path);
305
+ } else {
306
+ switch (it.command) {
307
+ case 'new-folder': emit('new-folder'); break;
308
+ case 'upload': emit('upload'); break;
309
+ case 'toggle-view': emit('toggle-view'); break;
310
+ case 'open-trash': emit('open-trash'); break;
311
+ case 'refresh': emit('refresh'); break;
312
+ case 'go-up': emit('go-up'); break;
313
+ }
314
+ }
315
+ }
316
+
317
+ function onDocKeydown(e: KeyboardEvent) {
318
+ if (!props.open) return;
319
+ switch (e.key) {
320
+ case 'ArrowDown':
321
+ e.preventDefault();
322
+ e.stopPropagation();
323
+ move(1);
324
+ break;
325
+ case 'ArrowUp':
326
+ e.preventDefault();
327
+ e.stopPropagation();
328
+ move(-1);
329
+ break;
330
+ case 'Enter':
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ choose();
334
+ break;
335
+ case 'Escape':
336
+ e.preventDefault();
337
+ e.stopPropagation();
338
+ emit('close');
339
+ break;
340
+ }
341
+ }
342
+
343
+ watch(
344
+ () => props.open,
345
+ (v) => {
346
+ if (v) {
347
+ query.value = '';
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();
353
+ document.addEventListener('keydown', onDocKeydown, true);
354
+ void nextTick(() => inputEl.value?.focus());
355
+ } else {
356
+ if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
357
+ document.removeEventListener('keydown', onDocKeydown, true);
358
+ }
359
+ },
360
+ );
361
+
362
+ onBeforeUnmount(() => {
363
+ if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
364
+ document.removeEventListener('keydown', onDocKeydown, true);
365
+ });
366
+ </script>
367
+
368
+ <template>
369
+ <transition name="fe-modal">
370
+ <div
371
+ v-if="open"
372
+ class="fe-modal__backdrop fe-cmdp__backdrop"
373
+ role="presentation"
374
+ @click="emit('close')"
375
+ >
376
+ <div
377
+ class="fe-cmdp"
378
+ role="dialog"
379
+ aria-modal="true"
380
+ :aria-label="t('palette.placeholder')"
381
+ @click.stop
382
+ >
383
+ <div class="fe-cmdp__inputwrap">
384
+ <svg
385
+ class="fe-cmdp__glyph"
386
+ width="16"
387
+ height="16"
388
+ viewBox="0 0 16 16"
389
+ fill="none"
390
+ stroke="currentColor"
391
+ stroke-width="1.5"
392
+ stroke-linecap="round"
393
+ aria-hidden="true"
394
+ >
395
+ <circle cx="7" cy="7" r="4.5" />
396
+ <path d="m10.5 10.5 3.5 3.5" />
397
+ </svg>
398
+ <input
399
+ ref="inputEl"
400
+ v-model="query"
401
+ type="text"
402
+ class="fe-cmdp__input"
403
+ :placeholder="t('palette.placeholder')"
404
+ autocomplete="off"
405
+ spellcheck="false"
406
+ />
407
+ </div>
408
+
409
+ <div ref="listEl" class="fe-cmdp__list" role="listbox">
410
+ <button
411
+ v-for="(it, i) in gotoItems"
412
+ :key="it.id"
413
+ type="button"
414
+ class="fe-cmdp__item"
415
+ :class="{ 'is-active': i === active }"
416
+ role="option"
417
+ :aria-selected="i === active"
418
+ @mouseenter="active = i"
419
+ @click="choose(it)"
420
+ >
421
+ <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
422
+ <span class="fe-cmdp__label">{{ it.label }}</span>
423
+ </button>
424
+
425
+ <template v-if="fileItems.length">
426
+ <div class="fe-cmdp__group">{{ t('palette.files') }}</div>
427
+ <button
428
+ v-for="(it, i) in fileItems"
429
+ :key="it.id"
430
+ type="button"
431
+ class="fe-cmdp__item"
432
+ :class="{ 'is-active': fileOffset + i === active }"
433
+ role="option"
434
+ :aria-selected="fileOffset + i === active"
435
+ @mouseenter="active = fileOffset + i"
436
+ @click="choose(it)"
437
+ >
438
+ <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
439
+ <span class="fe-cmdp__label">{{ it.label }}</span>
440
+ </button>
441
+ </template>
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
+
506
+ <template v-if="commandItems.length">
507
+ <div class="fe-cmdp__group">{{ t('palette.commands') }}</div>
508
+ <button
509
+ v-for="(it, i) in commandItems"
510
+ :key="it.id"
511
+ type="button"
512
+ class="fe-cmdp__item"
513
+ :class="{ 'is-active': cmdOffset + i === active }"
514
+ role="option"
515
+ :aria-selected="cmdOffset + i === active"
516
+ @mouseenter="active = cmdOffset + i"
517
+ @click="choose(it)"
518
+ >
519
+ <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
520
+ <span class="fe-cmdp__label">{{ it.label }}</span>
521
+ </button>
522
+ </template>
523
+
524
+ <div v-if="flat.length === 0 && !everywhereLoading" class="fe-cmdp__empty">
525
+ {{ t('palette.empty') }}
526
+ </div>
527
+ </div>
528
+
529
+ <div class="fe-cmdp__hint">{{ t('palette.hint') }}</div>
530
+ </div>
531
+ </div>
532
+ </transition>
533
+ </template>