@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
@@ -5,6 +5,7 @@
5
5
  import type { FileNode } from '../types/FileNode';
6
6
  import type { LocaleCode } from '../types/ExplorerConfig';
7
7
  import { useLocale } from '../composables/useLocale';
8
+ import { fileIconSvg } from '../lib/fileIcons';
8
9
 
9
10
  const props = defineProps<{
10
11
  files: FileNode[];
@@ -101,20 +102,12 @@ function parentDir(path: string): string {
101
102
  return stripped.slice(0, idx);
102
103
  }
103
104
 
104
- function iconFor(n: FileNode): string {
105
+ // Special rows keep their emoji (trash/storage are not file-TYPE icons);
106
+ // everything else renders the SVG icon set from lib/fileIcons.
107
+ function specialEmojiFor(n: FileNode): string | null {
105
108
  if (n.basename === '.trash') return '🗑';
106
109
  if (n.mime_type === 'inode/storage') return '💾';
107
- if (n.type === 'dir') return '📁';
108
- const e = (n.extension || '').toLowerCase();
109
- if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg'].includes(e)) return '🖼';
110
- if (['mp4', 'webm', 'mov', 'mkv'].includes(e)) return '🎞';
111
- if (['mp3', 'wav', 'flac', 'ogg'].includes(e)) return '🎵';
112
- if (e === 'pdf') return '📕';
113
- if (['doc', 'docx', 'odt'].includes(e)) return '📄';
114
- if (['xls', 'xlsx', 'csv'].includes(e)) return '📊';
115
- if (['ppt', 'pptx'].includes(e)) return '📽';
116
- if (['zip', 'tar', 'gz', '7z'].includes(e)) return '🗜';
117
- return '📎';
110
+ return null;
118
111
  }
119
112
  </script>
120
113
 
@@ -156,7 +149,9 @@ function iconFor(n: FileNode): string {
156
149
  loading="lazy"
157
150
  draggable="false"
158
151
  />
159
- <span v-else class="fe-grid__icon">{{ iconFor(n) }}</span>
152
+ <span v-else-if="specialEmojiFor(n)" class="fe-grid__icon">{{ specialEmojiFor(n) }}</span>
153
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
154
+ <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconSvg(n)"></span>
160
155
  </div>
161
156
  <div class="fe-grid__label" :title="n.basename">
162
157
  {{ nodeDisplayName(n) }}
@@ -6,10 +6,12 @@
6
6
  * just emit the events and let FileExplorer.vue handle them uniformly
7
7
  * across List and Grid.
8
8
  */
9
- import { computed } from 'vue';
9
+ import { computed, ref } from 'vue';
10
10
  import type { FileNode } from '../types/FileNode';
11
11
  import type { LocaleCode } from '../types/ExplorerConfig';
12
12
  import { useLocale } from '../composables/useLocale';
13
+ import { fileIconSvg } from '../lib/fileIcons';
14
+ import { matchedInContent, snippetSegments } from '../lib/snippet'; /* bul:s3 */
13
15
  import StarButton from './StarButton.vue';
14
16
 
15
17
  const props = defineProps<{
@@ -111,21 +113,16 @@ function cancelPress() {
111
113
  pressTarget = null;
112
114
  }
113
115
 
114
- function iconFor(n: FileNode): string {
116
+ // Special rows keep their emoji (trash/storage are not file-TYPE icons);
117
+ // everything else renders the SVG icon set from lib/fileIcons.
118
+ function specialEmojiFor(n: FileNode): string | null {
115
119
  if (n.basename === '.trash') return '🗑';
116
120
  if (n.mime_type === 'inode/storage') return '💾';
117
- if (n.type === 'dir') return '📁';
118
- const e = (n.extension || '').toLowerCase();
119
- if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'heic', 'svg'].includes(e)) return '🖼';
120
- if (['mp4', 'webm', 'mov', 'mkv', 'avi', 'ogv', 'm4v'].includes(e)) return '🎞';
121
- if (['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus'].includes(e)) return '🎵';
122
- if (e === 'pdf') return '📕';
123
- if (['doc', 'docx', 'odt', 'rtf'].includes(e)) return '📄';
124
- if (['xls', 'xlsx', 'ods', 'csv'].includes(e)) return '📊';
125
- if (['ppt', 'pptx', 'odp'].includes(e)) return '📽';
126
- if (['zip', 'tar', 'gz', 'bz2', '7z', 'rar'].includes(e)) return '🗜';
127
- if (['txt', 'md', 'log', 'json', 'yml', 'yaml', 'xml'].includes(e)) return '📝';
128
- return '📎';
121
+ return null;
122
+ }
123
+
124
+ function isPinnedSpecial(n: FileNode): boolean {
125
+ return n.basename === '.trash' || n.mime_type === 'inode/storage';
129
126
  }
130
127
 
131
128
  function displayDate(ms: number | undefined): string {
@@ -141,20 +138,187 @@ function parentDir(path: string): string {
141
138
  return stripped.slice(0, idx);
142
139
  }
143
140
 
144
- const rows = computed(() => props.files);
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
+
154
+ // ------------------------------------------------------------------
155
+ // Column sorting — local to the list view. Default (null) keeps the
156
+ // backend order, i.e. exactly the pre-existing behavior.
157
+ // ------------------------------------------------------------------
158
+
159
+ type SortKey = 'name' | 'size' | 'modified';
160
+ const SORT_LS_KEY = 'filex.list-sort';
161
+
162
+ const sortKey = ref<SortKey | null>(null);
163
+ const sortDir = ref<'asc' | 'desc'>('asc');
164
+ try {
165
+ const raw = localStorage.getItem(SORT_LS_KEY);
166
+ if (raw) {
167
+ const p = JSON.parse(raw) as { key?: unknown; dir?: unknown };
168
+ if (p.key === 'name' || p.key === 'size' || p.key === 'modified') sortKey.value = p.key;
169
+ if (p.dir === 'asc' || p.dir === 'desc') sortDir.value = p.dir;
170
+ }
171
+ } catch {
172
+ /* private mode / bad JSON */
173
+ }
174
+
175
+ // Dates default to newest-first, the others to ascending.
176
+ function defaultDir(key: SortKey): 'asc' | 'desc' {
177
+ return key === 'modified' ? 'desc' : 'asc';
178
+ }
179
+
180
+ // Click cycle per column: default direction → flipped → back to backend order.
181
+ function toggleSort(key: SortKey) {
182
+ if (sortKey.value !== key) {
183
+ sortKey.value = key;
184
+ sortDir.value = defaultDir(key);
185
+ } else if (sortDir.value === defaultDir(key)) {
186
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
187
+ } else {
188
+ sortKey.value = null;
189
+ }
190
+ try {
191
+ localStorage.setItem(
192
+ SORT_LS_KEY,
193
+ JSON.stringify({ key: sortKey.value, dir: sortDir.value }),
194
+ );
195
+ } catch {
196
+ /* quota */
197
+ }
198
+ }
199
+
200
+ function ariaSort(key: SortKey): 'ascending' | 'descending' | 'none' {
201
+ if (sortKey.value !== key) return 'none';
202
+ return sortDir.value === 'asc' ? 'ascending' : 'descending';
203
+ }
204
+
205
+ function modifiedMs(n: FileNode): number | null {
206
+ const v = n.last_modified;
207
+ if (!v) return null;
208
+ return v * (v < 1e12 ? 1000 : 1);
209
+ }
210
+
211
+ const rows = computed(() => {
212
+ const key = sortKey.value;
213
+ if (!key) return props.files;
214
+ // Virtual rows (.trash / storage drives) stay pinned on top in their
215
+ // original order; only real entries take part in the sort.
216
+ const pinned: FileNode[] = [];
217
+ const rest: FileNode[] = [];
218
+ for (const n of props.files) (isPinnedSpecial(n) ? pinned : rest).push(n);
219
+ const dir = sortDir.value === 'asc' ? 1 : -1;
220
+ rest.sort((a, b) => {
221
+ if (key === 'name') {
222
+ return (
223
+ dir *
224
+ a.basename.localeCompare(b.basename, undefined, { numeric: true, sensitivity: 'base' })
225
+ );
226
+ }
227
+ if (key === 'size') return dir * ((a.size ?? -1) - (b.size ?? -1));
228
+ const am = modifiedMs(a);
229
+ const bm = modifiedMs(b);
230
+ // Undated rows go last in both directions so date groups stay clean.
231
+ if (am == null && bm == null) return 0;
232
+ if (am == null) return 1;
233
+ if (bm == null) return -1;
234
+ return dir * (am - bm);
235
+ });
236
+ return [...pinned, ...rest];
237
+ });
238
+
239
+ // ------------------------------------------------------------------
240
+ // Date grouping — only while sorted by modified date. Rows are split
241
+ // into contiguous segments so the row markup below stays untouched.
242
+ // ------------------------------------------------------------------
243
+
244
+ interface Segment {
245
+ id: string;
246
+ /** null = no header (ungrouped mode, or the pinned virtual rows). */
247
+ label: string | null;
248
+ nodes: FileNode[];
249
+ }
250
+
251
+ const monthFmt = computed(
252
+ () =>
253
+ new Intl.DateTimeFormat(props.locale === 'tr' ? 'tr-TR' : 'en-US', {
254
+ month: 'long',
255
+ year: 'numeric',
256
+ }),
257
+ );
258
+
259
+ function bucketFor(ms: number | null): { id: string; label: string } {
260
+ if (ms == null) return { id: 'none', label: t('group.no_date') };
261
+ const d = new Date(ms);
262
+ const now = new Date();
263
+ const dayStart = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
264
+ const today = dayStart(now);
265
+ const day = dayStart(d);
266
+ const DAY = 86_400_000;
267
+ // Future stamps (clock skew) read best as "today".
268
+ if (day >= today) return { id: 'today', label: t('group.today') };
269
+ if (day === today - DAY) return { id: 'yesterday', label: t('group.yesterday') };
270
+ if (day > today - 7 * DAY) return { id: 'week', label: t('group.this_week') };
271
+ if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
272
+ return { id: 'month', label: t('group.this_month') };
273
+ }
274
+ return { id: `m-${d.getFullYear()}-${d.getMonth()}`, label: monthFmt.value.format(d) };
275
+ }
276
+
277
+ const segments = computed<Segment[]>(() => {
278
+ const list = rows.value;
279
+ if (sortKey.value !== 'modified') return [{ id: 'all', label: null, nodes: list }];
280
+ const out: Segment[] = [];
281
+ let cur: Segment | null = null;
282
+ for (const n of list) {
283
+ const b = isPinnedSpecial(n) ? { id: 'pinned', label: null } : bucketFor(modifiedMs(n));
284
+ if (!cur || cur.id !== b.id) {
285
+ cur = { id: b.id, label: b.label, nodes: [] };
286
+ out.push(cur);
287
+ }
288
+ cur.nodes.push(n);
289
+ }
290
+ return out;
291
+ });
145
292
  </script>
146
293
 
147
294
  <template>
148
295
  <div class="fe-list" :class="{ 'is-loading': loading, 'has-star-col': !!apiBase || apiBase === '' }">
149
296
  <div class="fe-list__head" role="row">
150
297
  <div class="fe-list__col fe-list__col--star" role="columnheader" aria-label="Star"></div>
151
- <div class="fe-list__col fe-list__col--name" role="columnheader">{{ t('col.name') }}</div>
152
- <div class="fe-list__col fe-list__col--size" role="columnheader">{{ t('col.size') }}</div>
153
- <div class="fe-list__col fe-list__col--mod" role="columnheader">{{ t('col.modified') }}</div>
298
+ <div class="fe-list__col fe-list__col--name" role="columnheader" :aria-sort="ariaSort('name')">
299
+ <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('name')">
300
+ {{ t('col.name') }}
301
+ <span v-if="sortKey === 'name'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
302
+ </button>
303
+ </div>
304
+ <div class="fe-list__col fe-list__col--size" role="columnheader" :aria-sort="ariaSort('size')">
305
+ <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('size')">
306
+ {{ t('col.size') }}
307
+ <span v-if="sortKey === 'size'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
308
+ </button>
309
+ </div>
310
+ <div class="fe-list__col fe-list__col--mod" role="columnheader" :aria-sort="ariaSort('modified')">
311
+ <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('modified')">
312
+ {{ t('col.modified') }}
313
+ <span v-if="sortKey === 'modified'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
314
+ </button>
315
+ </div>
154
316
  </div>
155
317
  <div class="fe-list__body" role="rowgroup">
318
+ <template v-for="seg in segments" :key="seg.id">
319
+ <div v-if="seg.label" class="fe-list__group" role="presentation">{{ seg.label }}</div>
156
320
  <div
157
- v-for="n in rows"
321
+ v-for="n in seg.nodes"
158
322
  :key="n.path"
159
323
  class="fe-list__row"
160
324
  :class="{
@@ -188,14 +352,27 @@ const rows = computed(() => props.files);
188
352
  />
189
353
  </div>
190
354
  <div class="fe-list__col fe-list__col--name">
191
- <span class="fe-list__icon" aria-hidden="true">{{ iconFor(n) }}</span>
355
+ <span v-if="specialEmojiFor(n)" class="fe-list__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
356
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
357
+ <span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></span>
192
358
  <div class="fe-list__name-wrap">
193
- <span class="fe-list__name" :title="n.basename">{{ nodeDisplayName(n) }}</span>
359
+ <span class="fe-list__name" :title="n.basename">
360
+ {{ nodeDisplayName(n) }}
361
+ <!-- bul:s3 — content-match badge -->
362
+ <span v-if="rowInContent(n)" class="fe-list__badge">{{ t('search.in_content') }}</span>
363
+ </span>
194
364
  <span
195
365
  v-if="showParentPath"
196
366
  class="fe-list__parent"
197
367
  :title="parentDir(n.path)"
198
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>
199
376
  </div>
200
377
  </div>
201
378
  <div class="fe-list__col fe-list__col--size">
@@ -205,6 +382,7 @@ const rows = computed(() => props.files);
205
382
  {{ displayDate(n.last_modified) }}
206
383
  </div>
207
384
  </div>
385
+ </template>
208
386
  <div v-if="!loading && rows.length === 0" class="fe-list__empty">
209
387
  {{ t('empty.folder') }}
210
388
  </div>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ShortcutsHelp — "?" cheat-sheet modal.
4
+ *
5
+ * Renders the SHORTCUTS table exported by useKeyboardShortcuts (single
6
+ * source of truth) grouped by section. Modal.vue supplies Esc handling,
7
+ * backdrop, focus and the close button.
8
+ */
9
+ import { computed } from 'vue';
10
+ import type { LocaleCode } from '../types/ExplorerConfig';
11
+ import { useLocale } from '../composables/useLocale';
12
+ import { SHORTCUTS, type ShortcutDef } from '../composables/useKeyboardShortcuts';
13
+ import Modal from '../modals/Modal.vue';
14
+
15
+ const props = defineProps<{
16
+ open: boolean;
17
+ locale: LocaleCode;
18
+ }>();
19
+
20
+ const emit = defineEmits<{
21
+ (e: 'close'): void;
22
+ }>();
23
+
24
+ const { t } = useLocale(() => props.locale);
25
+
26
+ const groups = computed(() => {
27
+ const order: string[] = [];
28
+ const map = new Map<string, ShortcutDef[]>();
29
+ for (const s of SHORTCUTS) {
30
+ if (!map.has(s.groupKey)) {
31
+ map.set(s.groupKey, []);
32
+ order.push(s.groupKey);
33
+ }
34
+ map.get(s.groupKey)!.push(s);
35
+ }
36
+ return order.map((key) => ({ key, items: map.get(key)! }));
37
+ });
38
+ </script>
39
+
40
+ <template>
41
+ <Modal :open="open" :title="t('shortcuts.title')" size="md" @close="emit('close')">
42
+ <div class="fe-shortcuts">
43
+ <section v-for="g in groups" :key="g.key" class="fe-shortcuts__group">
44
+ <h3 class="fe-shortcuts__heading">{{ t(g.key) }}</h3>
45
+ <div class="fe-shortcuts__table">
46
+ <div v-for="s in g.items" :key="s.labelKey" class="fe-shortcuts__row">
47
+ <span class="fe-shortcuts__keys">
48
+ <template v-for="(combo, i) in s.keys" :key="combo">
49
+ <kbd class="fe-kbd">{{ combo }}</kbd>
50
+ <span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
51
+ </template>
52
+ </span>
53
+ <span class="fe-shortcuts__desc">{{ t(s.labelKey) }}</span>
54
+ </div>
55
+ </div>
56
+ </section>
57
+ </div>
58
+ </Modal>
59
+ </template>
@@ -11,7 +11,7 @@
11
11
  * Presentational only — all logic (rename, share, …) lives in
12
12
  * FileExplorer.vue, which listens for `action` emits.
13
13
  */
14
- import { computed, ref, watch } from 'vue';
14
+ import { computed, onMounted, ref, watch } from 'vue';
15
15
  import type { ViewMode } from '../types/FileNode';
16
16
  import type { LocaleCode } from '../types/ExplorerConfig';
17
17
  import type { ContextAction } from './ContextMenu.vue';
@@ -69,8 +69,34 @@ const emit = defineEmits<{
69
69
  (e: 'go-up'): void;
70
70
  (e: 'action', key: string): void;
71
71
  (e: 'open-recents'): void;
72
+ (e: 'update:density', v: Density): void;
72
73
  }>();
73
74
 
75
+ // Density toggle — the toolbar owns the persisted preference; the parent
76
+ // only mirrors the value into a root class so both views pick it up.
77
+ export type Density = 'comfortable' | 'compact';
78
+ const DENSITY_LS_KEY = 'filex.density';
79
+ const density = ref<Density>(
80
+ (() => {
81
+ try {
82
+ return localStorage.getItem(DENSITY_LS_KEY) === 'compact' ? 'compact' : 'comfortable';
83
+ } catch {
84
+ return 'comfortable';
85
+ }
86
+ })(),
87
+ );
88
+ onMounted(() => emit('update:density', density.value));
89
+
90
+ function toggleDensity() {
91
+ density.value = density.value === 'compact' ? 'comfortable' : 'compact';
92
+ try {
93
+ localStorage.setItem(DENSITY_LS_KEY, density.value);
94
+ } catch {
95
+ /* quota */
96
+ }
97
+ emit('update:density', density.value);
98
+ }
99
+
74
100
  const { t } = useLocale(() => props.locale);
75
101
 
76
102
  const searchEl = ref<HTMLInputElement | null>(null);
@@ -192,6 +218,44 @@ function fire(key: string) {
192
218
  </button>
193
219
  </div>
194
220
 
221
+ <button
222
+ type="button"
223
+ class="fe-btn fe-btn--icon-only fe-toolbar__density"
224
+ :class="{ 'is-active': density === 'compact' }"
225
+ :aria-pressed="density === 'compact'"
226
+ :aria-label="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
227
+ :title="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
228
+ @click="toggleDensity"
229
+ >
230
+ <!-- Row-spacing glyph: tight rows when compact is ON, airy rows otherwise. -->
231
+ <svg
232
+ v-if="density === 'compact'"
233
+ class="fe-ficon"
234
+ viewBox="0 0 24 24"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ stroke-width="1.8"
238
+ stroke-linecap="round"
239
+ aria-hidden="true"
240
+ focusable="false"
241
+ >
242
+ <path d="M4 6.5h16M4 10.5h16M4 14.5h16M4 18.5h16" />
243
+ </svg>
244
+ <svg
245
+ v-else
246
+ class="fe-ficon"
247
+ viewBox="0 0 24 24"
248
+ fill="none"
249
+ stroke="currentColor"
250
+ stroke-width="1.8"
251
+ stroke-linecap="round"
252
+ aria-hidden="true"
253
+ focusable="false"
254
+ >
255
+ <path d="M4 5.5h16M4 12h16M4 18.5h16" />
256
+ </svg>
257
+ </button>
258
+
195
259
  <div class="fe-toolbar__view" role="tablist" aria-label="View">
196
260
  <button
197
261
  type="button"
@@ -101,6 +101,34 @@ export interface InviteResponse {
101
101
  emailed: boolean;
102
102
  }
103
103
 
104
+ /* === bul:s3 — global search (GET /api/files/search) === */
105
+
106
+ export type GlobalSearchScope = 'name' | 'content' | 'all';
107
+
108
+ /**
109
+ * One hit from the dedicated files-search endpoint. The backend returns raw
110
+ * node rows (`{results: [...]}`), so `path` is the IN-STORAGE relative path
111
+ * (no `adapter://` prefix) and the storage comes back as a numeric
112
+ * `storage_id`. `snippet`/`matched` are the v0.2 "Bul" contract additions —
113
+ * older backends simply omit them, so every consumer must stay
114
+ * undefined-safe.
115
+ */
116
+ export interface GlobalSearchHit {
117
+ id?: number;
118
+ storage_id?: number;
119
+ name?: string;
120
+ path?: string;
121
+ /** `file` | `dir` (backend NodeType). */
122
+ type?: string;
123
+ size?: number;
124
+ mime?: string;
125
+ /** Plain-text content snippet; matches wrapped in «» (never HTML). */
126
+ snippet?: string;
127
+ /** Where the hit matched: name | content | both. */
128
+ matched?: 'name' | 'content' | 'both';
129
+ [k: string]: unknown;
130
+ }
131
+
104
132
  /**
105
133
  * Resolve a Vuefinder-compatible endpoint map from the user's config.
106
134
  * Either `apiBase` is set (auto-derive everything) or each route is
@@ -340,6 +368,22 @@ export function useFileApi(config: ExplorerConfig) {
340
368
  return jsonFetch<ManagerResponse>(managerUrl('search', { path, filter }));
341
369
  }
342
370
 
371
+ /* === bul:s3 — global "search everywhere" ===
372
+ * Derived from the manager endpoint by swapping `/manager` for `/search`
373
+ * (same trick permissionsUrl uses), so embedded proxies that forward the
374
+ * whole /api/files/* subtree keep working. Errors and legacy backends
375
+ * degrade to an empty result list — the palette just shows nothing. */
376
+ async function globalSearch(
377
+ query: string,
378
+ opts: { limit?: number; scope?: GlobalSearchScope } = {},
379
+ ): Promise<GlobalSearchHit[]> {
380
+ const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/search');
381
+ const sep = base.includes('?') ? '&' : '?';
382
+ const url = `${base}${sep}${qs({ q: query, limit: opts.limit, scope: opts.scope })}`;
383
+ const data = await jsonFetch<{ results?: GlobalSearchHit[] | null }>(url);
384
+ return Array.isArray(data?.results) ? data.results : [];
385
+ }
386
+
343
387
  async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
344
388
  return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
345
389
  }
@@ -661,6 +705,7 @@ export function useFileApi(config: ExplorerConfig) {
661
705
  // Manager
662
706
  index,
663
707
  search,
708
+ globalSearch /* bul:s3 */,
664
709
  subfolders,
665
710
  newFolder,
666
711
  rename,
@@ -21,9 +21,41 @@ export interface ShortcutHandlers {
21
21
  onDuplicate?: () => void; // Ctrl+D
22
22
  onPathJump?: () => void; // Cmd+K / Ctrl+K
23
23
  onGoUp?: () => void; // Alt+Up / Backspace (when nothing selected)
24
+ onShowHelp?: () => void; // ? (Shift+/ on most layouts)
24
25
  hasSelection?: () => boolean; // disambiguates Backspace
25
26
  }
26
27
 
28
+ /**
29
+ * SHORTCUTS — the single source of truth for the shortcut cheat-sheet
30
+ * (ShortcutsHelp.vue). Only shortcuts actually wired by FileExplorer are
31
+ * listed; labels/groups resolve through the locale catalogue so the help
32
+ * dialog stays tr/en aware. `keys` entries are display combos — each one
33
+ * renders as its own <kbd>, alternatives joined visually with "/".
34
+ */
35
+ export interface ShortcutDef {
36
+ keys: string[];
37
+ labelKey: string;
38
+ groupKey: string;
39
+ }
40
+
41
+ export const SHORTCUTS: ShortcutDef[] = [
42
+ // Navigation
43
+ { keys: ['Ctrl+K'], labelKey: 'shortcuts.palette', groupKey: 'shortcuts.group.nav' },
44
+ { keys: ['/'], labelKey: 'shortcuts.search', groupKey: 'shortcuts.group.nav' },
45
+ { keys: ['Alt+↑', 'Backspace'], labelKey: 'shortcuts.go_up', groupKey: 'shortcuts.group.nav' },
46
+ { keys: ['Enter'], labelKey: 'shortcuts.open', groupKey: 'shortcuts.group.nav' },
47
+ { keys: ['Esc'], labelKey: 'shortcuts.close', groupKey: 'shortcuts.group.nav' },
48
+ { keys: ['?'], labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
49
+ // Selection
50
+ { keys: ['Ctrl+A'], labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
51
+ // File operations
52
+ { keys: ['F2'], labelKey: 'shortcuts.rename', groupKey: 'shortcuts.group.file' },
53
+ { keys: ['Del'], labelKey: 'shortcuts.delete', groupKey: 'shortcuts.group.file' },
54
+ { keys: ['Ctrl+X'], labelKey: 'shortcuts.cut', groupKey: 'shortcuts.group.file' },
55
+ { keys: ['Ctrl+C'], labelKey: 'shortcuts.copy', groupKey: 'shortcuts.group.file' },
56
+ { keys: ['Ctrl+V'], labelKey: 'shortcuts.paste', groupKey: 'shortcuts.group.file' },
57
+ ];
58
+
27
59
  export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers: ShortcutHandlers) {
28
60
  function onKey(e: KeyboardEvent) {
29
61
  const root = rootEl.value;
@@ -87,6 +119,12 @@ export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers:
87
119
  handlers.onFocusSearch();
88
120
  }
89
121
  break;
122
+ case '?':
123
+ if (handlers.onShowHelp && !ctrl && !e.altKey) {
124
+ e.preventDefault();
125
+ handlers.onShowHelp();
126
+ }
127
+ break;
90
128
  case 'a':
91
129
  case 'A':
92
130
  if (ctrl && handlers.onSelectAll) {
@@ -19,6 +19,12 @@ export interface RealtimeApi {
19
19
  export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
20
20
  const presenceUsers: Ref<PresenceUser[]> = ref([]);
21
21
  const connected = ref(false);
22
+ // True while the live socket is unavailable and the polling fallback is
23
+ // active — the explorer surfaces a small "no live connection" badge from it.
24
+ // Stays false during ordinary reconnect attempts, so brief blips don't flash
25
+ // the badge; only a genuinely given-up socket (RealtimeClient onFallback)
26
+ // flips it.
27
+ const degraded = ref(false);
22
28
 
23
29
  let client: RealtimeClient | null = null;
24
30
  let pendingSubscribe: string | null = null;
@@ -42,6 +48,7 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
42
48
  }
43
49
 
44
50
  function onFallback(active: boolean): void {
51
+ degraded.value = active;
45
52
  if (active) {
46
53
  // No live socket → poll the listing; presence isn't available here.
47
54
  presenceUsers.value = [];
@@ -88,7 +95,8 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
88
95
  }
89
96
  client?.close();
90
97
  client = null;
98
+ degraded.value = false;
91
99
  }
92
100
 
93
- return { presenceUsers, connected, start, subscribe, setFocus, stop };
101
+ return { presenceUsers, connected, degraded, start, subscribe, setFocus, stop };
94
102
  }
package/src/index.ts CHANGED
@@ -57,6 +57,10 @@ export { isExternalUsable } from './types/FileNode';
57
57
  // ——— Composables (consumers can roll their own UI on top) ———
58
58
  export { useFileApi, resolveEndpoints } from './composables/useFileApi';
59
59
  export type { FileApi, ManagerResponse, PendingOpDto } from './composables/useFileApi';
60
+ /* bul:s3 — global-search contract types + snippet helpers */
61
+ export type { GlobalSearchHit, GlobalSearchScope } from './composables/useFileApi';
62
+ export { snippetSegments, matchedInContent } from './lib/snippet';
63
+ export type { SnippetSegment, SearchMatched } from './lib/snippet';
60
64
 
61
65
  export { useUploadChunked } from './composables/useUploadChunked';
62
66
  export type { UploadJob, UploadOptions } from './composables/useUploadChunked';