@brftech/filex-core 0.30.1 → 0.32.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 (38) hide show
  1. package/README.md +37 -2
  2. package/dist/filex-core.js +10571 -8278
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +95 -87
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +548 -66
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/FileExplorer.vue +1104 -26
  10. package/src/components/Breadcrumb.vue +4 -2
  11. package/src/components/CommandPalette.vue +18 -2
  12. package/src/components/E2eRecoveryUnlockModal.vue +193 -0
  13. package/src/components/EncryptedFolderModal.vue +10 -0
  14. package/src/components/FilterBar.vue +244 -0
  15. package/src/components/GalleryView.vue +48 -0
  16. package/src/components/GridView.vue +85 -3
  17. package/src/components/InspectorPanel.vue +231 -8
  18. package/src/components/ListView.vue +11 -1
  19. package/src/components/RecoveryKeyModal.vue +133 -0
  20. package/src/components/SecondaryPane.vue +15 -1
  21. package/src/components/SideNav.vue +329 -6
  22. package/src/components/StarButton.vue +27 -15
  23. package/src/components/Toolbar.vue +235 -49
  24. package/src/components/ViewSwitcher.vue +81 -0
  25. package/src/composables/useFileApi.ts +83 -0
  26. package/src/composables/useKeyboardShortcuts.ts +7 -0
  27. package/src/index.ts +33 -1
  28. package/src/lib/e2ecrypto.ts +716 -70
  29. package/src/lib/fileFilters.ts +143 -0
  30. package/src/lib/listing.ts +103 -1
  31. package/src/lib/star.ts +42 -0
  32. package/src/lib/tags.ts +105 -0
  33. package/src/locales/en.ts +154 -2
  34. package/src/locales/tr.ts +154 -2
  35. package/src/modals/PermissionsModal.vue +18 -2
  36. package/src/styles/base.css +743 -0
  37. package/src/types/ExplorerConfig.ts +53 -1
  38. package/src/types/FileNode.ts +23 -0
@@ -15,6 +15,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
15
15
  import type { ViewMode } from '../types/FileNode';
16
16
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
17
17
  import ContextMenu, { type ContextAction } from './ContextMenu.vue';
18
+ import ViewSwitcher from './ViewSwitcher.vue';
18
19
  import { useLocale } from '../composables/useLocale';
19
20
 
20
21
  export type SelectionMode = 'none' | 'single-file' | 'single-dir' | 'multi';
@@ -90,6 +91,26 @@ const props = defineProps<{
90
91
  * "power user tool", and gallery is the one nobody outside photos asks for.
91
92
  */
92
93
  viewModes?: ViewMode[];
94
+ /* === surucu:d1 — the Drive shell ==================================== */
95
+ /**
96
+ * Which header this is.
97
+ *
98
+ * 'classic' (default) — the row this toolbar has always been.
99
+ * 'drive' — `uiProfile: 'drive'`: one search field across the
100
+ * width with its ⌘K/Ctrl+K hint, and the controls
101
+ * that belong to a *listing* rather than to the app
102
+ * (view switcher, details toggle) moved down onto the
103
+ * breadcrumb row, where the mockups put them.
104
+ *
105
+ * ⚠ Nothing is DELETED by 'drive'. Density, theme, the shortcut editor, the
106
+ * tour and both other view modes are all in the "⋯" menu this variant
107
+ * renders — the same list the narrow layout has used since bag:b4. A control
108
+ * that is on screen in one profile and unreachable in another is a capability
109
+ * split, not a preset.
110
+ */
111
+ shell?: 'classic' | 'drive';
112
+ /** Folder name for the drive field's placeholder ("Search in Photos"). */
113
+ scopeLabel?: string;
93
114
  }>();
94
115
 
