@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.
- package/dist/{ArchiveViewer-wg4uE-3C.js → ArchiveViewer-CYrKPEbE.js} +2 -2
- package/dist/{ArchiveViewer-wg4uE-3C.js.map → ArchiveViewer-CYrKPEbE.js.map} +1 -1
- package/dist/{CsvViewer-57uz4UKk.js → CsvViewer-C3ZU4Uua.js} +2 -2
- package/dist/{CsvViewer-57uz4UKk.js.map → CsvViewer-C3ZU4Uua.js.map} +1 -1
- package/dist/{DrawioViewer-BfL9Hbyp.js → DrawioViewer-15ZL4ken.js} +2 -2
- package/dist/{DrawioViewer-BfL9Hbyp.js.map → DrawioViewer-15ZL4ken.js.map} +1 -1
- package/dist/{EpubViewer-CYG2CV_3.js → EpubViewer-CrXcDgmo.js} +2 -2
- package/dist/{EpubViewer-CYG2CV_3.js.map → EpubViewer-CrXcDgmo.js.map} +1 -1
- package/dist/{IpynbViewer-DJw34-wV.js → IpynbViewer-BAb9rV2Q.js} +2 -2
- package/dist/{IpynbViewer-DJw34-wV.js.map → IpynbViewer-BAb9rV2Q.js.map} +1 -1
- package/dist/{MermaidViewer-C86jjm27.js → MermaidViewer-BN8GN4FY.js} +2 -2
- package/dist/{MermaidViewer-C86jjm27.js.map → MermaidViewer-BN8GN4FY.js.map} +1 -1
- package/dist/{PsdViewer-DGHIqv-x.js → PsdViewer-o-kWQExw.js} +2 -2
- package/dist/{PsdViewer-DGHIqv-x.js.map → PsdViewer-o-kWQExw.js.map} +1 -1
- package/dist/{TiffViewer-gct9Ub3o.js → TiffViewer-AjMVBzxp.js} +2 -2
- package/dist/{TiffViewer-gct9Ub3o.js.map → TiffViewer-AjMVBzxp.js.map} +1 -1
- package/dist/{Viewer3D-BVt4l2a9.js → Viewer3D-BR3WDbxQ.js} +2 -2
- package/dist/{Viewer3D-BVt4l2a9.js.map → Viewer3D-BR3WDbxQ.js.map} +1 -1
- package/dist/filex-core.js +17 -15
- package/dist/filex-core.umd.cjs +41 -41
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-BG5upYlH.js +7392 -0
- package/dist/index-BG5upYlH.js.map +1 -0
- package/dist/index.d.ts +106 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +109 -1
- package/src/components/CommandPalette.vue +239 -5
- package/src/components/ContextMenu.vue +101 -3
- package/src/components/ListView.vue +26 -1
- package/src/components/Toolbar.vue +216 -5
- package/src/composables/useFileApi.ts +45 -0
- package/src/index.ts +4 -0
- package/src/lib/snippet.ts +43 -0
- package/src/locales/en.ts +14 -0
- package/src/locales/tr.ts +14 -0
- package/src/styles/base.css +238 -0
- package/dist/index-BeUoODQq.js +0 -6871
- package/dist/index-BeUoODQq.js.map +0 -1
package/src/FileExplorer.vue
CHANGED
|
@@ -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
|
|
263
|
+
const hitOffset = computed(() => fileOffset.value + fileItems.value.length);
|
|
264
|
+
const savedOffset = computed(() => hitOffset.value + hitItems.value.length);
|
|
265
|
+
const cmdOffset = computed(() => savedOffset.value + savedItems.value.length);
|
|
120
266
|
|
|
121
|
-
watch(query, () => {
|
|
267
|
+
watch(query, (q) => {
|
|
122
268
|
active.value = 0;
|
|
269
|
+
scheduleEverywhere(q.trim()); /* bul:s3 */
|
|
123
270
|
});
|
|
124
271
|
|
|
125
272
|
function move(delta: number) {
|
|
@@ -134,7 +281,23 @@ function move(delta: number) {
|
|
|
134
281
|
function choose(item?: PaletteItem) {
|
|
135
282
|
const it = item ?? flat.value[active.value];
|
|
136
283
|
if (!it) return;
|
|
284
|
+
/* bul:s3 — saved-search rows keep the palette OPEN: picking one re-runs
|
|
285
|
+
* the palette with that query; saving just persists it. */
|
|
286
|
+
if (it.kind === 'saved') {
|
|
287
|
+
query.value = it.query;
|
|
288
|
+
void nextTick(() => inputEl.value?.focus());
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (it.kind === 'save') {
|
|
292
|
+
saveSearch(it.query);
|
|
293
|
+
void nextTick(() => inputEl.value?.focus());
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
137
296
|
emit('close');
|
|
297
|
+
if (it.kind === 'hit') {
|
|
298
|
+
emit('open-hit', it.hit);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
138
301
|
if (it.kind === 'file') {
|
|
139
302
|
emit('open-node', it.node);
|
|
140
303
|
} else if (it.kind === 'goto') {
|
|
@@ -183,15 +346,23 @@ watch(
|
|
|
183
346
|
if (v) {
|
|
184
347
|
query.value = '';
|
|
185
348
|
active.value = 0;
|
|
349
|
+
/* bul:s3 — fresh session state for the new groups. */
|
|
350
|
+
everywhere.value = [];
|
|
351
|
+
everywhereLoading.value = false;
|
|
352
|
+
savedSearches.value = readSavedSearches();
|
|
186
353
|
document.addEventListener('keydown', onDocKeydown, true);
|
|
187
354
|
void nextTick(() => inputEl.value?.focus());
|
|
188
355
|
} else {
|
|
356
|
+
if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
|
|
189
357
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
190
358
|
}
|
|
191
359
|
},
|
|
192
360
|
);
|
|
193
361
|
|
|
194
|
-
onBeforeUnmount(() =>
|
|
362
|
+
onBeforeUnmount(() => {
|
|
363
|
+
if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
|
|
364
|
+
document.removeEventListener('keydown', onDocKeydown, true);
|
|
365
|
+
});
|
|
195
366
|
</script>
|
|
196
367
|
|
|
197
368
|
<template>
|
|
@@ -269,6 +440,69 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onDocKeydown, true
|
|
|
269
440
|
</button>
|
|
270
441
|
</template>
|
|
271
442
|
|
|
443
|
+
<!-- bul:s3 — "Everywhere" global-search hits -->
|
|
444
|
+
<template v-if="hitItems.length || everywhereLoading">
|
|
445
|
+
<div class="fe-cmdp__group">{{ t('palette.everywhere') }}</div>
|
|
446
|
+
<div v-if="everywhereLoading && !hitItems.length" class="fe-cmdp__loading">
|
|
447
|
+
{{ t('palette.searching') }}
|
|
448
|
+
</div>
|
|
449
|
+
<button
|
|
450
|
+
v-for="(it, i) in hitItems"
|
|
451
|
+
:key="it.id"
|
|
452
|
+
type="button"
|
|
453
|
+
class="fe-cmdp__item fe-cmdp__item--hit"
|
|
454
|
+
:class="{ 'is-active': hitOffset + i === active }"
|
|
455
|
+
role="option"
|
|
456
|
+
:aria-selected="hitOffset + i === active"
|
|
457
|
+
@mouseenter="active = hitOffset + i"
|
|
458
|
+
@click="choose(it)"
|
|
459
|
+
>
|
|
460
|
+
<span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
|
|
461
|
+
<span class="fe-cmdp__hitbody">
|
|
462
|
+
<span class="fe-cmdp__hitline">
|
|
463
|
+
<span class="fe-cmdp__label">{{ it.label }}</span>
|
|
464
|
+
<span v-if="it.inContent" class="fe-cmdp__badge">{{ t('search.in_content') }}</span>
|
|
465
|
+
</span>
|
|
466
|
+
<span v-if="it.crumb" class="fe-cmdp__crumb" :title="it.crumb">{{ it.crumb }}</span>
|
|
467
|
+
<!-- Snippet: «»-highlights become <mark> via TEXT segments — never innerHTML. -->
|
|
468
|
+
<span v-if="it.snippet" class="fe-cmdp__snippet">
|
|
469
|
+
<template v-for="(seg, si) in snippetSegments(it.snippet)" :key="si">
|
|
470
|
+
<mark v-if="seg.match" class="fe-cmdp__mark">{{ seg.text }}</mark>
|
|
471
|
+
<template v-else>{{ seg.text }}</template>
|
|
472
|
+
</template>
|
|
473
|
+
</span>
|
|
474
|
+
</span>
|
|
475
|
+
</button>
|
|
476
|
+
</template>
|
|
477
|
+
|
|
478
|
+
<!-- bul:s3 — saved searches -->
|
|
479
|
+
<template v-if="savedItems.length">
|
|
480
|
+
<div class="fe-cmdp__group">{{ t('palette.saved') }}</div>
|
|
481
|
+
<div
|
|
482
|
+
v-for="(it, i) in savedItems"
|
|
483
|
+
:key="it.id"
|
|
484
|
+
class="fe-cmdp__item fe-cmdp__item--saved"
|
|
485
|
+
:class="{ 'is-active': savedOffset + i === active }"
|
|
486
|
+
role="option"
|
|
487
|
+
:aria-selected="savedOffset + i === active"
|
|
488
|
+
tabindex="-1"
|
|
489
|
+
@mouseenter="active = savedOffset + i"
|
|
490
|
+
@click="choose(it)"
|
|
491
|
+
@keydown.enter.prevent="choose(it)"
|
|
492
|
+
>
|
|
493
|
+
<span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
|
|
494
|
+
<span class="fe-cmdp__label">{{ it.label }}</span>
|
|
495
|
+
<button
|
|
496
|
+
v-if="it.kind === 'saved'"
|
|
497
|
+
type="button"
|
|
498
|
+
class="fe-cmdp__del"
|
|
499
|
+
:title="t('palette.saved.delete')"
|
|
500
|
+
:aria-label="t('palette.saved.delete')"
|
|
501
|
+
@click.stop="removeSavedSearch(it.query)"
|
|
502
|
+
>🗑</button>
|
|
503
|
+
</div>
|
|
504
|
+
</template>
|
|
505
|
+
|
|
272
506
|
<template v-if="commandItems.length">
|
|
273
507
|
<div class="fe-cmdp__group">{{ t('palette.commands') }}</div>
|
|
274
508
|
<button
|
|
@@ -287,7 +521,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onDocKeydown, true
|
|
|
287
521
|
</button>
|
|
288
522
|
</template>
|
|
289
523
|
|
|
290
|
-
<div v-if="flat.length === 0" class="fe-cmdp__empty">
|
|
524
|
+
<div v-if="flat.length === 0 && !everywhereLoading" class="fe-cmdp__empty">
|
|
291
525
|
{{ t('palette.empty') }}
|
|
292
526
|
</div>
|
|
293
527
|
</div>
|
|
@@ -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
|
-
|
|
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">
|
|
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">
|