@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.
- package/dist/{ArchiveViewer-BRASXNIS.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
- package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
- package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-DMvnp82x.js} +2 -2
- package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
- package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-B5Io0yTO.js} +2 -2
- package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
- package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-z6RZd3K8.js} +2 -2
- package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
- package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DfL4EauH.js} +2 -2
- package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
- package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-QNl_o7V-.js} +2 -2
- package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
- package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-Btmpr0Fz.js} +2 -2
- package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
- package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-CMLpwds5.js} +2 -2
- package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
- package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-gf3cb2gv.js} +2 -2
- package/dist/{Viewer3D-B3kLZrwO.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 +63 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +440 -32
- package/src/components/Breadcrumb.vue +100 -5
- package/src/components/CommandPalette.vue +533 -0
- package/src/components/GridView.vue +8 -13
- package/src/components/ListView.vue +199 -21
- package/src/components/ShortcutsHelp.vue +59 -0
- package/src/components/Toolbar.vue +65 -1
- package/src/composables/useFileApi.ts +45 -0
- package/src/composables/useKeyboardShortcuts.ts +38 -0
- package/src/composables/useRealtime.ts +9 -1
- package/src/index.ts +4 -0
- package/src/lib/fileIcons.ts +127 -0
- package/src/lib/snippet.ts +43 -0
- package/src/locales/en.ts +62 -0
- package/src/locales/tr.ts +62 -0
- package/src/styles/base.css +650 -0
- package/src/styles/variables.css +70 -0
- package/dist/index-DL6_eaM3.js +0 -5888
- 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
|
|
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 ===
|
|
343
|
+
:class="{ 'is-last': i === tailCrumbs.length - 1 }"
|
|
249
344
|
type="button"
|
|
250
|
-
:aria-current="i ===
|
|
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 <
|
|
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>
|