95
116
  const emit = defineEmits<{
@@ -106,6 +127,8 @@ const emit = defineEmits<{
106
127
  (e: 'toggle-nav'): void /* gezinti:g1 */;
107
128
  (e: 'open-theme'): void /* wiring:c1 — tema galerisi */;
108
129
  (e: 'open-shortcut-settings'): void /* wiring:c2 */;
130
+ /* surucu:d1 — escalate this query into the command palette (⌘K/Ctrl+K). */
131
+ (e: 'open-palette', query: string): void;
109
132
  }>();
110
133
 
111
134
  // Density toggle — the toolbar owns the persisted preference; the parent
@@ -294,16 +317,14 @@ function offersView(v: ViewMode): boolean {
294
317
  return !props.viewModes || props.viewModes.includes(v);
295
318
  }
296
319
 
297
- const moreActions = computed<ContextAction[]>(() => {
320
+ /* surucu:d1 the tail of the "⋯" menu on its own: everything that is a
321
+ * SETTING of the explorer rather than an action on a file. The narrow layout
322
+ * shows it after the write/selection actions (unchanged); the drive header
323
+ * shows only this half, because in that layout the selection actions are
324
+ * already on screen as buttons and a menu that repeats them is a second door
325
+ * to the same room. */
326
+ const utilityActions = computed<ContextAction[]>(() => {
298
327
  const list: ContextAction[] = [];
299
- const writable =
300
- !props.trashActive && !props.atVirtualRoot && props.canWrite !== false;
301
- if (mode.value === 'none' && writable) {
302
- list.push({ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' });
303
- if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste'), icon: '📋' });
304
- }
305
- list.push(...toolbarItems.value);
306
- if (list.length) list.push({ divider: true, key: 'bag-sep', label: '' });
307
328
  list.push({ key: 'refresh', label: t('toolbar.refresh'), icon: '↻' });
308
329
  list.push({
309
330
  key: 'density',
@@ -333,6 +354,62 @@ const moreActions = computed<ContextAction[]>(() => {
333
354
  return list;
334
355
  });
335
356
 
357
+ const moreActions = computed<ContextAction[]>(() => {
358
+ const list: ContextAction[] = [];
359
+ const writable =
360
+ !props.trashActive && !props.atVirtualRoot && props.canWrite !== false;
361
+ if (mode.value === 'none' && writable) {
362
+ list.push({ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' });
363
+ if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste'), icon: '📋' });
364
+ }
365
+ list.push(...toolbarItems.value);
366
+ if (list.length) list.push({ divider: true, key: 'bag-sep', label: '' });
367
+ list.push(...utilityActions.value);
368
+ return list;
369
+ });
370
+
371
+ /* === surucu:d1 — the drive header's search field ======================
372
+ * ONE field, and it is the one the toolbar already had: typing here sets
373
+ * `searchQuery`, which the explorer answers with
374
+ * `?action=search&filter=…` — a real search of the folder you are standing in,
375
+ * exactly as before.
376
+ *
377
+ * The ⌘K chip beside it is the ESCALATION, not a second box: it opens the
378
+ * command palette carrying whatever has been typed, and the palette is where
379
+ * "everywhere" lives (the global endpoint, saved searches, and the commands).
380
+ * That is why the hint can sit on this field honestly — Ctrl+K from here does
381
+ * what the hint says, and Ctrl+K from anywhere else still opens the palette
382
+ * as it has since cila:c.
383
+ */
384
+ const macLike =
385
+ typeof navigator !== 'undefined' &&
386
+ /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '');
387
+ const paletteCombo = computed(() => (macLike ? '⌘ K' : 'Ctrl K'));
388
+
389
+ const drivePlaceholder = computed(() =>
390
+ props.scopeLabel
391
+ ? t('drive.search.placeholder', { scope: props.scopeLabel })
392
+ : t('drive.search.placeholder_all'),
393
+ );
394
+
395
+ /** ⌘/Ctrl+K typed INSIDE the field. The global shortcut handler ignores
396
+ * keystrokes aimed at an input — which is correct, and it would otherwise
397
+ * make the hint on this very field a lie. */
398
+ function onSearchKeydown(ev: KeyboardEvent) {
399
+ if ((ev.ctrlKey || ev.metaKey) && (ev.key === 'k' || ev.key === 'K')) {
400
+ ev.preventDefault();
401
+ ev.stopPropagation();
402
+ emit('open-palette', localSearch.value);
403
+ }
404
+ }
405
+
406
+ /* surucu:d1 — the drive header renders the selection actions as buttons, so
407
+ * its "⋯" carries only the settings half; the narrow layout renders no action
408
+ * buttons at all and still needs the whole list. */
409
+ const menuActions = computed<ContextAction[]>(() =>
410
+ props.shell === 'drive' && !props.narrow ? utilityActions.value : moreActions.value,
411
+ );
412
+
336
413
  function openMore() {
337
414
  const r = moreBtnEl.value?.getBoundingClientRect();
338
415
  moreRef.value?.show({ clientX: r ? r.right : 0, clientY: r ? r.bottom + 4 : 0 }, []);
@@ -387,6 +464,7 @@ function onMoreSelect(a: ContextAction) {
387
464
  :class="{
388
465
  'fe-toolbar--narrow': narrow /* bag:b4 */,
389
466
  'fe-toolbar--searching': narrow && searchOpen /* bag:b4 */,
467
+ 'fe-toolbar--drive': shell === 'drive' /* surucu:d1 */,
390
468
  }"
391
469
  >
392
470
  <!-- bag:b4 — wide layout, untouched; renders exactly as before when not narrow -->
@@ -433,8 +511,11 @@ function onMoreSelect(a: ContextAction) {
433
511
  <span class="fe-icon">↑</span>
434
512
  </button>
435
513
 
514
+ <!-- surucu:d1 — in the drive shell the panel's "+ New" menu is the one
515
+ primary action; a second New Folder button here would be a second
516
+ front door to the same modal. -->
436
517
  <button
437
- v-if="mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
518
+ v-if="shell !== 'drive' && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
438
519
  type="button"
439
520
  class="fe-btn fe-btn--primary"
440
521
  :title="t('toolbar.new_folder')"
@@ -502,8 +583,45 @@ function onMoreSelect(a: ContextAction) {
502
583
 
503
584
  <div class="fe-toolbar__spacer" />
504
585
 
505
- <div class="fe-toolbar__search-group">
506
- <div class="fe-search">
586
+ <div class="fe-toolbar__search-group" :class="{ 'fe-toolbar__search-group--drive': shell === 'drive' }">
587
+ <!-- surucu:d1 — the drive shell's one search field: wide, in the header,
588
+ with the ⌘K chip that opens the palette carrying this query. -->
589
+ <div v-if="shell === 'drive'" class="fe-drivesearch" data-testid="drive-search">
590
+ <svg
591
+ class="fe-ficon fe-drivesearch__glass"
592
+ viewBox="0 0 24 24"
593
+ fill="none"
594
+ stroke="currentColor"
595
+ stroke-width="1.9"
596
+ stroke-linecap="round"
597
+ aria-hidden="true"
598
+ focusable="false"
599
+ >
600
+ <circle cx="11" cy="11" r="6.5" />
601
+ <path d="M16 16l4.5 4.5" />
602
+ </svg>
603
+ <input
604
+ ref="searchEl"
605
+ type="search"
606
+ class="fe-drivesearch__input"
607
+ :placeholder="drivePlaceholder"
608
+ :value="localSearch"
609
+ :aria-label="t('toolbar.search')"
610
+ @input="onSearchInput"
611
+ @keydown="onSearchKeydown"
612
+ />
613
+ <button
614
+ type="button"
615
+ class="fe-drivesearch__kbd"
616
+ :title="t('drive.search.hint_title', { combo: paletteCombo })"
617
+ :aria-label="t('drive.search.hint_title', { combo: paletteCombo })"
618
+ data-testid="drive-search-palette"
619
+ @click="emit('open-palette', localSearch)"
620
+ >
621
+ {{ paletteCombo }}
622
+ </button>
623
+ </div>
624
+ <div v-else class="fe-search">
507
625
  <input
508
626
  ref="searchEl"
509
627
  type="search"
@@ -515,7 +633,7 @@ function onMoreSelect(a: ContextAction) {
515
633
  />
516
634
  </div>
517
635
  <button
518
- v-if="!atVirtualRoot && canWrite !== false"
636
+ v-if="shell !== 'drive' && !atVirtualRoot && canWrite !== false"
519
637
  type="button"
520
638
  class="fe-btn fe-btn--icon-only"
521
639
  :title="t('toolbar.upload')"
@@ -533,9 +651,26 @@ function onMoreSelect(a: ContextAction) {
533
651
  >
534
652
  <span class="fe-icon" aria-hidden="true">↻</span>
535
653
  </button>
654
+ <!-- surucu:d1 — density, theme, the shortcut editor, the tour and the
655
+ other view modes. Everything the drive header does not draw is one
656
+ click away here; nothing is removed from the build. -->
657
+ <button
658
+ v-if="shell === 'drive'"
659
+ ref="moreBtnEl"
660
+ type="button"
661
+ class="fe-btn fe-btn--icon-only"
662
+ :title="t('toolbar.more')"
663
+ :aria-label="t('toolbar.more')"
664
+ aria-haspopup="menu"
665
+ data-testid="drive-more"
666
+ @click="openMore"
667
+ >
668
+ <span class="fe-icon" aria-hidden="true">⋯</span>
669
+ </button>
536
670
  </div>
537
671
 
538
672
  <button
673
+ v-if="shell !== 'drive'"
539
674
  type="button"
540
675
  class="fe-btn fe-btn--icon-only fe-toolbar__density"
541
676
  :class="{ 'is-active': density === 'compact' }"
@@ -573,8 +708,11 @@ function onMoreSelect(a: ContextAction) {
573
708
  </svg>
574
709
  </button>
575
710
 
576
- <!-- koru:k1 — inspector (details panel) toggle -->
711
+ <!-- koru:k1 — inspector (details panel) toggle.
712
+ surucu:d1 — the drive shell renders it on the breadcrumb row instead,
713
+ beside the view switcher, where both belong to the listing. -->
577
714
  <button
715
+ v-if="shell !== 'drive'"
578
716
  type="button"
579
717
  class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
580
718
  :class="{ 'is-active': inspectorOpen }"
@@ -601,6 +739,7 @@ function onMoreSelect(a: ContextAction) {
601
739
 
602
740
  <!-- wiring:c1 — tema galerisi (palet ikonu) -->
603
741
  <button
742
+ v-if="shell !== 'drive'"
604
743
  type="button"
605
744
  class="fe-btn fe-btn--icon-only fe-toolbar__theme"
606
745
  :title="t('theme.menu')"
@@ -626,49 +765,96 @@ function onMoreSelect(a: ContextAction) {
626
765
  </button>
627
766
  <!-- /wiring:c1 -->
628
767
 
629
- <div class="fe-toolbar__view" role="tablist" :aria-label="t('toolbar.view_label') /* wiring:c4 — was hardcoded EN */">
630
- <button
631
- type="button"
632
- v-if="offersView('list')"
633
- class="fe-btn fe-btn--icon-only"
634
- :class="{ 'is-active': viewMode === 'list' }"
635
- role="tab"
636
- :aria-selected="viewMode === 'list'"
637
- :title="t('toolbar.view.list')"
638
- :aria-label="t('toolbar.view.list') /* wiring:c4 */"
639
- @click="emit('update:viewMode', 'list')"
640
- >
641
- <span class="fe-icon" aria-hidden="true">☰</span>
642
- </button>
768
+ <ViewSwitcher
769
+ v-if="shell !== 'drive'"
770
+ :view-mode="viewMode"
771
+ :locale="locale"
772
+ :modes="viewModes"
773
+ @update:view-mode="emit('update:viewMode', $event)"
774
+ />
775
+ </template>
776
+
777
+ <!-- surucu:d1 narrow DRIVE layout: [☰] [ search ] [⋯].
778
+ The classic narrow row hides search behind a magnifier, which is the
779
+ right trade when the row also carries Up / Upload / actions. This shell
780
+ does not: "+ New" lives in the drawer, upload has the floating button
781
+ at this width already, and the whole point of the profile is that the
782
+ search field is the thing you see. So the field keeps the row, and only
783
+ the ⌘K chip goes (CSS) — a phone cannot press it, and the palette stays
784
+ in the "⋯" menu. -->
785
+ <template v-else-if="shell === 'drive'">
643
786
  <button
787
+ v-if="navEnabled !== false"
644
788
  type="button"
645
- v-if="offersView('grid')"
646
- class="fe-btn fe-btn--icon-only"
647
- :class="{ 'is-active': viewMode === 'grid' }"
648
- role="tab"
649
- :aria-selected="viewMode === 'grid'"
650
- :title="t('toolbar.view.grid')"
651
- :aria-label="t('toolbar.view.grid') /* wiring:c4 */"
652
- @click="emit('update:viewMode', 'grid')"
789
+ class="fe-btn fe-btn--icon-only fe-toolbar__nav"
790
+ :class="{ 'is-active': navOpen }"
791
+ :aria-pressed="!!navOpen"
792
+ :title="t('toolbar.nav')"
793
+ :aria-label="t('toolbar.nav')"
794
+ data-testid="toolbar-nav"
795
+ @click="emit('toggle-nav')"
653
796
  >
654
- <span class="fe-icon" aria-hidden="true">▦</span>
797
+ <svg
798
+ class="fe-ficon"
799
+ viewBox="0 0 24 24"
800
+ fill="none"
801
+ stroke="currentColor"
802
+ stroke-width="1.8"
803
+ stroke-linecap="round"
804
+ aria-hidden="true"
805
+ focusable="false"
806
+ >
807
+ <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
808
+ <path d="M9.5 4.5v15" />
809
+ </svg>
655
810
  </button>
656
- <!-- wiring:d2 — üçüncü görünüm: galeri -->
811
+ <div class="fe-drivesearch" data-testid="drive-search">
812
+ <svg
813
+ class="fe-ficon fe-drivesearch__glass"
814
+ viewBox="0 0 24 24"
815
+ fill="none"
816
+ stroke="currentColor"
817
+ stroke-width="1.9"
818
+ stroke-linecap="round"
819
+ aria-hidden="true"
820
+ focusable="false"
821
+ >
822
+ <circle cx="11" cy="11" r="6.5" />
823
+ <path d="M16 16l4.5 4.5" />
824
+ </svg>
825
+ <input
826
+ ref="searchEl"
827
+ type="search"
828
+ class="fe-drivesearch__input"
829
+ :placeholder="drivePlaceholder"
830
+ :value="localSearch"
831
+ :aria-label="t('toolbar.search')"
832
+ @input="onSearchInput"
833
+ @keydown="onSearchKeydown"
834
+ />
835
+ <button
836
+ type="button"
837
+ class="fe-drivesearch__kbd"
838
+ :title="t('drive.search.hint_title', { combo: paletteCombo })"
839
+ :aria-label="t('drive.search.hint_title', { combo: paletteCombo })"
840
+ data-testid="drive-search-palette"
841
+ @click="emit('open-palette', localSearch)"
842
+ >
843
+ {{ paletteCombo }}
844
+ </button>
845
+ </div>
657
846
  <button
847
+ ref="moreBtnEl"
658
848
  type="button"
659
- v-if="offersView('gallery')"
660
849
  class="fe-btn fe-btn--icon-only"
661
- :class="{ 'is-active': viewMode === 'gallery' }"
662
- role="tab"
663
- :aria-selected="viewMode === 'gallery'"
664
- :title="t('toolbar.view.gallery')"
665
- :aria-label="t('toolbar.view.gallery')"
666
- @click="emit('update:viewMode', 'gallery')"
850
+ :title="t('toolbar.more')"
851
+ :aria-label="t('toolbar.more')"
852
+ aria-haspopup="menu"
853
+ data-testid="drive-more"
854
+ @click="openMore"
667
855
  >
668
- <span class="fe-icon" aria-hidden="true">▣</span>
856
+ <span class="fe-icon">⋯</span>
669
857
  </button>
670
- <!-- /wiring:d2 -->
671
- </div>
672
858
  </template>
673
859
 
674
860
  <!-- bag:b4 — narrow layout: [↑?] ... [🔍] [⬆] [⋯], search expands full-width -->
@@ -778,7 +964,7 @@ function onMoreSelect(a: ContextAction) {
778
964
  :locale="locale"
779
965
  :theme="theme || 'auto'"
780
966
  :sheet="coarse"
781
- :actions="moreActions"
967
+ :actions="menuActions"
782
968
  @select="onMoreSelect"
783
969
  />
784
970
  </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ViewSwitcher — the list / grid / gallery toggle.
4
+ *
5
+ * Lifted out of Toolbar.vue unchanged (same classes, same glyphs, same
6
+ * `role="tablist"` semantics) because the `drive` profile puts it on the
7
+ * breadcrumb row instead of in the header, and two copies of a switcher is
8
+ * how the two rows end up offering different modes. One component, mounted
9
+ * in whichever row the profile asks for.
10
+ *
11
+ * `modes` absent = all three, so every embedder that predates `uiProfile`
12
+ * keeps the switcher it had.
13
+ *
14
+ * ⚠ No `<style>` block — package CSS lives in `styles/base.css` (a scoped
15
+ * block's `data-v` hash does not match in the web-component build).
16
+ */
17
+ import { useLocale } from '../composables/useLocale';
18
+ import type { LocaleCode } from '../types/ExplorerConfig';
19
+ import type { ViewMode } from '../types/FileNode';
20
+
21
+ const props = defineProps<{
22
+ viewMode: ViewMode;
23
+ locale: LocaleCode;
24
+ modes?: ViewMode[];
25
+ }>();
26
+
27
+ const emit = defineEmits<{ (e: 'update:viewMode', v: ViewMode): void }>();
28
+
29
+ const { t } = useLocale(() => props.locale);
30
+
31
+ function offers(v: ViewMode): boolean {
32
+ return !props.modes || props.modes.includes(v);
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div class="fe-toolbar__view" role="tablist" :aria-label="t('toolbar.view_label')">
38
+ <button
39
+ v-if="offers('list')"
40
+ type="button"
41
+ class="fe-btn fe-btn--icon-only"
42
+ :class="{ 'is-active': viewMode === 'list' }"
43
+ role="tab"
44
+ :aria-selected="viewMode === 'list'"
45
+ :title="t('toolbar.view.list')"
46
+ :aria-label="t('toolbar.view.list')"
47
+ data-testid="view-list"
48
+ @click="emit('update:viewMode', 'list')"
49
+ >
50
+ <span class="fe-icon" aria-hidden="true">☰</span>
51
+ </button>
52
+ <button
53
+ v-if="offers('grid')"
54
+ type="button"
55
+ class="fe-btn fe-btn--icon-only"
56
+ :class="{ 'is-active': viewMode === 'grid' }"
57
+ role="tab"
58
+ :aria-selected="viewMode === 'grid'"
59
+ :title="t('toolbar.view.grid')"
60
+ :aria-label="t('toolbar.view.grid')"
61
+ data-testid="view-grid"
62
+ @click="emit('update:viewMode', 'grid')"
63
+ >
64
+ <span class="fe-icon" aria-hidden="true">▦</span>
65
+ </button>
66
+ <button
67
+ v-if="offers('gallery')"
68
+ type="button"
69
+ class="fe-btn fe-btn--icon-only"
70
+ :class="{ 'is-active': viewMode === 'gallery' }"
71
+ role="tab"
72
+ :aria-selected="viewMode === 'gallery'"
73
+ :title="t('toolbar.view.gallery')"
74
+ :aria-label="t('toolbar.view.gallery')"
75
+ data-testid="view-gallery"
76
+ @click="emit('update:viewMode', 'gallery')"
77
+ >
78
+ <span class="fe-icon" aria-hidden="true">▣</span>
79
+ </button>
80
+ </div>
81
+ </template>
@@ -77,6 +77,14 @@ export interface Grant {
77
77
  inherited?: boolean;
78
78
  }
79
79
 
80
+ /** surucu:d1 — `GET /api/files/quota/me` (quota.Snapshot). */
81
+ export interface QuotaSnapshot {
82
+ used_bytes: number;
83
+ quota_bytes: number;
84
+ percent_used: number;
85
+ unlimited: boolean;
86
+ }
87
+
80
88
  export interface PermissionsResponse {
81
89
  path: string;
82
90
  storage_rbac: boolean;
@@ -226,6 +234,9 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
226
234
  // filex trash: list soft-deleted nodes + restore one by node id.
227
235
  trashList: derive(config.trashList, '/api/files/manager/trash'),
228
236
  trashRestore: derive(config.trashRestore, '/api/files/manager/restore'),
237
+ /* wiring:e2 — escrow proof-of-possession, then the owner is told. */
238
+ e2eEscrowChallenge: derive(config.e2eEscrowChallenge, '/api/files/e2e/escrow/challenge'),
239
+ e2eEscrowUsed: derive(config.e2eEscrowUsed, '/api/files/e2e/escrow/used'),
229
240
  };
230
241
  }
231
242
 
@@ -454,6 +465,31 @@ export function useFileApi(config: ExplorerConfig) {
454
465
  return Array.isArray(data?.results) ? data.results : [];
455
466
  }
456
467
 
468
+ /* === surucu:d1 — the signed-in person's storage line ==================
469
+ * `GET /api/files/quota/me` → `{used_bytes, quota_bytes, percent_used,
470
+ * unlimited}` (internal/quota/service.go `Snapshot`). Derived from the
471
+ * manager endpoint the same way permissions and search are, so a proxy that
472
+ * forwards /api/files/* keeps working.
473
+ *
474
+ * ⚠ PER-USER, never per-storage: usage is `SUM(nodes.size) WHERE owner_id=me`
475
+ * and there is no per-provider quota. Anything labelling this figure with a
476
+ * drive's name is describing a number the server did not send.
477
+ *
478
+ * ⚠ Returns null rather than throwing on ANY failure — a server without the
479
+ * route (404), an app token with no person behind it (403) or an older build
480
+ * must leave the panel exactly as it was, not put an error where a status
481
+ * line goes.
482
+ */
483
+ async function quotaMe(): Promise<QuotaSnapshot | null> {
484
+ const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/quota/me');
485
+ try {
486
+ const q = await jsonFetch<QuotaSnapshot>(base);
487
+ return q && typeof q.used_bytes === 'number' ? q : null;
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+
457
493
  async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
458
494
  return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
459
495
  }
@@ -624,11 +660,22 @@ export function useFileApi(config: ExplorerConfig) {
624
660
  */
625
661
  async function fetchBlob(
626
662
  path: string,
663
+ opts: { fresh?: boolean } = {},
627
664
  ): Promise<{ url: string; blob: Blob; mime: string }> {
628
665
  const headers = await authHeaders();
629
666
  const res = await fetch(previewUrl(path), {
630
667
  headers,
631
668
  credentials: credentialsMode(),
669
+ // ⚠ The preview endpoint answers `Cache-Control: private, max-age=60`,
670
+ // which is right for the thing it was built for — a viewer re-opening
671
+ // an image — and wrong for anything the client treats as CONTROL data.
672
+ // `.filex-e2e.json` is control data: after the client rewrites it
673
+ // (recovery upgrade, escrow slot, escrow refusal) the next read inside
674
+ // that minute came back PRE-write, so the folder looked un-upgraded
675
+ // and the question filex had just been answered was asked again.
676
+ // Measured 2026-09-05 in a real browser: decline the escrow offer,
677
+ // reopen the folder, and the offer was back.
678
+ cache: opts.fresh ? 'no-store' : 'default',
632
679
  });
633
680
  if (!res.ok) {
634
681
  const text = await res.text().catch(() => '');
@@ -687,6 +734,38 @@ export function useFileApi(config: ExplorerConfig) {
687
734
  return jsonFetch<Capabilities>(endpoints.capabilities);
688
735
  }
689
736
 
737
+ /* wiring:e2 — escrow use is announced, not merely performed.
738
+ *
739
+ * Ask the server for a nonce sealed to the escrow public key; only the
740
+ * holder of the private half can read it back. Returning it is what earns
741
+ * the notification to the folder's owner — a bare "I used escrow" POST
742
+ * would be a string anyone could send.
743
+ *
744
+ * ⚠ This is an announcement, not a gate. An operator holding the private
745
+ * key can decrypt the folder offline with a script and never come here.
746
+ * docs/E2E-ENCRYPTION.md says so plainly and must keep saying so. */
747
+ async function e2eEscrowChallenge(
748
+ path: string,
749
+ ): Promise<{ id: string; challenge: string; kid: string }> {
750
+ if (!endpoints.e2eEscrowChallenge) throw new Error('e2e escrow endpoint not configured');
751
+ return jsonFetch(endpoints.e2eEscrowChallenge, {
752
+ method: 'POST',
753
+ body: JSON.stringify({ path }),
754
+ });
755
+ }
756
+
757
+ async function e2eEscrowUsed(payload: {
758
+ path: string;
759
+ id: string;
760
+ nonce: string;
761
+ }): Promise<{ ok: boolean; notified: boolean }> {
762
+ if (!endpoints.e2eEscrowUsed) throw new Error('e2e escrow endpoint not configured');
763
+ return jsonFetch(endpoints.e2eEscrowUsed, {
764
+ method: 'POST',
765
+ body: JSON.stringify(payload),
766
+ });
767
+ }
768
+
690
769
  async function createShare(payload: {
691
770
  path: string;
692
771
  password?: boolean;
@@ -850,6 +929,7 @@ export function useFileApi(config: ExplorerConfig) {
850
929
  index,
851
930
  search,
852
931
  globalSearch /* bul:s3 */,
932
+ quotaMe /* surucu:d1 */,
853
933
  subfolders,
854
934
  newFolder,
855
935
  rename,
@@ -869,6 +949,9 @@ export function useFileApi(config: ExplorerConfig) {
869
949
  // Peripheral
870
950
  limits,
871
951
  capabilities,
952
+ /* wiring:e2 */
953
+ e2eEscrowChallenge,
954
+ e2eEscrowUsed,
872
955
  createShare,
873
956
  listShares,
874
957
  revokeShare,
@@ -38,6 +38,8 @@ export interface ShortcutHandlers {
38
38
  onShowHelp?: () => void; // ? (Shift+/ on most layouts)
39
39
  onToggleInspector?: () => void; // i (koru:k1 details panel)
40
40
  onToggleHidden?: () => void; // Ctrl+Shift+. — dot-file visibility
41
+ /* yildiz:s1 */
42
+ onStar?: () => void; // S — star / unstar the selection
41
43
  onQuickLook?: () => void; // Space (wiring:c2 quick-look overlay)
42
44
  /* wiring:d1 — tab strip actions */
43
45
  onTabNew?: () => void; // Ctrl+T
@@ -87,6 +89,10 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
87
89
  { id: 'inspector', defaultCombo: 'I', labelKey: 'shortcuts.inspector', groupKey: 'shortcuts.group.nav' } /* koru:k1 */,
88
90
  { id: 'help', defaultCombo: '?', labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
89
91
  { id: 'toggle-hidden', defaultCombo: 'Ctrl+Shift+.', labelKey: 'shortcuts.toggle_hidden', groupKey: 'shortcuts.group.nav' },
92
+ /* yildiz:s1 — starring is an action, so it belongs where the other verbs
93
+ * are. A bare letter like the inspector's `I`; the handler ignores events
94
+ * from form controls, so it never eats a keystroke meant for a filename. */
95
+ { id: 'star', defaultCombo: 'S', labelKey: 'shortcuts.star', groupKey: 'shortcuts.group.file' },
90
96
  // Selection
91
97
  { id: 'select-all', defaultCombo: 'Ctrl+A', labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
92
98
  // File operations
@@ -115,6 +121,7 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
115
121
  close: 'onClose',
116
122
  inspector: 'onToggleInspector',
117
123
  'toggle-hidden': 'onToggleHidden',
124
+ star: 'onStar' /* yildiz:s1 */,
118
125
  help: 'onShowHelp',
119
126
  'select-all': 'onSelectAll',
120
127
  rename: 'onRename',